diff --git a/PlotLine/Controllers/AdminController.cs b/PlotLine/Controllers/AdminController.cs index b76991f..677afa7 100644 --- a/PlotLine/Controllers/AdminController.cs +++ b/PlotLine/Controllers/AdminController.cs @@ -13,7 +13,9 @@ public sealed class AdminController( IFeatureRequestService featureRequests, IEmailService emails, IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics, - IStoryIntelligenceDryRunService storyIntelligenceDryRun) : Controller + IStoryIntelligenceDryRunService storyIntelligenceDryRun, + IChapterStructureDryRunService chapterStructureDryRun, + IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun) : Controller { [HttpGet("")] [HttpGet("index")] @@ -50,6 +52,32 @@ public sealed class AdminController( return View(await storyIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); } + [HttpGet("chapter-structure-test")] + public IActionResult ChapterStructureTest() + { + return View(chapterStructureDryRun.GetDefault()); + } + + [HttpPost("chapter-structure-test")] + [ValidateAntiForgeryToken] + public async Task ChapterStructureTest([Bind(Prefix = "Form")] ChapterStructureDryRunForm form, CancellationToken cancellationToken) + { + return View(await chapterStructureDryRun.ExecuteAsync(form, cancellationToken)); + } + + [HttpGet("chapter-story-intelligence-test")] + public IActionResult ChapterStoryIntelligenceTest() + { + return View(chapterStoryIntelligenceDryRun.GetDefault()); + } + + [HttpPost("chapter-story-intelligence-test")] + [ValidateAntiForgeryToken] + public async Task ChapterStoryIntelligenceTest([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken) + { + return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken)); + } + [HttpGet("feature-requests")] public async Task FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort) { diff --git a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md new file mode 100644 index 0000000..978b582 --- /dev/null +++ b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md @@ -0,0 +1,126 @@ +# Chapter Structure Prompt V1 + +## Runtime Use + +This prompt is intended to be loaded from disk and sent to the OpenAI API with minimal runtime substitution. + +Replace the insertion blocks marked with `{{...}}` before sending. + +## Prompt + +You are PlotDirector's Chapter Structure engine. + +You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, summarise scenes, create story entities or infer canon. + +Your only task is to identify likely scene boundaries in one chapter. + +## Operating Principles + +1. Treat the supplied chapter text as the authority. +2. Identify structural breaks, not story meaning. +3. Prefer slightly larger scenes over excessive fragmentation. +4. Do not split merely because there is a paragraph break. +5. Do not try to create perfect scenes. +6. Return JSON only. + +## Boundary Guidance + +A new scene normally begins when one or more of these occur: + +- significant location change; +- significant time change; +- viewpoint change; +- dramatic transition; +- clear narrative break. + +Do not create boundaries for ordinary paragraph changes, small action beats, dialogue turns or changes in emotional intensity alone. + +## Paragraph Numbering + +Treat the supplied chapter text as a sequence of non-empty paragraphs. + +The first non-empty paragraph is paragraph `1`. + +Each `startParagraph` and `endParagraph` must refer to these paragraph numbers. + +Scene boundaries must: + +- start at paragraph `1`; +- cover the chapter in order; +- not overlap; +- not leave gaps; +- use increasing `sceneNumber` values starting at `1`. + +## Output Requirements + +Return exactly one JSON object and no prose outside it. + +Return only these root properties: + +- `schemaVersion` +- `chapterSummary` +- `sceneBoundaries` + +Each `sceneBoundaries` item must contain exactly: + +- `sceneNumber` +- `startParagraph` +- `endParagraph` +- `confidence` +- `reason` + +Use confidence values from `0.0` to `1.0`. + +Use short factual reasons, such as: + +`Location changes from house to train.` + +Do not output: + +- characters; +- locations; +- assets; +- relationships; +- metrics; +- scene summaries; +- observations; +- continuity warnings; +- plot threads. + +## JSON Shape + +```json +{ + "schemaVersion": "1.0", + "chapterSummary": "Structural analysis of the supplied chapter.", + "sceneBoundaries": [ + { + "sceneNumber": 1, + "startParagraph": 1, + "endParagraph": 3, + "confidence": 0.86, + "reason": "Opening scene remains in the same place and time." + } + ] +} +``` + +If the chapter appears to contain only one scene, return one boundary covering all paragraphs. + +## Runtime Data Insertion + +The application will insert chapter context and chapter text below. + +### Supplied Chapter Context + +```json +{{CHAPTER_CONTEXT_JSON}} +``` + +### Chapter Text + +```text +{{CHAPTER_TEXT}} +``` + +Now return the Chapter Structure JSON object only. diff --git a/PlotLine/Models/ChapterStructureModels.cs b/PlotLine/Models/ChapterStructureModels.cs new file mode 100644 index 0000000..f3de63c --- /dev/null +++ b/PlotLine/Models/ChapterStructureModels.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PlotLine.Models; + +public sealed class ChapterStructureModel +{ + public string? SchemaVersion { get; init; } + public string? ChapterSummary { get; init; } + public List? SceneBoundaries { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} + +public sealed class ChapterSceneBoundary +{ + public int? SceneNumber { get; init; } + public int? StartParagraph { get; init; } + public int? EndParagraph { get; init; } + public decimal? Confidence { get; init; } + public string? Reason { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 19ae8c7..d413259 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -188,8 +188,11 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs new file mode 100644 index 0000000..61c154a --- /dev/null +++ b/PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs @@ -0,0 +1,350 @@ +using System.Diagnostics; +using System.Text.Json; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IChapterStoryIntelligenceDryRunService +{ + ChapterStoryIntelligenceDryRunViewModel GetDefault(); + Task ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken); +} + +public sealed class ChapterStoryIntelligenceDryRunService( + IStoryPromptRepository prompts, + IStoryPromptVersionService versions, + IStoryPromptBuilder scenePromptBuilder, + IStoryIntelligenceClient client, + IChapterStructureValidator chapterValidator, + IStorySceneValidator sceneValidator, + ILogger logger) : IChapterStoryIntelligenceDryRunService +{ + private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md"; + private const string ScenePromptFile = "Scene-Prompt-V1.md"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public ChapterStoryIntelligenceDryRunViewModel GetDefault() + => new(); + + public async Task ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken) + { + var model = new ChapterStoryIntelligenceDryRunViewModel { Form = form }; + var totalStopwatch = Stopwatch.StartNew(); + var chapterPromptVersion = versions.GetPromptVersion(ChapterPromptFile); + var scenePromptVersion = versions.GetPromptVersion(ScenePromptFile); + var clientStatus = client.GetConfigurationStatus(); + var paragraphs = SplitParagraphs(form.ChapterText); + var chapterContextJson = BuildChapterContextJson(form); + + try + { + if (paragraphs.Count == 0) + { + throw new InvalidOperationException("Enter fictional chapter text before running the combined pipeline test."); + } + + var chapterTemplate = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); + var chapterPrompt = BuildChapterPrompt(chapterTemplate, chapterContextJson, form.ChapterText); + var chapterResult = await client.ExecutePromptAsync(chapterPrompt, chapterPromptVersion, cancellationToken); + var chapterJson = ExtractOutputText(chapterResult.RawResponseText); + var parsedChapter = JsonSerializer.Deserialize(chapterJson, JsonOptions); + var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count); + var sceneBlocks = new List(); + + if (!chapterValidation.IsValid || parsedChapter?.SceneBoundaries is null) + { + totalStopwatch.Stop(); + var stoppedResult = new ChapterStoryIntelligenceDryRunResultViewModel + { + Success = false, + PipelineStopped = true, + StopReason = "Chapter Structure validation failed. Scene Intelligence was not run.", + ChapterPromptVersion = chapterPromptVersion, + ScenePromptVersion = scenePromptVersion, + Model = chapterResult.Model, + ChapterContextJson = chapterContextJson, + ParagraphCount = paragraphs.Count, + TotalDuration = FormatDuration(totalStopwatch.Elapsed), + TotalInputTokens = chapterResult.InputTokens, + TotalOutputTokens = chapterResult.OutputTokens, + ChapterDuration = FormatDuration(chapterResult.Duration), + ChapterRetryCount = chapterResult.RetryCount, + ChapterInputTokens = chapterResult.InputTokens, + ChapterOutputTokens = chapterResult.OutputTokens, + ChapterRawResponseText = chapterResult.RawResponseText, + ChapterJsonText = chapterJson, + ParsedChapter = parsedChapter, + ChapterValidation = chapterValidation, + SceneBlocks = sceneBlocks + }; + LogPipelineResult(stoppedResult); + model.Result = stoppedResult; + return model; + } + + var sceneTemplate = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken); + foreach (var boundary in parsedChapter.SceneBoundaries) + { + var block = BuildSceneBlock(form, paragraphs, boundary); + if (!block.SplitValid) + { + sceneBlocks.Add(block); + continue; + } + + var sceneContextJson = BuildSceneContextJson(form, boundary); + var completedScenePrompt = scenePromptBuilder.BuildPrompt(sceneTemplate, sceneContextJson, block.SceneText); + sceneBlocks.Add(await ExecuteSceneAsync(block, sceneContextJson, completedScenePrompt, scenePromptVersion, cancellationToken)); + } + + totalStopwatch.Stop(); + var finalResult = new ChapterStoryIntelligenceDryRunResultViewModel + { + Success = sceneBlocks.All(block => block.Success || !block.SplitValid), + PipelineStopped = false, + ChapterPromptVersion = chapterPromptVersion, + ScenePromptVersion = scenePromptVersion, + Model = chapterResult.Model, + ChapterContextJson = chapterContextJson, + ParagraphCount = paragraphs.Count, + TotalDuration = FormatDuration(totalStopwatch.Elapsed), + TotalInputTokens = SumTokens(chapterResult.InputTokens, sceneBlocks.Select(block => block.InputTokens)), + TotalOutputTokens = SumTokens(chapterResult.OutputTokens, sceneBlocks.Select(block => block.OutputTokens)), + ChapterDuration = FormatDuration(chapterResult.Duration), + ChapterRetryCount = chapterResult.RetryCount, + ChapterInputTokens = chapterResult.InputTokens, + ChapterOutputTokens = chapterResult.OutputTokens, + ChapterRawResponseText = chapterResult.RawResponseText, + ChapterJsonText = chapterJson, + ParsedChapter = parsedChapter, + ChapterValidation = chapterValidation, + SceneBlocks = sceneBlocks + }; + + LogPipelineResult(finalResult); + model.Result = finalResult; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + totalStopwatch.Stop(); + model.Result = new ChapterStoryIntelligenceDryRunResultViewModel + { + Success = false, + PipelineStopped = true, + StopReason = "The combined pipeline could not complete.", + ChapterPromptVersion = chapterPromptVersion, + ScenePromptVersion = scenePromptVersion, + Model = clientStatus.Model, + ChapterContextJson = chapterContextJson, + ParagraphCount = paragraphs.Count, + TotalDuration = FormatDuration(totalStopwatch.Elapsed), + ErrorMessage = ex.Message + }; + + logger.LogWarning( + ex, + "Admin Chapter Story Intelligence pipeline failed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDurationMs={TotalDurationMs} Success={Success}", + chapterPromptVersion, + scenePromptVersion, + clientStatus.Model, + totalStopwatch.ElapsedMilliseconds, + false); + } + + return model; + } + + private async Task ExecuteSceneAsync( + ChapterStorySceneBlockViewModel block, + string sceneContextJson, + string completedScenePrompt, + string scenePromptVersion, + CancellationToken cancellationToken) + { + try + { + var result = await client.ExecutePromptAsync(completedScenePrompt, scenePromptVersion, cancellationToken); + var sceneJson = ExtractOutputText(result.RawResponseText); + var parsedScene = JsonSerializer.Deserialize(sceneJson, JsonOptions); + var validation = sceneValidator.Validate(parsedScene); + + logger.LogInformation( + "Admin Chapter Story Intelligence scene completed. TemporarySceneNumber={TemporarySceneNumber} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}", + block.TemporarySceneNumber, + result.Duration.TotalMilliseconds, + result.InputTokens, + result.OutputTokens, + validation.IsValid, + validation.Warnings.Count, + validation.Errors.Count, + true); + + return block with + { + Success = true, + SceneContextJson = sceneContextJson, + Duration = FormatDuration(result.Duration), + RetryCount = result.RetryCount, + InputTokens = result.InputTokens, + OutputTokens = result.OutputTokens, + RawResponseText = result.RawResponseText, + SceneJsonText = sceneJson, + ParsedScene = parsedScene, + Validation = validation + }; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning( + ex, + "Admin Chapter Story Intelligence scene failed. TemporarySceneNumber={TemporarySceneNumber} Success={Success}", + block.TemporarySceneNumber, + false); + + return block with + { + Success = false, + SceneContextJson = sceneContextJson, + ErrorMessage = ex.Message + }; + } + } + + private static ChapterStorySceneBlockViewModel BuildSceneBlock( + ChapterStoryIntelligenceDryRunForm form, + IReadOnlyList paragraphs, + ChapterSceneBoundary boundary) + { + 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 new ChapterStorySceneBlockViewModel + { + TemporarySceneNumber = sceneNumber, + StartParagraph = start, + EndParagraph = end, + BoundaryReason = boundary.Reason ?? string.Empty, + BoundaryConfidence = boundary.Confidence, + SplitValid = false, + SplitErrorMessage = "Scene boundary range is invalid for the submitted chapter paragraphs." + }; + } + + var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); + var sceneText = string.Join(Environment.NewLine + Environment.NewLine, sceneParagraphs); + return new ChapterStorySceneBlockViewModel + { + TemporarySceneNumber = sceneNumber, + StartParagraph = start, + EndParagraph = end, + BoundaryReason = boundary.Reason ?? string.Empty, + BoundaryConfidence = boundary.Confidence, + SceneText = sceneText, + SceneTextPreview = Preview(sceneText), + SplitValid = true, + SceneContextJson = BuildSceneContextJson(form, boundary) + }; + } + + private static string BuildChapterContextJson(ChapterStoryIntelligenceDryRunForm form) + => JsonSerializer.Serialize(new + { + projectId = form.ProjectID, + bookId = form.BookID, + chapterId = form.ChapterID, + chapterNumber = form.ChapterNumber, + sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim() + }, JsonOptions); + + private static string BuildSceneContextJson(ChapterStoryIntelligenceDryRunForm form, ChapterSceneBoundary boundary) + => JsonSerializer.Serialize(new + { + projectId = form.ProjectID, + bookId = form.BookID, + chapterId = form.ChapterID, + sceneId = (int?)null, + chapterNumber = form.ChapterNumber, + sceneNumber = boundary.SceneNumber, + sourceLabel = $"{(string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin pipeline test chapter" : form.SourceLabel.Trim())}, suggested scene {boundary.SceneNumber}" + }, JsonOptions); + + private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) + { + if (string.IsNullOrWhiteSpace(promptTemplate)) + { + throw new InvalidOperationException("Prompt template is empty."); + } + + return promptTemplate + .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) + .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal); + } + + private static string ExtractOutputText(string rawResponseText) + { + var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) + ?? throw new JsonException("OpenAI response envelope was empty."); + var outputText = response.Output? + .SelectMany(item => item.Content ?? []) + .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(content.Text)) + ?.Text; + + if (string.IsNullOrWhiteSpace(outputText)) + { + throw new JsonException("OpenAI response did not contain output_text content."); + } + + return outputText.Trim(); + } + + private static List SplitParagraphs(string chapterText) + => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) + .Select(paragraph => paragraph.Trim()) + .Where(paragraph => !string.IsNullOrWhiteSpace(paragraph)) + .ToList(); + + private static string Preview(string value) + { + var cleaned = value.ReplaceLineEndings(" ").Trim(); + return cleaned.Length <= 260 ? cleaned : $"{cleaned[..260]}..."; + } + + private static int? SumTokens(int? first, IEnumerable rest) + { + var values = new[] { first }.Concat(rest).ToList(); + return values.Any(value => value.HasValue) ? values.Sum(value => value ?? 0) : null; + } + + private static string FormatDuration(TimeSpan duration) + => duration.TotalSeconds < 1 + ? $"{duration.TotalMilliseconds:0} ms" + : $"{duration.TotalSeconds:0.00} sec"; + + private void LogPipelineResult(ChapterStoryIntelligenceDryRunResultViewModel result) + { + logger.LogInformation( + "Admin Chapter Story Intelligence pipeline completed. ChapterPromptVersion={ChapterPromptVersion} ScenePromptVersion={ScenePromptVersion} Model={Model} TotalDuration={TotalDuration} TotalInputTokens={TotalInputTokens} TotalOutputTokens={TotalOutputTokens} ChapterValidationErrors={ChapterValidationErrors} ChapterValidationWarnings={ChapterValidationWarnings} SceneCount={SceneCount} SceneValidationErrors={SceneValidationErrors} SceneValidationWarnings={SceneValidationWarnings} Success={Success}", + result.ChapterPromptVersion, + result.ScenePromptVersion, + result.Model, + result.TotalDuration, + result.TotalInputTokens, + result.TotalOutputTokens, + result.ChapterValidation?.Errors.Count, + result.ChapterValidation?.Warnings.Count, + result.SceneBlocks.Count, + result.SceneBlocks.Sum(block => block.Validation?.Errors.Count ?? 0), + result.SceneBlocks.Sum(block => block.Validation?.Warnings.Count ?? 0), + result.Success); + } +} diff --git a/PlotLine/Services/ChapterStructureValidator.cs b/PlotLine/Services/ChapterStructureValidator.cs new file mode 100644 index 0000000..5262753 --- /dev/null +++ b/PlotLine/Services/ChapterStructureValidator.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using PlotLine.Models; +using PlotLine.Models.StoryIntelligence; + +namespace PlotLine.Services; + +public interface IChapterStructureValidator +{ + ValidationResult Validate(ChapterStructureModel? chapterStructure, int paragraphCount); +} + +public sealed class ChapterStructureValidator : IChapterStructureValidator +{ + public ValidationResult Validate(ChapterStructureModel? chapterStructure, int paragraphCount) + { + var result = new ValidationResult(); + + try + { + if (chapterStructure is null) + { + AddError(result, "$", "Chapter Structure JSON could not be deserialised.", "Return one valid Chapter Structure JSON object."); + return result; + } + + if (paragraphCount <= 0) + { + AddError(result, "chapterText", "Chapter text must contain at least one non-empty paragraph.", "Enter chapter text with one or more paragraphs."); + } + + RequiredString(chapterStructure.SchemaVersion, "schemaVersion", result); + if (!string.Equals(chapterStructure.SchemaVersion, "1.0", StringComparison.Ordinal)) + { + AddError(result, "schemaVersion", "Schema version must be \"1.0\".", "Return schemaVersion as \"1.0\"."); + } + + RequiredString(chapterStructure.ChapterSummary, "chapterSummary", result); + if (chapterStructure.SceneBoundaries is null) + { + AddError(result, "sceneBoundaries", "Scene boundaries array is required.", "Return sceneBoundaries as an array."); + } + else + { + ValidateBoundaries(chapterStructure.SceneBoundaries, paragraphCount, result); + } + + Unknown(chapterStructure.ExtensionData, "$", result); + + if (result.IsValid) + { + AddInfo(result, "$", "Validation passed.", "No action needed."); + } + } + catch (Exception ex) + { + AddError(result, "$", $"Validator failed internally: {ex.Message}", "Review validator implementation for this response shape."); + } + + return result; + } + + private static void ValidateBoundaries(List boundaries, int paragraphCount, ValidationResult result) + { + if (boundaries.Count == 0) + { + AddError(result, "sceneBoundaries", "At least one scene boundary is required.", "Return one boundary covering the chapter if no scene split is supported."); + return; + } + + var expectedStart = 1; + for (var i = 0; i < boundaries.Count; i++) + { + var boundary = boundaries[i]; + var path = $"sceneBoundaries[{i}]"; + var expectedSceneNumber = i + 1; + + if (!boundary.SceneNumber.HasValue) + { + AddError(result, $"{path}.sceneNumber", "Scene number is required.", $"Return sceneNumber {expectedSceneNumber}."); + } + else if (boundary.SceneNumber.Value != expectedSceneNumber) + { + AddError(result, $"{path}.sceneNumber", "Scene numbers must be sequential from 1.", $"Use sceneNumber {expectedSceneNumber} for this boundary."); + } + + if (!boundary.StartParagraph.HasValue) + { + AddError(result, $"{path}.startParagraph", "Start paragraph is required.", "Return the first paragraph number for this boundary."); + } + + if (!boundary.EndParagraph.HasValue) + { + AddError(result, $"{path}.endParagraph", "End paragraph is required.", "Return the last paragraph number for this boundary."); + } + + if (boundary.StartParagraph.HasValue && boundary.EndParagraph.HasValue) + { + if (boundary.StartParagraph.Value > boundary.EndParagraph.Value) + { + AddError(result, path, "Start paragraph cannot be after end paragraph.", "Use an ordered inclusive paragraph range."); + } + + if (boundary.StartParagraph.Value != expectedStart) + { + AddError(result, $"{path}.startParagraph", "Scene boundaries must not overlap or leave gaps.", $"Use startParagraph {expectedStart}."); + } + + if (boundary.StartParagraph.Value < 1 || boundary.EndParagraph.Value < 1) + { + AddError(result, path, "Paragraph numbers must be 1 or greater.", "Use paragraph numbers from the submitted chapter text."); + } + + if (paragraphCount > 0 && boundary.EndParagraph.Value > paragraphCount) + { + AddError(result, $"{path}.endParagraph", "End paragraph exceeds the submitted chapter paragraph count.", $"Use a value from 1 to {paragraphCount}."); + } + + expectedStart = boundary.EndParagraph.Value + 1; + } + + Confidence(boundary.Confidence, $"{path}.confidence", result); + RequiredString(boundary.Reason, $"{path}.reason", result); + Unknown(boundary.ExtensionData, path, result); + } + + if (paragraphCount > 0 && expectedStart <= paragraphCount) + { + AddError(result, "sceneBoundaries", "Scene boundaries do not cover the full chapter.", $"Add a boundary ending at paragraph {paragraphCount}."); + } + } + + private static void Confidence(decimal? value, string path, ValidationResult result) + { + if (!value.HasValue) + { + AddError(result, path, "Confidence is required.", "Return a confidence value from 0.0 to 1.0."); + return; + } + + if (value < 0 || value > 1) + { + AddError(result, path, "Confidence must be from 0.0 to 1.0.", "Use the 0.0 to 1.0 confidence scale."); + } + } + + private static void RequiredString(string? value, string path, ValidationResult result) + { + if (value is null) + { + AddError(result, path, "Required string property is missing or null.", "Return a supported string value."); + } + else if (string.IsNullOrWhiteSpace(value)) + { + AddError(result, path, "Required string property is empty.", "Return a non-empty string supported by the chapter structure."); + } + } + + private static void Unknown(Dictionary? values, string path, ValidationResult result) + { + if (values is null || values.Count == 0) + { + return; + } + + foreach (var propertyName in values.Keys.Order(StringComparer.Ordinal)) + { + AddWarning(result, path == "$" ? propertyName : $"{path}.{propertyName}", "Unknown property is not part of Chapter Structure V1.", "Remove this property or update the schema version intentionally."); + } + } + + private static void AddError(ValidationResult result, string path, string message, string suggestedFix) + => result.Errors.Add(new ValidationIssue { Severity = "Error", Path = path, Message = message, SuggestedFix = suggestedFix }); + + private static void AddWarning(ValidationResult result, string path, string message, string suggestedFix) + => result.Warnings.Add(new ValidationIssue { Severity = "Warning", Path = path, Message = message, SuggestedFix = suggestedFix }); + + private static void AddInfo(ValidationResult result, string path, string message, string suggestedFix) + => result.Information.Add(new ValidationIssue { Severity = "Information", Path = path, Message = message, SuggestedFix = suggestedFix }); +} diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index 49cae92..a9149a8 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -43,6 +43,12 @@ public interface IStoryIntelligenceDryRunService Task ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken); } +public interface IChapterStructureDryRunService +{ + ChapterStructureDryRunViewModel GetDefault(); + Task ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken); +} + public sealed class StoryPromptRepository( IWebHostEnvironment environment, IMemoryCache cache, @@ -487,6 +493,172 @@ public sealed class StoryIntelligenceDryRunService( => JsonSerializer.Deserialize(sceneJson, JsonOptions); } +public sealed class ChapterStructureDryRunService( + IStoryPromptRepository prompts, + IStoryPromptVersionService versions, + IStoryIntelligenceClient client, + IChapterStructureValidator validator, + ILogger logger) : IChapterStructureDryRunService +{ + private const string ChapterPromptFile = "Chapter-Structure-Prompt-V1.md"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true + }; + + public ChapterStructureDryRunViewModel GetDefault() + => new(); + + public async Task ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken) + { + var model = new ChapterStructureDryRunViewModel { Form = form }; + var promptVersion = versions.GetPromptVersion(ChapterPromptFile); + var clientStatus = client.GetConfigurationStatus(); + var contextJson = BuildChapterContextJson(form); + var paragraphCount = CountParagraphs(form.ChapterText); + + try + { + if (string.IsNullOrWhiteSpace(form.ChapterText)) + { + throw new InvalidOperationException("Enter fictional chapter text before running the chapter structure test."); + } + + var template = await prompts.LoadPromptAsync(ChapterPromptFile, cancellationToken); + var completedPrompt = BuildChapterPrompt(template, contextJson, form.ChapterText); + var result = await client.ExecutePromptAsync(completedPrompt, promptVersion, cancellationToken); + var chapterJson = ExtractOutputText(result.RawResponseText); + var parsedChapter = DeserializeChapter(chapterJson); + var validation = validator.Validate(parsedChapter, paragraphCount); + + model.Result = new ChapterStructureDryRunResultViewModel + { + Success = true, + PromptVersion = promptVersion, + Model = result.Model, + ChapterContextJson = contextJson, + ParagraphCount = paragraphCount, + Duration = FormatDuration(result.Duration), + RetryCount = result.RetryCount, + InputTokens = result.InputTokens, + OutputTokens = result.OutputTokens, + RawResponseText = result.RawResponseText, + ChapterJsonText = chapterJson, + ParsedChapter = parsedChapter, + Validation = validation + }; + + logger.LogInformation( + "Admin Chapter Structure dry run completed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} RetryCount={RetryCount} InputTokens={InputTokens} OutputTokens={OutputTokens} ValidationPassed={ValidationPassed} ValidationWarnings={ValidationWarnings} ValidationErrors={ValidationErrors} Success={Success}", + promptVersion, + result.Model, + result.Duration.TotalMilliseconds, + result.RetryCount, + result.InputTokens, + result.OutputTokens, + validation.IsValid, + validation.Warnings.Count, + validation.Errors.Count, + true); + } + catch (JsonException ex) + { + model.Result = new ChapterStructureDryRunResultViewModel + { + Success = false, + PromptVersion = promptVersion, + Model = clientStatus.Model, + ChapterContextJson = contextJson, + ParagraphCount = paragraphCount, + ErrorMessage = $"Chapter Structure JSON could not be deserialised: {ex.Message}", + Validation = validator.Validate(null, paragraphCount) + }; + + logger.LogWarning( + ex, + "Admin Chapter Structure dry run deserialisation failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", + promptVersion, + clientStatus.Model, + false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + model.Result = new ChapterStructureDryRunResultViewModel + { + Success = false, + PromptVersion = promptVersion, + Model = clientStatus.Model, + ChapterContextJson = contextJson, + ParagraphCount = paragraphCount, + ErrorMessage = ex.Message + }; + + logger.LogWarning( + ex, + "Admin Chapter Structure dry run failed. PromptVersion={PromptVersion} Model={Model} Success={Success}", + promptVersion, + clientStatus.Model, + false); + } + + return model; + } + + private static string BuildChapterContextJson(ChapterStructureDryRunForm form) + => JsonSerializer.Serialize(new + { + projectId = form.ProjectID, + bookId = form.BookID, + chapterId = form.ChapterID, + chapterNumber = form.ChapterNumber, + sourceLabel = string.IsNullOrWhiteSpace(form.SourceLabel) ? "Admin test chapter" : form.SourceLabel.Trim() + }, JsonOptions); + + private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) + { + if (string.IsNullOrWhiteSpace(promptTemplate)) + { + throw new InvalidOperationException("Prompt template is empty."); + } + + return promptTemplate + .Replace("{{CHAPTER_CONTEXT_JSON}}", chapterContextJson ?? string.Empty, StringComparison.Ordinal) + .Replace("{{CHAPTER_TEXT}}", chapterText ?? string.Empty, StringComparison.Ordinal); + } + + private static string ExtractOutputText(string rawResponseText) + { + var response = JsonSerializer.Deserialize(rawResponseText, JsonOptions) + ?? throw new JsonException("OpenAI response envelope was empty."); + var outputText = response.Output? + .SelectMany(item => item.Content ?? []) + .FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(content.Text)) + ?.Text; + + if (string.IsNullOrWhiteSpace(outputText)) + { + throw new JsonException("OpenAI response did not contain output_text content."); + } + + return outputText.Trim(); + } + + private static ChapterStructureModel? DeserializeChapter(string chapterJson) + => JsonSerializer.Deserialize(chapterJson, JsonOptions); + + private static int CountParagraphs(string chapterText) + => chapterText.Split(["\r\n\r\n", "\n\n", "\r\r"], StringSplitOptions.RemoveEmptyEntries) + .Count(paragraph => !string.IsNullOrWhiteSpace(paragraph)); + + private static string FormatDuration(TimeSpan duration) + => duration.TotalSeconds < 1 + ? $"{duration.TotalMilliseconds:0} ms" + : $"{duration.TotalSeconds:0.00} sec"; +} + internal sealed class OpenAIResponseEnvelope { public List? Output { get; init; } diff --git a/PlotLine/ViewModels/FeatureRequestViewModels.cs b/PlotLine/ViewModels/FeatureRequestViewModels.cs index d2ebdac..b8a768d 100644 --- a/PlotLine/ViewModels/FeatureRequestViewModels.cs +++ b/PlotLine/ViewModels/FeatureRequestViewModels.cs @@ -142,6 +142,136 @@ public sealed class StoryIntelligenceDryRunResultViewModel public string? ErrorMessage { get; init; } } +public sealed class ChapterStructureDryRunViewModel +{ + public ChapterStructureDryRunForm Form { get; set; } = ChapterStructureDryRunForm.Default(); + public ChapterStructureDryRunResultViewModel? Result { get; set; } +} + +public sealed class ChapterStructureDryRunForm +{ + public int? ProjectID { get; set; } + public int? BookID { get; set; } + public int? ChapterID { get; set; } + public decimal? ChapterNumber { get; set; } + public string SourceLabel { get; set; } = "Admin test chapter"; + public string ChapterText { get; set; } = string.Empty; + + public static ChapterStructureDryRunForm Default() => new() + { + ProjectID = null, + BookID = null, + ChapterID = null, + ChapterNumber = 1, + SourceLabel = "Admin test chapter", + ChapterText = string.Join( + Environment.NewLine + Environment.NewLine, + [ + "Mara waited in the shuttered kitchen while rain tapped against the narrow windows. She counted the coins in her palm and listened for the baker's cart.", + "Elias arrived before dawn with mud on his boots and a warning folded into his sleeve. He said the bridge guards had doubled their watch.", + "They argued in whispers over the cold stove until Mara decided they would leave by the canal instead.", + "By noon the rain had stopped and the city gate was three miles behind them. The canal boat moved slowly through reeds bright with summer light.", + "Elias watched the towpath and admitted he had seen the captain speaking with the bridge guards the night before.", + "That evening, in an abandoned lockkeeper's cottage, Mara found the captain's seal pressed into a scrap of blue wax.", + "She set it beside the warning and understood that someone had wanted them away from the bridge.", + "Outside, a horse stopped on the road, and both of them went silent." + ]) + }; +} + +public sealed class ChapterStructureDryRunResultViewModel +{ + public bool Success { get; init; } + public string PromptVersion { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public string ChapterContextJson { get; init; } = string.Empty; + public int ParagraphCount { get; init; } + public string Duration { get; init; } = string.Empty; + public int RetryCount { get; init; } + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public string RawResponseText { get; init; } = string.Empty; + public string ChapterJsonText { get; init; } = string.Empty; + public ChapterStructureModel? ParsedChapter { get; init; } + public PlotLine.Models.StoryIntelligence.ValidationResult? Validation { get; init; } + public string? ErrorMessage { get; init; } +} + +public sealed class ChapterStoryIntelligenceDryRunViewModel +{ + public ChapterStoryIntelligenceDryRunForm Form { get; set; } = ChapterStoryIntelligenceDryRunForm.Default(); + public ChapterStoryIntelligenceDryRunResultViewModel? Result { get; set; } +} + +public sealed class ChapterStoryIntelligenceDryRunForm +{ + public int? ProjectID { get; set; } + public int? BookID { get; set; } + public int? ChapterID { get; set; } + public decimal? ChapterNumber { get; set; } + public string SourceLabel { get; set; } = "Admin pipeline test chapter"; + public string ChapterText { get; set; } = string.Empty; + + public static ChapterStoryIntelligenceDryRunForm Default() => new() + { + ProjectID = null, + BookID = null, + ChapterID = null, + ChapterNumber = 1, + SourceLabel = "Admin pipeline test chapter", + ChapterText = ChapterStructureDryRunForm.Default().ChapterText + }; +} + +public sealed class ChapterStoryIntelligenceDryRunResultViewModel +{ + public bool Success { get; init; } + public bool PipelineStopped { get; init; } + public string StopReason { get; init; } = string.Empty; + public string ChapterPromptVersion { get; init; } = string.Empty; + public string ScenePromptVersion { get; init; } = string.Empty; + public string Model { get; init; } = string.Empty; + public string ChapterContextJson { get; init; } = string.Empty; + public int ParagraphCount { get; init; } + public string TotalDuration { get; init; } = string.Empty; + public int? TotalInputTokens { get; init; } + public int? TotalOutputTokens { get; init; } + public string ChapterDuration { get; init; } = string.Empty; + public int ChapterRetryCount { get; init; } + public int? ChapterInputTokens { get; init; } + public int? ChapterOutputTokens { get; init; } + public string ChapterRawResponseText { get; init; } = string.Empty; + public string ChapterJsonText { get; init; } = string.Empty; + public ChapterStructureModel? ParsedChapter { get; init; } + public PlotLine.Models.StoryIntelligence.ValidationResult? ChapterValidation { get; init; } + public IReadOnlyList SceneBlocks { get; init; } = []; + public string? ErrorMessage { get; init; } +} + +public sealed record ChapterStorySceneBlockViewModel +{ + public int TemporarySceneNumber { get; init; } + public int StartParagraph { get; init; } + public int EndParagraph { get; init; } + public string BoundaryReason { get; init; } = string.Empty; + public decimal? BoundaryConfidence { get; init; } + public string SceneContextJson { get; init; } = string.Empty; + public string SceneText { get; init; } = string.Empty; + public string SceneTextPreview { get; init; } = string.Empty; + public bool SplitValid { get; init; } + public string SplitErrorMessage { get; init; } = string.Empty; + public bool Success { get; init; } + public string Duration { get; init; } = string.Empty; + public int RetryCount { get; init; } + public int? InputTokens { get; init; } + public int? OutputTokens { get; init; } + public string RawResponseText { get; init; } = string.Empty; + public string SceneJsonText { get; init; } = string.Empty; + public SceneIntelligenceScene? ParsedScene { get; init; } + public PlotLine.Models.StoryIntelligence.ValidationResult? Validation { get; init; } + public string? ErrorMessage { get; init; } +} + public sealed class AdminFeatureRequestFilter { public string? Status { get; set; } diff --git a/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml new file mode 100644 index 0000000..30d6698 --- /dev/null +++ b/PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml @@ -0,0 +1,329 @@ +@model ChapterStoryIntelligenceDryRunViewModel +@{ + ViewData["Title"] = "Chapter Story Intelligence Test"; + var result = Model.Result; +} + +
+
+

Admin utility

+

Chapter Story Intelligence Test

+

This in-memory dry run detects suggested scene boundaries and then runs Scene Intelligence sequentially for each suggested scene. It does not save data.

+
+
+ + + +
+

Manual combined pipeline test

+

Use fictional sample text only. This page runs Chapter Structure first, splits the submitted chapter into in-memory scene blocks, then runs Scene Intelligence for each block without creating PlotDirector scenes.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Back to diagnostics +
+
+
+ +@if (result is not null) +{ +
+

Combined pipeline result

+ @if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + { +
@result.ErrorMessage
+ } + @if (result.PipelineStopped) + { +
@result.StopReason
+ } + +
+
+
+

Model

+

@Display(result.Model)

+
+
+
+
+

Paragraphs

+

@result.ParagraphCount.ToString("N0")

+
+
+
+
+

Detected scenes

+

@result.SceneBlocks.Count.ToString("N0")

+
+
+
+
+

Total duration

+

@Display(result.TotalDuration)

+
+
+
+ +
+
Chapter prompt
+
@result.ChapterPromptVersion
+
Scene prompt
+
@result.ScenePromptVersion
+
Total input tokens
+
@Display(result.TotalInputTokens)
+
Total output tokens
+
@Display(result.TotalOutputTokens)
+
+
+ +
+

Chapter Structure

+
+
+
+

Duration

+

@Display(result.ChapterDuration)

+
+
+
+
+

Input tokens

+

@Display(result.ChapterInputTokens)

+
+
+
+
+

Output tokens

+

@Display(result.ChapterOutputTokens)

+
+
+
+ +

Validation

+ @if (result.ChapterValidation is null) + { +
Chapter Structure validation did not run.
+ } + else + { +

@ValidationSummary(result.ChapterValidation)

+ @RenderIssues("Errors", result.ChapterValidation.Errors) + @RenderIssues("Warnings", result.ChapterValidation.Warnings) + @RenderIssues("Information", result.ChapterValidation.Information) + } + +

Parsed Model

+ @if (result.ParsedChapter is null) + { +

No parsed Chapter Structure model is available.

+ } + else + { +

@Display(result.ParsedChapter.ChapterSummary)

+ @if (result.ParsedChapter.SceneBoundaries is not null) + { +
    + @foreach (var boundary in result.ParsedChapter.SceneBoundaries) + { +
  1. Scene @Display(boundary.SceneNumber): paragraphs @Display(boundary.StartParagraph)-@Display(boundary.EndParagraph), confidence @Display(boundary.Confidence). @Display(boundary.Reason)
  2. + } +
+ } + } + +

Raw JSON

+ + + + +
+ +
+

Detected Scene List

+ @if (result.SceneBlocks.Count == 0) + { +

No in-memory scene blocks were produced.

+ } + else + { +
    + @foreach (var block in result.SceneBlocks) + { +
  1. + Scene @Display(block.TemporarySceneNumber), paragraphs @Display(block.StartParagraph)-@Display(block.EndParagraph): + @Display(block.SceneTextPreview) +
  2. + } +
+ } +
+ + @foreach (var block in result.SceneBlocks) + { +
+

Suggested Scene @Display(block.TemporarySceneNumber)

+
+
Paragraph range
+
@Display(block.StartParagraph)-@Display(block.EndParagraph)
+
Boundary confidence
+
@Display(block.BoundaryConfidence)
+
Boundary reason
+
@Display(block.BoundaryReason)
+
Scene Intelligence duration
+
@Display(block.Duration)
+
Input tokens
+
@Display(block.InputTokens)
+
Output tokens
+
@Display(block.OutputTokens)
+
+ + @if (!block.SplitValid) + { +
@block.SplitErrorMessage
+ } + @if (!string.IsNullOrWhiteSpace(block.ErrorMessage)) + { +
@block.ErrorMessage
+ } + +

Scene Text Preview

+

@Display(block.SceneTextPreview)

+ +

Validation

+ @if (block.Validation is null) + { +

Scene validation did not run.

+ } + else + { +

@ValidationSummary(block.Validation)

+ @RenderIssues("Errors", block.Validation.Errors) + @RenderIssues("Warnings", block.Validation.Warnings) + } + +

Parsed Model

+ @if (block.ParsedScene is null) + { +

No parsed Scene Intelligence model is available.

+ } + else + { +
+
Summary
+
@Display(block.ParsedScene.Summary?.Short)
+
Setting
+
@Display(block.ParsedScene.Setting?.LocationName) (@Display(block.ParsedScene.Setting?.LocationType))
+
Characters
+
@Display(block.ParsedScene.Characters?.Count)
+
Locations
+
@Display(block.ParsedScene.Locations?.Count)
+
Assets
+
@Display(block.ParsedScene.Assets?.Count)
+
Observations
+
@Display(block.ParsedScene.Observations?.Count)
+
+ } + +

Raw Scene Intelligence Response

+ + + + +
+ } +} + +@functions { + private static string Display(object? value) => value switch + { + null => "None", + string text when string.IsNullOrWhiteSpace(text) => "None", + decimal number => number.ToString("0.##"), + bool flag => flag ? "Yes" : "No", + int number => number.ToString("N0"), + _ => value.ToString() ?? "None" + }; + + private static string ValidationSummary(PlotLine.Models.StoryIntelligence.ValidationResult validation) + => validation.IsValid + ? $"✓ Passed with {validation.Warnings.Count:N0} warning(s)" + : $"✖ Failed with {validation.Errors.Count:N0} error(s) and {validation.Warnings.Count:N0} warning(s)"; + + private static string SeveritySymbol(string severity) => severity switch + { + "Error" => "✖", + "Warning" => "⚠", + _ => "✓" + }; + + private static Microsoft.AspNetCore.Html.IHtmlContent RenderIssues(string heading, IReadOnlyList issues) + { + var builder = new System.Text.StringBuilder(); + builder.Append("
"); + builder.Append("

").Append(Encode(heading)).Append(" (").Append(issues.Count.ToString("N0")).Append(")

"); + if (issues.Count == 0) + { + builder.Append("

None.

"); + } + else + { + builder.Append("
"); + foreach (var issue in issues) + { + builder.Append("
"); + builder.Append("

") + .Append(Encode(SeveritySymbol(issue.Severity))) + .Append(' ') + .Append(Encode(issue.Severity)) + .Append(" ") + .Append(Encode(issue.Path)) + .Append("

"); + builder.Append("

").Append(Encode(issue.Message)).Append("

"); + builder.Append("

").Append(Encode(issue.SuggestedFix)).Append("

"); + builder.Append("
"); + } + + builder.Append("
"); + } + + builder.Append("
"); + return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString()); + } + + private static string Encode(string? value) + => System.Net.WebUtility.HtmlEncode(value ?? string.Empty); +} diff --git a/PlotLine/Views/Admin/ChapterStructureTest.cshtml b/PlotLine/Views/Admin/ChapterStructureTest.cshtml new file mode 100644 index 0000000..244728e --- /dev/null +++ b/PlotLine/Views/Admin/ChapterStructureTest.cshtml @@ -0,0 +1,248 @@ +@model ChapterStructureDryRunViewModel +@{ + ViewData["Title"] = "Chapter Intelligence Test"; + var result = Model.Result; +} + +
+
+

Admin utility

+

Chapter Intelligence Test

+

This is a structural dry run. It makes one live OpenAI request only when submitted, and it does not create scenes or save data.

+
+
+ + + +
+

Manual chapter structure test

+

Use fictional sample text only. This page loads Chapter-Structure-Prompt-V1.md, replaces runtime placeholders, asks for likely scene boundaries, and displays the raw response without saving it.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + Back to diagnostics +
+
+
+ +@if (result is not null) +{ +
+

Chapter structure result

+ @if (!result.Success) + { +
@result.ErrorMessage
+ } +
+
+
+

Prompt version

+

@result.PromptVersion

+
+
+
+
+

Model

+

@result.Model

+
+
+
+
+

Duration

+

@(string.IsNullOrWhiteSpace(result.Duration) ? "Not run" : result.Duration)

+
+
+
+
+

Paragraphs

+

@result.ParagraphCount.ToString("N0")

+
+
+
+
+
Input tokens
+
@(result.InputTokens.HasValue ? result.InputTokens.Value.ToString("N0") : "Not reported")
+
Output tokens
+
@(result.OutputTokens.HasValue ? result.OutputTokens.Value.ToString("N0") : "Not reported")
+
Retries
+
@result.RetryCount.ToString("N0")
+
+ + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ @if (result.Validation is null) + { +
Validation did not run.
+ } + else + { +

@ValidationSummary(result.Validation)

+ @RenderIssues("Errors", result.Validation.Errors) + @RenderIssues("Warnings", result.Validation.Warnings) + @RenderIssues("Information", result.Validation.Information) + } +
+ +
+ @if (result.ParsedChapter is null) + { +
No parsed Chapter Structure model is available.
+ } + else + { +
+

Chapter

+
+
Schema
+
@Display(result.ParsedChapter.SchemaVersion)
+
Summary
+
@Display(result.ParsedChapter.ChapterSummary)
+
+
+ +
+

Scene Boundaries

+ @if (result.ParsedChapter.SceneBoundaries is null || result.ParsedChapter.SceneBoundaries.Count == 0) + { +

No scene boundaries reported.

+ } + else + { +
    + @foreach (var boundary in result.ParsedChapter.SceneBoundaries) + { +
  1. + Scene @Display(boundary.SceneNumber): + paragraphs @Display(boundary.StartParagraph)-@Display(boundary.EndParagraph), + confidence @Display(boundary.Confidence). + @Display(boundary.Reason) +
  2. + } +
+ } +
+ } +
+
+
+} + +@functions { + private static string Display(object? value) => value switch + { + null => "None", + string text when string.IsNullOrWhiteSpace(text) => "None", + decimal number => number.ToString("0.##"), + bool flag => flag ? "Yes" : "No", + _ => value.ToString() ?? "None" + }; + + private static string ValidationSummary(PlotLine.Models.StoryIntelligence.ValidationResult validation) + => validation.IsValid + ? $"✓ Passed with {validation.Warnings.Count:N0} warning(s)" + : $"✖ Failed with {validation.Errors.Count:N0} error(s) and {validation.Warnings.Count:N0} warning(s)"; + + private static string SeveritySymbol(string severity) => severity switch + { + "Error" => "✖", + "Warning" => "⚠", + _ => "✓" + }; + + private static Microsoft.AspNetCore.Html.IHtmlContent RenderIssues(string heading, IReadOnlyList issues) + { + var builder = new System.Text.StringBuilder(); + builder.Append("
"); + builder.Append("

").Append(Encode(heading)).Append(" (").Append(issues.Count.ToString("N0")).Append(")

"); + if (issues.Count == 0) + { + builder.Append("

None.

"); + } + else + { + builder.Append("
"); + foreach (var issue in issues) + { + builder.Append("
"); + builder.Append("

") + .Append(Encode(SeveritySymbol(issue.Severity))) + .Append(' ') + .Append(Encode(issue.Severity)) + .Append(" ") + .Append(Encode(issue.Path)) + .Append("

"); + builder.Append("

").Append(Encode(issue.Message)).Append("

"); + builder.Append("

").Append(Encode(issue.SuggestedFix)).Append("

"); + builder.Append("
"); + } + + builder.Append("
"); + } + + builder.Append("
"); + return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString()); + } + + private static string Encode(string? value) + => System.Net.WebUtility.HtmlEncode(value ?? string.Empty); +} diff --git a/PlotLine/Views/Admin/Index.cshtml b/PlotLine/Views/Admin/Index.cshtml index 4512cfd..ad4f8df 100644 --- a/PlotLine/Views/Admin/Index.cshtml +++ b/PlotLine/Views/Admin/Index.cshtml @@ -83,6 +83,8 @@
diff --git a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml index 3cfff08..95e91b7 100644 --- a/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml +++ b/PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml @@ -95,6 +95,8 @@

Manual dry run

This admin-only tool performs live API verification by loading the prompt, substituting test scene data and making one OpenAI request with extraction-focused settings. It does not save data.

Open dry run + Open chapter structure test + Open combined pipeline test @functions {