Chunk Word Companion manuscript scans
This commit is contained in:
parent
d2449a4683
commit
d32de17dcc
@ -1,8 +1,10 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.IO.Compression;
|
||||||
using Microsoft.AspNetCore.Http.Metadata;
|
using Microsoft.AspNetCore.Http.Metadata;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PlotLine.Controllers;
|
using PlotLine.Controllers;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@ -150,6 +152,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
|
("Character discovery rejects sentence-start words", CharacterDiscoveryRejectsSentenceStartWords),
|
||||||
("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames),
|
("Character discovery rejects fragments and location names", CharacterDiscoveryRejectsFragmentsAndLocationNames),
|
||||||
("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates),
|
("Manuscript scan preview keeps only auto-selected character candidates", ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates),
|
||||||
|
("Word Companion scan completion uses chunked transport", WordCompanionScanCompletionUsesChunkedTransport),
|
||||||
|
("Word Companion scan chunks assemble full novel payloads", WordCompanionScanChunksAssembleFullNovelPayloads),
|
||||||
|
("Word Companion scan chunks reject partial and conflicting payloads", WordCompanionScanChunksRejectPartialAndConflictingPayloads),
|
||||||
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
|
("Onboarding wizard keeps character pages inside one main step", OnboardingWizardKeepsCharacterPagesInsideOneMainStep),
|
||||||
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
("Scan review aliases feed Story Intelligence known character context", ScanReviewAliasesFeedStoryIntelligenceKnownCharacterContext),
|
||||||
("Name Intelligence package contract is present", NameIntelligencePackageContractIsPresent),
|
("Name Intelligence package contract is present", NameIntelligencePackageContractIsPresent),
|
||||||
@ -3624,6 +3629,108 @@ static void ManuscriptScanPreviewKeepsOnlyAutoSelectedCharacterCandidates()
|
|||||||
Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates.");
|
Assert(candidates[0].Name == "Colin", "Scan preview should keep probable character candidates.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void WordCompanionScanCompletionUsesChunkedTransport()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
||||||
|
var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs"));
|
||||||
|
var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs"));
|
||||||
|
var store = File.ReadAllText(Path.Combine(root, "Services/ManuscriptScanPreviewStore.cs"));
|
||||||
|
var monitor = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceMonitor.cs"));
|
||||||
|
var presence = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceService.cs"));
|
||||||
|
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
|
||||||
|
|
||||||
|
Assert(hostScript.Contains("const onboardingScanChunkSize = 128 * 1024", StringComparison.Ordinal), "Word Companion scan result chunks should remain well below the SignalR receive limit.");
|
||||||
|
Assert(hostScript.Contains("completeOnboardingScanInChunks(command, preview)", StringComparison.Ordinal), "Word Companion should upload scan completion through chunked transport.");
|
||||||
|
Assert(!hostScript.Contains("invoke(\"CompleteOnboardingScan\", preview)", StringComparison.Ordinal), "Word Companion should not send full manuscript previews as one SignalR message.");
|
||||||
|
Assert(hub.Contains("ReceiveOnboardingScanChunk", StringComparison.Ordinal), "Follow hub should expose a chunk receive method.");
|
||||||
|
Assert(models.Contains("public sealed class ManuscriptScanChunk", StringComparison.Ordinal), "Scan chunk model should be explicit.");
|
||||||
|
Assert(models.Contains("public Guid? ScanID", StringComparison.Ordinal), "Scan state should expose a scan ID for correlation.");
|
||||||
|
Assert(store.Contains("A duplicate scan result chunk did not match the original payload.", StringComparison.Ordinal), "Chunk store should reject conflicting duplicate chunks.");
|
||||||
|
Assert(store.Contains("HasRecentRunningScanForUserAsync", StringComparison.Ordinal), "Scan store should expose active scan state for connection semantics.");
|
||||||
|
Assert(monitor.Contains("ActiveScanHeartbeatGrace", StringComparison.Ordinal), "Presence monitor should treat long-running scans as a distinct active state.");
|
||||||
|
Assert(monitor.Contains("scanStore.HasRecentRunningScanForUserAsync", StringComparison.Ordinal), "Presence monitor should not mark an active scanning companion offline just because normal heartbeat is quiet.");
|
||||||
|
Assert(presence.Contains("Func<int, Task<bool>>? keepConnected", StringComparison.Ordinal), "Presence stale checks should support scan-aware keep-connected semantics.");
|
||||||
|
Assert(program.Contains("options.MaximumReceiveMessageSize = 1024 * 1024", StringComparison.Ordinal), "The fix should not depend on globally raising the SignalR receive limit.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WordCompanionScanChunksAssembleFullNovelPayloads()
|
||||||
|
{
|
||||||
|
var preview = BuildSyntheticScanPreview(chapters: 42, paragraphsPerChapter: 58, wordsPerParagraph: 82, includeUnicode: true);
|
||||||
|
var json = JsonSerializer.Serialize(preview, ManuscriptScanJsonOptions());
|
||||||
|
var compressedSize = GZip(Encoding.UTF8.GetBytes(json)).Length;
|
||||||
|
Assert(preview.TotalWordCount >= 200_000, $"Synthetic full-novel fixture should exceed 200k words; got {preview.TotalWordCount}.");
|
||||||
|
Assert(json.Length > 1024 * 1024, $"Synthetic full-novel scan JSON should exceed the old 1 MB SignalR cap; got {json.Length} chars.");
|
||||||
|
Assert(compressedSize > 0 && compressedSize < Encoding.UTF8.GetByteCount(json), "Compressed size measurement should be valid and smaller than raw JSON.");
|
||||||
|
|
||||||
|
var store = BuildScanStore();
|
||||||
|
var state = store.StartAsync(preview.UserID, preview.OnboardingID, preview.ProjectID, preview.BookID).GetAwaiter().GetResult();
|
||||||
|
var chunks = SplitScanChunks(state.ScanID!.Value, preview.OnboardingID, json, 128 * 1024);
|
||||||
|
foreach (var chunk in chunks)
|
||||||
|
{
|
||||||
|
state = store.AddChunkAsync(preview.UserID, chunk).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert(state.Status == ManuscriptScanStatuses.Complete, "Final chunk should complete the full-novel scan.");
|
||||||
|
Assert(state.TotalWordCount == preview.TotalWordCount, "Full-novel scan should preserve word count.");
|
||||||
|
Assert(state.ChapterCount == preview.ChapterCount, "Full-novel scan should preserve chapter count.");
|
||||||
|
var stored = store.GetLatestPreviewAsync(preview.UserID, preview.OnboardingID).GetAwaiter().GetResult();
|
||||||
|
Assert(stored is not null, "Completed chunked scan should persist a preview.");
|
||||||
|
Assert(stored!.Chapters.Count == preview.Chapters.Count, "Chunked scan should preserve all chapters.");
|
||||||
|
Assert(stored.Chapters.Sum(chapter => chapter.SourceParagraphs.Count) == preview.Chapters.Sum(chapter => chapter.SourceParagraphs.Count), "Chunked scan should preserve all source paragraphs.");
|
||||||
|
Assert(stored.Chapters.SelectMany(chapter => chapter.SourceParagraphs).Any(paragraph => paragraph.Text.Contains("Rosie's", StringComparison.Ordinal)), "Chunked scan should preserve Unicode punctuation.");
|
||||||
|
|
||||||
|
var longParagraphPreview = BuildSyntheticScanPreview(chapters: 1, paragraphsPerChapter: 1, wordsPerParagraph: 200_000, includeUnicode: true);
|
||||||
|
var longParagraphJson = JsonSerializer.Serialize(longParagraphPreview, ManuscriptScanJsonOptions());
|
||||||
|
var longParagraphStore = BuildScanStore();
|
||||||
|
var longParagraphState = longParagraphStore.StartAsync(longParagraphPreview.UserID, longParagraphPreview.OnboardingID, longParagraphPreview.ProjectID, longParagraphPreview.BookID).GetAwaiter().GetResult();
|
||||||
|
foreach (var chunk in SplitScanChunks(longParagraphState.ScanID!.Value, longParagraphPreview.OnboardingID, longParagraphJson, 128 * 1024))
|
||||||
|
{
|
||||||
|
longParagraphState = longParagraphStore.AddChunkAsync(longParagraphPreview.UserID, chunk).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert(longParagraphState.Status == ManuscriptScanStatuses.Complete, "Chunked scan should support a large individual paragraph.");
|
||||||
|
Assert(longParagraphState.TotalWordCount >= 200_000, "Large individual paragraph fixture should preserve full word count.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void WordCompanionScanChunksRejectPartialAndConflictingPayloads()
|
||||||
|
{
|
||||||
|
var preview = BuildSyntheticScanPreview(chapters: 6, paragraphsPerChapter: 20, wordsPerParagraph: 70, includeUnicode: false);
|
||||||
|
var json = JsonSerializer.Serialize(preview, ManuscriptScanJsonOptions());
|
||||||
|
var store = BuildScanStore();
|
||||||
|
var state = store.StartAsync(preview.UserID, preview.OnboardingID, preview.ProjectID, preview.BookID).GetAwaiter().GetResult();
|
||||||
|
var chunks = SplitScanChunks(state.ScanID!.Value, preview.OnboardingID, json, 4096);
|
||||||
|
Assert(chunks.Count > 2, "Six-chapter synthetic fixture should span multiple small test chunks.");
|
||||||
|
|
||||||
|
var partialState = store.AddChunkAsync(preview.UserID, chunks[0]).GetAwaiter().GetResult();
|
||||||
|
Assert(partialState.Status == ManuscriptScanStatuses.Running, "A partial chunk set must not be accepted as complete.");
|
||||||
|
var duplicateState = store.AddChunkAsync(preview.UserID, chunks[0]).GetAwaiter().GetResult();
|
||||||
|
Assert(duplicateState.Status == ManuscriptScanStatuses.Running, "An exact duplicate chunk retry should be idempotent.");
|
||||||
|
|
||||||
|
var conflicting = new ManuscriptScanChunk
|
||||||
|
{
|
||||||
|
ScanID = chunks[0].ScanID,
|
||||||
|
UserID = chunks[0].UserID,
|
||||||
|
OnboardingID = chunks[0].OnboardingID,
|
||||||
|
ChunkIndex = chunks[0].ChunkIndex,
|
||||||
|
TotalChunks = chunks[0].TotalChunks,
|
||||||
|
TotalLength = chunks[0].TotalLength,
|
||||||
|
Payload = chunks[0].Payload + "x"
|
||||||
|
};
|
||||||
|
AssertThrows(() => store.AddChunkAsync(preview.UserID, conflicting).GetAwaiter().GetResult(), "Conflicting duplicate chunks should be rejected.");
|
||||||
|
var staleScanChunk = new ManuscriptScanChunk
|
||||||
|
{
|
||||||
|
ScanID = Guid.NewGuid(),
|
||||||
|
UserID = chunks[1].UserID,
|
||||||
|
OnboardingID = chunks[1].OnboardingID,
|
||||||
|
ChunkIndex = chunks[1].ChunkIndex,
|
||||||
|
TotalChunks = chunks[1].TotalChunks,
|
||||||
|
TotalLength = chunks[1].TotalLength,
|
||||||
|
Payload = chunks[1].Payload
|
||||||
|
};
|
||||||
|
AssertThrows(() => store.AddChunkAsync(preview.UserID, staleScanChunk).GetAwaiter().GetResult(), "Chunks from a stale scan ID should be rejected.");
|
||||||
|
}
|
||||||
|
|
||||||
static void CharacterDiscoveryRejectsFragmentsAndLocationNames()
|
static void CharacterDiscoveryRejectsFragmentsAndLocationNames()
|
||||||
{
|
{
|
||||||
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
var method = typeof(WordCompanionService).GetMethod("DiscoverCharacterCandidates", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
@ -5909,6 +6016,20 @@ static void Assert(bool condition, string message)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void AssertThrows(Action action, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
static string ExtractMethod(string source, string methodName)
|
static string ExtractMethod(string source, string methodName)
|
||||||
{
|
{
|
||||||
var signature = $" {methodName}(";
|
var signature = $" {methodName}(";
|
||||||
@ -5937,6 +6058,126 @@ static string ExtractMethod(string source, string methodName)
|
|||||||
throw new InvalidOperationException($"{methodName} body was not closed.");
|
throw new InvalidOperationException($"{methodName} body was not closed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ManuscriptScanPreview BuildSyntheticScanPreview(int chapters, int paragraphsPerChapter, int wordsPerParagraph, bool includeUnicode)
|
||||||
|
{
|
||||||
|
var chapterPreviews = new List<ManuscriptScanChapterPreview>();
|
||||||
|
var paragraphIndex = 0;
|
||||||
|
var totalWords = 0;
|
||||||
|
for (var chapterIndex = 1; chapterIndex <= chapters; chapterIndex++)
|
||||||
|
{
|
||||||
|
var paragraphTexts = new List<string>();
|
||||||
|
var paragraphMap = new List<ManuscriptScanParagraphMap>();
|
||||||
|
var sourceParagraphs = new List<ManuscriptScanSourceParagraph>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
WordParagraphIndex = paragraphIndex,
|
||||||
|
Text = $"Chapter {chapterIndex}",
|
||||||
|
IsChapterHeading = true,
|
||||||
|
IncludeInSourceText = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var chapterStart = paragraphIndex;
|
||||||
|
paragraphIndex++;
|
||||||
|
|
||||||
|
for (var paragraphOrdinal = 1; paragraphOrdinal <= paragraphsPerChapter; paragraphOrdinal++)
|
||||||
|
{
|
||||||
|
var sentence = includeUnicode && paragraphOrdinal % 17 == 0
|
||||||
|
? "Rosie's mum Mary said, \"Don't panic\" as the rain tapped against the window."
|
||||||
|
: "Maggie watched the road while Beth remembered Grace and Rebecca counted the passing houses.";
|
||||||
|
var text = string.Join(' ', Enumerable.Repeat(sentence, Math.Max(1, wordsPerParagraph / 13)));
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
|
||||||
|
totalWords += words;
|
||||||
|
paragraphTexts.Add(text);
|
||||||
|
paragraphMap.Add(new ManuscriptScanParagraphMap
|
||||||
|
{
|
||||||
|
StructuralParagraphOrdinal = paragraphOrdinal,
|
||||||
|
WordParagraphIndex = paragraphIndex,
|
||||||
|
Text = text
|
||||||
|
});
|
||||||
|
sourceParagraphs.Add(new ManuscriptScanSourceParagraph
|
||||||
|
{
|
||||||
|
WordParagraphIndex = paragraphIndex,
|
||||||
|
Text = text,
|
||||||
|
IncludeInSourceText = true
|
||||||
|
});
|
||||||
|
paragraphIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
chapterPreviews.Add(new ManuscriptScanChapterPreview
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = $"chapter-{chapterIndex}",
|
||||||
|
ChapterNumber = chapterIndex,
|
||||||
|
Title = $"Chapter {chapterIndex}",
|
||||||
|
WordCount = paragraphTexts.Sum(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length),
|
||||||
|
ChapterText = string.Join(Environment.NewLine + Environment.NewLine, paragraphTexts),
|
||||||
|
StartPosition = chapterStart,
|
||||||
|
ParagraphMap = paragraphMap,
|
||||||
|
SourceParagraphs = sourceParagraphs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ManuscriptScanPreview
|
||||||
|
{
|
||||||
|
UserID = 1,
|
||||||
|
OnboardingID = 100,
|
||||||
|
ProjectID = 200,
|
||||||
|
BookID = 300,
|
||||||
|
Source = ManuscriptScanSources.WordCompanion,
|
||||||
|
DocumentTitle = "Synthetic full novel",
|
||||||
|
CompanionDocumentIdentifier = Guid.NewGuid().ToString("N"),
|
||||||
|
TotalWordCount = totalWords,
|
||||||
|
ChapterCount = chapterPreviews.Count,
|
||||||
|
CreatedUtc = DateTime.UtcNow,
|
||||||
|
Chapters = chapterPreviews
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ManuscriptScanChunk> SplitScanChunks(Guid scanId, int onboardingId, string payload, int chunkSize)
|
||||||
|
{
|
||||||
|
var chunks = new List<ManuscriptScanChunk>();
|
||||||
|
var totalChunks = Math.Max(1, (int)Math.Ceiling(payload.Length / (double)chunkSize));
|
||||||
|
for (var chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++)
|
||||||
|
{
|
||||||
|
var start = chunkIndex * chunkSize;
|
||||||
|
chunks.Add(new ManuscriptScanChunk
|
||||||
|
{
|
||||||
|
ScanID = scanId,
|
||||||
|
UserID = 1,
|
||||||
|
OnboardingID = onboardingId,
|
||||||
|
ChunkIndex = chunkIndex,
|
||||||
|
TotalChunks = totalChunks,
|
||||||
|
TotalLength = payload.Length,
|
||||||
|
Payload = payload.Substring(start, Math.Min(chunkSize, payload.Length - start))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ManuscriptScanPreviewStore BuildScanStore()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection().BuildServiceProvider();
|
||||||
|
return new ManuscriptScanPreviewStore(services.GetRequiredService<IServiceScopeFactory>(), NullLogger<ManuscriptScanPreviewStore>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] GZip(byte[] payload)
|
||||||
|
{
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
using (var gzip = new GZipStream(output, CompressionLevel.Fastest))
|
||||||
|
{
|
||||||
|
gzip.Write(payload, 0, payload.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonSerializerOptions ManuscriptScanJsonOptions()
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
static StoryIntelligenceClient BuildStoryIntelligenceClient(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
static StoryIntelligenceClient BuildStoryIntelligenceClient(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||||
=> new(
|
=> new(
|
||||||
new HttpClient(new CaptureImageRequestHandler(respond)),
|
new HttpClient(new CaptureImageRequestHandler(respond)),
|
||||||
|
|||||||
@ -154,8 +154,17 @@ public sealed class WordCompanionFollowHub(
|
|||||||
|
|
||||||
var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value);
|
var scanState = await scanStore.StartAsync(userId, state.UserOnboardingStateID, state.ProjectID.Value, state.BookID.Value);
|
||||||
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState);
|
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanStateChanged", scanState);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Word Companion onboarding scan requested. UserID={UserID} OnboardingID={OnboardingID} ProjectID={ProjectID} BookID={BookID} ScanID={ScanID} CompanionConnectionID={CompanionConnectionID}",
|
||||||
|
userId,
|
||||||
|
state.UserOnboardingStateID,
|
||||||
|
state.ProjectID.Value,
|
||||||
|
state.BookID.Value,
|
||||||
|
scanState.ScanID,
|
||||||
|
companionConnectionId);
|
||||||
await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand
|
await Clients.Client(companionConnectionId).SendAsync("ScanCurrentDocument", new ManuscriptScanCommand
|
||||||
{
|
{
|
||||||
|
ScanID = scanState.ScanID ?? Guid.NewGuid(),
|
||||||
UserID = userId,
|
UserID = userId,
|
||||||
OnboardingID = state.UserOnboardingStateID,
|
OnboardingID = state.UserOnboardingStateID,
|
||||||
ProjectID = state.ProjectID.Value,
|
ProjectID = state.ProjectID.Value,
|
||||||
@ -165,6 +174,31 @@ public sealed class WordCompanionFollowHub(
|
|||||||
return scanState;
|
return scanState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ManuscriptScanState> ReceiveOnboardingScanChunk(ManuscriptScanChunk chunk)
|
||||||
|
{
|
||||||
|
var userId = RequireUserId();
|
||||||
|
var scanState = await scanStore.AddChunkAsync(userId, chunk);
|
||||||
|
if (string.Equals(scanState.Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Word Companion onboarding scan completed from chunked payload. UserID={UserID} OnboardingID={OnboardingID} ScanID={ScanID} Chunks={Chunks} PayloadCharacters={PayloadCharacters} Words={Words} Chapters={Chapters}",
|
||||||
|
userId,
|
||||||
|
chunk.OnboardingID,
|
||||||
|
chunk.ScanID,
|
||||||
|
chunk.TotalChunks,
|
||||||
|
chunk.TotalLength,
|
||||||
|
scanState.TotalWordCount,
|
||||||
|
scanState.ChapterCount);
|
||||||
|
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanCompleted", scanState);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingScanProgress", scanState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanState;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ManuscriptScanState> ReportOnboardingScanProgress(ManuscriptScanProgress progress)
|
public async Task<ManuscriptScanState> ReportOnboardingScanProgress(ManuscriptScanProgress progress)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
|
|||||||
@ -24,6 +24,7 @@ public static class ManuscriptScanReviewStatuses
|
|||||||
|
|
||||||
public sealed class ManuscriptScanCommand
|
public sealed class ManuscriptScanCommand
|
||||||
{
|
{
|
||||||
|
public Guid ScanID { get; init; }
|
||||||
public int UserID { get; init; }
|
public int UserID { get; init; }
|
||||||
public int OnboardingID { get; init; }
|
public int OnboardingID { get; init; }
|
||||||
public int ProjectID { get; init; }
|
public int ProjectID { get; init; }
|
||||||
@ -50,6 +51,17 @@ public sealed class ManuscriptScanFailure
|
|||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanChunk
|
||||||
|
{
|
||||||
|
public Guid ScanID { get; init; }
|
||||||
|
public int? UserID { get; init; }
|
||||||
|
public int OnboardingID { get; init; }
|
||||||
|
public int ChunkIndex { get; init; }
|
||||||
|
public int TotalChunks { get; init; }
|
||||||
|
public int TotalLength { get; init; }
|
||||||
|
public string Payload { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanPreview
|
public sealed class ManuscriptScanPreview
|
||||||
{
|
{
|
||||||
public Guid PreviewID { get; init; } = Guid.NewGuid();
|
public Guid PreviewID { get; init; } = Guid.NewGuid();
|
||||||
@ -138,6 +150,7 @@ public sealed class ManuscriptScanCharacterEvidenceScene
|
|||||||
|
|
||||||
public sealed class ManuscriptScanState
|
public sealed class ManuscriptScanState
|
||||||
{
|
{
|
||||||
|
public Guid? ScanID { get; init; }
|
||||||
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
||||||
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
|
|
||||||
namespace PlotLine.Services;
|
namespace PlotLine.Services;
|
||||||
@ -8,9 +9,11 @@ public interface IManuscriptScanPreviewStore
|
|||||||
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
|
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
|
||||||
Task ClearAsync(int userId, int onboardingId);
|
Task ClearAsync(int userId, int onboardingId);
|
||||||
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
|
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
|
||||||
|
Task<ManuscriptScanState> AddChunkAsync(int userId, ManuscriptScanChunk chunk);
|
||||||
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
|
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
|
||||||
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
|
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message);
|
||||||
Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message);
|
Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message);
|
||||||
|
Task<bool> HasRecentRunningScanForUserAsync(int userId, TimeSpan maxAge);
|
||||||
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
|
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
|
||||||
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
|
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
|
||||||
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
||||||
@ -36,6 +39,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
{
|
{
|
||||||
var session = new ScanSession
|
var session = new ScanSession
|
||||||
{
|
{
|
||||||
|
ScanID = Guid.NewGuid(),
|
||||||
UserID = userId,
|
UserID = userId,
|
||||||
OnboardingID = onboardingId,
|
OnboardingID = onboardingId,
|
||||||
ProjectID = projectId,
|
ProjectID = projectId,
|
||||||
@ -65,6 +69,11 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
Status = ManuscriptScanStatuses.Running
|
Status = ManuscriptScanStatuses.Running
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (string.Equals(session.Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Task.FromResult(ToState(session));
|
||||||
|
}
|
||||||
|
|
||||||
session.Status = ManuscriptScanStatuses.Running;
|
session.Status = ManuscriptScanStatuses.Running;
|
||||||
session.Message = Clean(progress.Message, "Scanning manuscript");
|
session.Message = Clean(progress.Message, "Scanning manuscript");
|
||||||
session.PercentComplete = progress.PercentComplete;
|
session.PercentComplete = progress.PercentComplete;
|
||||||
@ -76,6 +85,73 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
return Task.FromResult(ToState(session));
|
return Task.FromResult(ToState(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<ManuscriptScanState> AddChunkAsync(int userId, ManuscriptScanChunk chunk)
|
||||||
|
{
|
||||||
|
if (chunk.ScanID == Guid.Empty
|
||||||
|
|| chunk.OnboardingID <= 0
|
||||||
|
|| chunk.ChunkIndex < 0
|
||||||
|
|| chunk.TotalChunks <= 0
|
||||||
|
|| chunk.ChunkIndex >= chunk.TotalChunks
|
||||||
|
|| chunk.TotalLength <= 0
|
||||||
|
|| string.IsNullOrEmpty(chunk.Payload))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The scan result chunk was invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessions.TryGetValue((userId, chunk.OnboardingID), out var session)
|
||||||
|
|| !string.Equals(session.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal)
|
||||||
|
|| session.ScanID != chunk.ScanID)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The scan result no longer matches an active scan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.ExpectedChunkCount.HasValue && session.ExpectedChunkCount.Value != chunk.TotalChunks
|
||||||
|
|| session.ExpectedPayloadLength.HasValue && session.ExpectedPayloadLength.Value != chunk.TotalLength)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The scan result chunk metadata changed during upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.ExpectedChunkCount = chunk.TotalChunks;
|
||||||
|
session.ExpectedPayloadLength = chunk.TotalLength;
|
||||||
|
if (session.Chunks.TryGetValue(chunk.ChunkIndex, out var existingPayload))
|
||||||
|
{
|
||||||
|
if (!string.Equals(existingPayload, chunk.Payload, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("A duplicate scan result chunk did not match the original payload.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
session.Chunks[chunk.ChunkIndex] = chunk.Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Message = $"Receiving manuscript scan result ({session.Chunks.Count:N0}/{chunk.TotalChunks:N0})";
|
||||||
|
session.PercentComplete = Math.Min(99, 95 + (int)Math.Floor(session.Chunks.Count * 4m / chunk.TotalChunks));
|
||||||
|
session.UpdatedUtc = DateTime.UtcNow;
|
||||||
|
if (session.Chunks.Count < chunk.TotalChunks)
|
||||||
|
{
|
||||||
|
return Task.FromResult(ToState(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < chunk.TotalChunks; index++)
|
||||||
|
{
|
||||||
|
if (!session.Chunks.ContainsKey(index))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The scan result is missing one or more chunks.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = string.Concat(Enumerable.Range(0, chunk.TotalChunks).Select(index => session.Chunks[index]));
|
||||||
|
if (payload.Length != chunk.TotalLength)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The assembled scan result length did not match the expected length.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview = JsonSerializer.Deserialize<ManuscriptScanPreview>(payload, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException("The assembled scan result could not be read.");
|
||||||
|
return CompleteAsync(userId, preview);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview)
|
public Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview)
|
||||||
{
|
{
|
||||||
var stored = preview.WithUser(userId);
|
var stored = preview.WithUser(userId);
|
||||||
@ -98,6 +174,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
session.TotalWordCount = stored.TotalWordCount;
|
session.TotalWordCount = stored.TotalWordCount;
|
||||||
session.PreviewID = stored.PreviewID;
|
session.PreviewID = stored.PreviewID;
|
||||||
session.UpdatedUtc = stored.CreatedUtc;
|
session.UpdatedUtc = stored.CreatedUtc;
|
||||||
|
session.Chunks.Clear();
|
||||||
return Task.FromResult(ToState(session));
|
return Task.FromResult(ToState(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +188,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
session.Status = ManuscriptScanStatuses.Failed;
|
session.Status = ManuscriptScanStatuses.Failed;
|
||||||
session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion.");
|
session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion.");
|
||||||
session.UpdatedUtc = DateTime.UtcNow;
|
session.UpdatedUtc = DateTime.UtcNow;
|
||||||
|
session.Chunks.Clear();
|
||||||
return Task.FromResult(ToState(session));
|
return Task.FromResult(ToState(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,12 +205,23 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
pair.Value.Status = ManuscriptScanStatuses.Failed;
|
pair.Value.Status = ManuscriptScanStatuses.Failed;
|
||||||
pair.Value.Message = Clean(message, "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
|
pair.Value.Message = Clean(message, "The Word Companion disconnected before the scan finished. Reopen Word and try again.");
|
||||||
pair.Value.UpdatedUtc = DateTime.UtcNow;
|
pair.Value.UpdatedUtc = DateTime.UtcNow;
|
||||||
|
pair.Value.Chunks.Clear();
|
||||||
failed.Add(ToState(pair.Value));
|
failed.Add(ToState(pair.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<IReadOnlyList<ManuscriptScanState>>(failed);
|
return Task.FromResult<IReadOnlyList<ManuscriptScanState>>(failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> HasRecentRunningScanForUserAsync(int userId, TimeSpan maxAge)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
return Task.FromResult(sessions.Any(pair =>
|
||||||
|
pair.Key.UserId == userId
|
||||||
|
&& string.Equals(pair.Value.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal)
|
||||||
|
&& pair.Value.UpdatedUtc.HasValue
|
||||||
|
&& now - pair.Value.UpdatedUtc.Value <= maxAge));
|
||||||
|
}
|
||||||
|
|
||||||
public Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId)
|
public Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId)
|
||||||
=> Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session)
|
=> Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session)
|
||||||
? ToState(session)
|
? ToState(session)
|
||||||
@ -277,6 +366,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
private static ManuscriptScanState ToState(ScanSession session)
|
private static ManuscriptScanState ToState(ScanSession session)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
|
ScanID = session.ScanID,
|
||||||
Status = session.Status,
|
Status = session.Status,
|
||||||
ReviewStatus = session.ReviewStatus,
|
ReviewStatus = session.ReviewStatus,
|
||||||
Message = session.Message,
|
Message = session.Message,
|
||||||
@ -349,6 +439,7 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
|
|
||||||
private sealed class ScanSession
|
private sealed class ScanSession
|
||||||
{
|
{
|
||||||
|
public Guid ScanID { get; set; }
|
||||||
public int UserID { get; init; }
|
public int UserID { get; init; }
|
||||||
public int OnboardingID { get; init; }
|
public int OnboardingID { get; init; }
|
||||||
public int ProjectID { get; init; }
|
public int ProjectID { get; init; }
|
||||||
@ -363,7 +454,15 @@ public sealed class ManuscriptScanPreviewStore(
|
|||||||
public int TotalWordCount { get; set; }
|
public int TotalWordCount { get; set; }
|
||||||
public Guid? PreviewID { get; set; }
|
public Guid? PreviewID { get; set; }
|
||||||
public DateTime? UpdatedUtc { get; set; }
|
public DateTime? UpdatedUtc { get; set; }
|
||||||
|
public int? ExpectedChunkCount { get; set; }
|
||||||
|
public int? ExpectedPayloadLength { get; set; }
|
||||||
|
public SortedDictionary<int, string> Chunks { get; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
file static class ManuscriptScanPreviewExtensions
|
file static class ManuscriptScanPreviewExtensions
|
||||||
|
|||||||
@ -10,6 +10,8 @@ public sealed class WordCompanionPresenceMonitor(
|
|||||||
IManuscriptScanPreviewStore scanStore,
|
IManuscriptScanPreviewStore scanStore,
|
||||||
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
|
IHubContext<WordCompanionFollowHub> hub) : BackgroundService
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan ActiveScanHeartbeatGrace = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||||
@ -17,7 +19,8 @@ public sealed class WordCompanionPresenceMonitor(
|
|||||||
{
|
{
|
||||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
var offlineStatuses = await presence.MarkStaleOfflineAsync();
|
var offlineStatuses = await presence.MarkStaleOfflineAsync(userId =>
|
||||||
|
scanStore.HasRecentRunningScanForUserAsync(userId, ActiveScanHeartbeatGrace));
|
||||||
foreach (var status in offlineStatuses)
|
foreach (var status in offlineStatuses)
|
||||||
{
|
{
|
||||||
await hub.Clients
|
await hub.Clients
|
||||||
|
|||||||
@ -11,7 +11,7 @@ public interface IWordCompanionPresenceService
|
|||||||
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
||||||
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
|
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
|
||||||
Task<bool> IsConnectedAsync(int userId);
|
Task<bool> IsConnectedAsync(int userId);
|
||||||
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
|
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync(Func<int, Task<bool>>? keepConnected = null);
|
||||||
Task<string?> GetCompanionConnectionIdAsync(int userId);
|
Task<string?> GetCompanionConnectionIdAsync(int userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +141,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
: null);
|
: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync()
|
public async Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync(Func<int, Task<bool>>? keepConnected = null)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var changed = new List<WordCompanionPresenceStatus>();
|
var changed = new List<WordCompanionPresenceStatus>();
|
||||||
@ -154,6 +154,11 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keepConnected is not null && await keepConnected(record.UserID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
record.Status = WordCompanionPresenceStatuses.Offline;
|
record.Status = WordCompanionPresenceStatuses.Offline;
|
||||||
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
|
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
|
||||||
{
|
{
|
||||||
@ -164,7 +169,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|||||||
changed.Add(ToStatus(record, now));
|
changed.Add(ToStatus(record, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<IReadOnlyList<WordCompanionPresenceStatus>>(changed);
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WordCompanionPresenceStatus ToStatus(PresenceRecord record, DateTime now)
|
private static WordCompanionPresenceStatus ToStatus(PresenceRecord record, DateTime now)
|
||||||
|
|||||||
@ -235,6 +235,7 @@
|
|||||||
let runtimeGeneration = 0;
|
let runtimeGeneration = 0;
|
||||||
let lastCurrentSceneSnapshot = null;
|
let lastCurrentSceneSnapshot = null;
|
||||||
const runtimeIdleDelayMs = 1200;
|
const runtimeIdleDelayMs = 1200;
|
||||||
|
const onboardingScanChunkSize = 128 * 1024;
|
||||||
let selectionHandlerRegistered = false;
|
let selectionHandlerRegistered = false;
|
||||||
let isAutoRefreshingScene = false;
|
let isAutoRefreshingScene = false;
|
||||||
let sceneWordCountSyncTimer = null;
|
let sceneWordCountSyncTimer = null;
|
||||||
@ -2487,6 +2488,51 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const completeOnboardingScanInChunks = async (command, preview) => {
|
||||||
|
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
|
||||||
|
throw new Error("The Word Companion connection is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify(preview);
|
||||||
|
const totalChunks = Math.max(1, Math.ceil(payload.length / onboardingScanChunkSize));
|
||||||
|
const scanID = scanCommandValue(command, "scanID", "ScanID", "scanId");
|
||||||
|
if (!scanID) {
|
||||||
|
throw new Error("The scan request did not include a scan ID.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[Word Companion Scan] Uploading chunked scan result.", {
|
||||||
|
scanID,
|
||||||
|
totalChunks,
|
||||||
|
payloadCharacters: payload.length,
|
||||||
|
payloadBytes: new Blob([payload]).size
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
|
||||||
|
const start = chunkIndex * onboardingScanChunkSize;
|
||||||
|
const chunkPayload = payload.slice(start, start + onboardingScanChunkSize);
|
||||||
|
if (chunkIndex === 0 || chunkIndex === totalChunks - 1 || chunkIndex % 10 === 0) {
|
||||||
|
await reportOnboardingScanProgress(
|
||||||
|
command,
|
||||||
|
`Uploading scan result (${chunkIndex + 1}/${totalChunks})`,
|
||||||
|
Math.min(99, 95 + Math.floor(((chunkIndex + 1) * 4) / totalChunks)),
|
||||||
|
preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await companionPresenceConnection.invoke("ReceiveOnboardingScanChunk", {
|
||||||
|
scanID,
|
||||||
|
userID: scanCommandValue(command, "userID", "UserID", "userId"),
|
||||||
|
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID", "onboardingId"),
|
||||||
|
chunkIndex,
|
||||||
|
totalChunks,
|
||||||
|
totalLength: payload.length,
|
||||||
|
payload: chunkPayload
|
||||||
|
});
|
||||||
|
if (state?.status === "Failed" || state?.Status === "Failed") {
|
||||||
|
throw new Error(state?.message || state?.Message || "The scan result upload failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runOnboardingDocumentScan = async (command) => {
|
const runOnboardingDocumentScan = async (command) => {
|
||||||
if (!wordHostAvailable || !window.Word) {
|
if (!wordHostAvailable || !window.Word) {
|
||||||
await failOnboardingScan(command, "Open your manuscript in Microsoft Word, then try the scan again.");
|
await failOnboardingScan(command, "Open your manuscript in Microsoft Word, then try the scan again.");
|
||||||
@ -2494,7 +2540,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const scanStartedAt = Date.now();
|
||||||
|
console.info("[Word Companion Scan] Starting onboarding document scan.", {
|
||||||
|
scanID: scanCommandValue(command, "scanID", "ScanID", "scanId"),
|
||||||
|
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID", "onboardingId")
|
||||||
|
});
|
||||||
await reportOnboardingScanProgress(command, "Preparing document scan", 5);
|
await reportOnboardingScanProgress(command, "Preparing document scan", 5);
|
||||||
|
const extractionStartedAt = Date.now();
|
||||||
const paragraphs = await window.Word.run(async (context) => {
|
const paragraphs = await window.Word.run(async (context) => {
|
||||||
const bodyParagraphs = context.document.body.paragraphs;
|
const bodyParagraphs = context.document.body.paragraphs;
|
||||||
bodyParagraphs.load("items/text,items/style,items/styleBuiltIn");
|
bodyParagraphs.load("items/text,items/style,items/styleBuiltIn");
|
||||||
@ -2509,12 +2561,28 @@
|
|||||||
await context.sync();
|
await context.sync();
|
||||||
return bodyParagraphs.items;
|
return bodyParagraphs.items;
|
||||||
});
|
});
|
||||||
|
console.info("[Word Companion Scan] Word extraction completed.", {
|
||||||
|
scanID: scanCommandValue(command, "scanID", "ScanID", "scanId"),
|
||||||
|
paragraphCount: paragraphs.length,
|
||||||
|
durationMs: Date.now() - extractionStartedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewStartedAt = Date.now();
|
||||||
const preview = buildOnboardingScanPreview(paragraphs, command);
|
const preview = buildOnboardingScanPreview(paragraphs, command);
|
||||||
|
console.info("[Word Companion Scan] Preview built.", {
|
||||||
|
scanID: scanCommandValue(command, "scanID", "ScanID", "scanId"),
|
||||||
|
chapterCount: preview.chapterCount,
|
||||||
|
totalWordCount: preview.totalWordCount,
|
||||||
|
durationMs: Date.now() - previewStartedAt
|
||||||
|
});
|
||||||
await reportOnboardingScanProgress(command, "Preparing chapter review", 85, preview);
|
await reportOnboardingScanProgress(command, "Preparing chapter review", 85, preview);
|
||||||
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
|
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
|
||||||
lastOnboardingScanPreview = preview;
|
lastOnboardingScanPreview = preview;
|
||||||
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
|
await completeOnboardingScanInChunks(command, preview);
|
||||||
|
console.info("[Word Companion Scan] Onboarding document scan uploaded.", {
|
||||||
|
scanID: scanCommandValue(command, "scanID", "ScanID", "scanId"),
|
||||||
|
durationMs: Date.now() - scanStartedAt
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Unable to complete onboarding scan.", error);
|
console.error("Unable to complete onboarding scan.", error);
|
||||||
await failOnboardingScan(command, friendlyScanError(error));
|
await failOnboardingScan(command, friendlyScanError(error));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user