PlotDirector/PlotLine/Services/ManuscriptScanPreviewStore.cs

491 lines
21 KiB
C#

using System.Collections.Concurrent;
using System.Text.Json;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IManuscriptScanPreviewStore
{
Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId);
Task ClearAsync(int userId, int onboardingId);
Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress);
Task<ManuscriptScanState> AddChunkAsync(int userId, ManuscriptScanChunk chunk);
Task<ManuscriptScanState> CompleteAsync(int userId, ManuscriptScanPreview preview);
Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, 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<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates);
Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
}
public sealed class ManuscriptScanPreviewStore(
IServiceScopeFactory scopeFactory,
ILogger<ManuscriptScanPreviewStore> logger) : IManuscriptScanPreviewStore
{
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanReviewDecision> reviews = new();
private readonly ConcurrentDictionary<Guid, OnboardingManuscriptBuildResult> buildResults = new();
private readonly ConcurrentDictionary<Guid, OnboardingBuildProgress> buildProgress = new();
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
{
var session = new ScanSession
{
ScanID = Guid.NewGuid(),
UserID = userId,
OnboardingID = onboardingId,
ProjectID = projectId,
BookID = bookId,
Status = ManuscriptScanStatuses.Running,
Message = "Preparing document scan",
PercentComplete = 0,
UpdatedUtc = DateTime.UtcNow
};
sessions[(userId, onboardingId)] = session;
return Task.FromResult(ToState(session));
}
public Task ClearAsync(int userId, int onboardingId)
{
sessions.TryRemove((userId, onboardingId), out _);
return Task.CompletedTask;
}
public Task<ManuscriptScanState> ProgressAsync(int userId, ManuscriptScanProgress progress)
{
var key = (userId, progress.OnboardingID);
var session = sessions.GetOrAdd(key, _ => new ScanSession
{
UserID = userId,
OnboardingID = progress.OnboardingID,
Status = ManuscriptScanStatuses.Running
});
if (string.Equals(session.Status, ManuscriptScanStatuses.Complete, StringComparison.Ordinal))
{
return Task.FromResult(ToState(session));
}
session.Status = ManuscriptScanStatuses.Running;
session.Message = Clean(progress.Message, "Scanning manuscript");
session.PercentComplete = progress.PercentComplete;
session.ChapterCount = progress.ChapterCount;
session.SceneCount = progress.SceneCount;
session.CharacterCandidateCount = progress.CharacterCandidateCount;
session.TotalWordCount = progress.TotalWordCount;
session.UpdatedUtc = DateTime.UtcNow;
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)
{
var stored = preview.WithUser(userId);
previews[stored.PreviewID] = stored;
var session = sessions.GetOrAdd((userId, stored.OnboardingID), _ => new ScanSession
{
UserID = userId,
OnboardingID = stored.OnboardingID,
ProjectID = stored.ProjectID,
BookID = stored.BookID
});
session.Status = ManuscriptScanStatuses.Complete;
session.ReviewStatus = ManuscriptScanReviewStatuses.ScanComplete;
session.Message = "Scan complete";
session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount;
session.SceneCount = 0;
session.CharacterCandidateCount = 0;
session.TotalWordCount = stored.TotalWordCount;
session.PreviewID = stored.PreviewID;
session.UpdatedUtc = stored.CreatedUtc;
session.Chunks.Clear();
return Task.FromResult(ToState(session));
}
public Task<ManuscriptScanState> FailAsync(int userId, int onboardingId, string message)
{
var session = sessions.GetOrAdd((userId, onboardingId), _ => new ScanSession
{
UserID = userId,
OnboardingID = onboardingId
});
session.Status = ManuscriptScanStatuses.Failed;
session.Message = Clean(message, "The scan could not finish. Try reconnecting the Word Companion.");
session.UpdatedUtc = DateTime.UtcNow;
session.Chunks.Clear();
return Task.FromResult(ToState(session));
}
public Task<IReadOnlyList<ManuscriptScanState>> FailRunningForUserAsync(int userId, string message)
{
var failed = new List<ManuscriptScanState>();
foreach (var pair in sessions)
{
if (pair.Key.UserId != userId || !string.Equals(pair.Value.Status, ManuscriptScanStatuses.Running, StringComparison.Ordinal))
{
continue;
}
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.UpdatedUtc = DateTime.UtcNow;
pair.Value.Chunks.Clear();
failed.Add(ToState(pair.Value));
}
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)
=> Task.FromResult(sessions.TryGetValue((userId, onboardingId), out var session)
? ToState(session)
: new ManuscriptScanState());
public Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId)
{
if (!sessions.TryGetValue((userId, onboardingId), out var session) || !session.PreviewID.HasValue)
{
return Task.FromResult<ManuscriptScanPreview?>(null);
}
return GetPreviewAsync(userId, session.PreviewID.Value);
}
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
public async Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return null;
}
if (reviews.TryGetValue(previewId, out var review))
{
return review;
}
review = await BuildDefaultReviewAsync(preview);
var stored = reviews.GetOrAdd(previewId, review);
return stored;
}
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
{
if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
}
var stored = new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = review.Status,
GenerateSceneTitles = review.GenerateSceneTitles,
UpdatedUtc = DateTime.UtcNow,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = review.Characters
};
reviews[review.PreviewID] = stored;
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
{
session.ReviewStatus = stored.Status;
session.UpdatedUtc = stored.UpdatedUtc;
}
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
}
public Task<ManuscriptScanPreview?> SaveCharacterCandidatesAsync(int userId, Guid previewId, IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanPreview?>(null);
}
var cleaned = candidates
.Where(candidate => !string.IsNullOrWhiteSpace(candidate.Name))
.ToList();
var updated = new ManuscriptScanPreview
{
PreviewID = preview.PreviewID,
UserID = preview.UserID,
OnboardingID = preview.OnboardingID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
Source = preview.Source,
DocumentTitle = preview.DocumentTitle,
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = cleaned.Count,
CreatedUtc = preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = preview.Scenes,
CharacterCandidates = cleaned
};
previews[previewId] = updated;
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
{
session.CharacterCandidateCount = cleaned.Count;
session.UpdatedUtc = DateTime.UtcNow;
}
return Task.FromResult<ManuscriptScanPreview?>(updated);
}
public Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult(new OnboardingBuildProgress { Status = "Failed", Message = "The scan preview could not be found." });
}
var progress = new OnboardingBuildProgress
{
Status = result is null ? "Running" : "Complete",
Message = message,
PercentComplete = percentComplete,
ChaptersCreated = result?.ChaptersCreated ?? 0,
ScenesCreated = result?.ScenesCreated ?? 0,
CharactersCreated = (result?.CharactersCreated ?? 0) + (result?.CharactersReused ?? 0),
SceneAppearancesCreated = result?.SceneAppearancesCreated ?? 0,
PreviewID = previewId
};
buildProgress[previewId] = progress;
return Task.FromResult(progress);
}
public Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview)
&& preview.UserID == userId
&& buildResults.TryGetValue(previewId, out var result)
? result
: null);
public Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result)
{
if (!previews.TryGetValue(result.PreviewID, out var preview) || preview.UserID != userId)
{
return Task.FromResult<OnboardingManuscriptBuildResult?>(null);
}
buildResults[result.PreviewID] = result;
return Task.FromResult<OnboardingManuscriptBuildResult?>(result);
}
private static ManuscriptScanState ToState(ScanSession session)
=> new()
{
ScanID = session.ScanID,
Status = session.Status,
ReviewStatus = session.ReviewStatus,
Message = session.Message,
PercentComplete = session.PercentComplete,
ChapterCount = session.ChapterCount,
SceneCount = session.SceneCount,
CharacterCandidateCount = session.CharacterCandidateCount,
TotalWordCount = session.TotalWordCount,
PreviewID = session.PreviewID,
UpdatedUtc = session.UpdatedUtc
};
private static string Clean(string? value, string fallback)
{
var cleaned = (value ?? string.Empty).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
}
private async Task<ManuscriptScanReviewDecision> BuildDefaultReviewAsync(ManuscriptScanPreview preview)
{
return new ManuscriptScanReviewDecision
{
PreviewID = preview.PreviewID,
GenerateSceneTitles = true,
Status = ManuscriptScanReviewStatuses.ScanComplete,
Chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => new ManuscriptScanChapterReviewDecision
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
Include = !string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0,
Title = chapter.Title,
ChapterNumber = chapter.ChapterNumber
})
.ToList(),
Scenes = [],
Characters = preview.CharacterCandidates
.Select(candidate => new ManuscriptScanCharacterReviewDecision
{
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name,
Category = candidate.Category,
SexHint = CharacterSexHintService.Suggest(candidate.Name)
})
.ToList()
};
}
private async Task<IReadOnlyDictionary<string, string>> GetAiSexHintsAsync(
IReadOnlyList<ManuscriptScanCharacterCandidatePreview> candidates)
{
if (candidates.Count == 0)
{
return new Dictionary<string, string>();
}
try
{
using var scope = scopeFactory.CreateScope();
var hintService = scope.ServiceProvider.GetRequiredService<ICharacterSexHintAiService>();
return await hintService.SuggestAsync(candidates, CancellationToken.None);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not create AI character sex hints for manuscript scan review; using deterministic fallback hints.");
return new Dictionary<string, string>();
}
}
private sealed class ScanSession
{
public Guid ScanID { get; set; }
public int UserID { get; init; }
public int OnboardingID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
public string ReviewStatus { get; set; } = ManuscriptScanReviewStatuses.ScanComplete;
public string Message { get; set; } = string.Empty;
public int? PercentComplete { get; set; }
public int ChapterCount { get; set; }
public int SceneCount { get; set; }
public int CharacterCandidateCount { get; set; }
public int TotalWordCount { get; set; }
public Guid? PreviewID { 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
{
public static ManuscriptScanPreview WithUser(this ManuscriptScanPreview preview, int userId)
=> new()
{
PreviewID = preview.PreviewID == Guid.Empty ? Guid.NewGuid() : preview.PreviewID,
UserID = userId,
OnboardingID = preview.OnboardingID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
Source = preview.Source,
DocumentTitle = preview.DocumentTitle,
CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier,
TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount,
SceneCount = 0,
CharacterCandidateCount = 0,
CreatedUtc = preview.CreatedUtc == default ? DateTime.UtcNow : preview.CreatedUtc,
Chapters = preview.Chapters,
Scenes = [],
CharacterCandidates = []
};
}