Phase 20M – Chapter Structural Analysis

Phase 20N – In-Memory Chapter-to-Scene Pipeline
This commit is contained in:
Nick Beckley 2026-07-04 20:49:36 +01:00
parent 7e7ac46880
commit 7ccc0447a3
12 changed files with 1596 additions and 1 deletions

View File

@ -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<IActionResult> 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<IActionResult> ChapterStoryIntelligenceTest([Bind(Prefix = "Form")] ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken)
{
return View(await chapterStoryIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
}
[HttpGet("feature-requests")]
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
{

View File

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

View File

@ -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<ChapterSceneBoundary>? SceneBoundaries { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? 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<string, JsonElement>? ExtensionData { get; init; }
}

View File

@ -188,8 +188,11 @@ public class Program
builder.Services.AddSingleton<IStoryPromptVersionService, StoryPromptVersionService>();
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
builder.Services.AddScoped<IStorySceneValidator, StorySceneValidator>();
builder.Services.AddScoped<IChapterStructureValidator, ChapterStructureValidator>();
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();

View File

@ -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<ChapterStoryIntelligenceDryRunViewModel> ExecuteAsync(ChapterStoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
}
public sealed class ChapterStoryIntelligenceDryRunService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
IStoryIntelligenceClient client,
IChapterStructureValidator chapterValidator,
IStorySceneValidator sceneValidator,
ILogger<ChapterStoryIntelligenceDryRunService> 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<ChapterStoryIntelligenceDryRunViewModel> 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<ChapterStructureModel>(chapterJson, JsonOptions);
var chapterValidation = chapterValidator.Validate(parsedChapter, paragraphs.Count);
var sceneBlocks = new List<ChapterStorySceneBlockViewModel>();
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<ChapterStorySceneBlockViewModel> 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<SceneIntelligenceScene>(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<string> 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<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
?? throw new JsonException("OpenAI response envelope was empty.");
var outputText = response.Output?
.SelectMany(item => item.Content ?? [])
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(content.Text))
?.Text;
if (string.IsNullOrWhiteSpace(outputText))
{
throw new JsonException("OpenAI response did not contain output_text content.");
}
return outputText.Trim();
}
private static List<string> 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<int?> 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);
}
}

View File

@ -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<ChapterSceneBoundary> 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<string, JsonElement>? 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 });
}

View File

@ -43,6 +43,12 @@ public interface IStoryIntelligenceDryRunService
Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
}
public interface IChapterStructureDryRunService
{
ChapterStructureDryRunViewModel GetDefault();
Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken);
}
public sealed class StoryPromptRepository(
IWebHostEnvironment environment,
IMemoryCache cache,
@ -487,6 +493,172 @@ public sealed class StoryIntelligenceDryRunService(
=> JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
}
public sealed class ChapterStructureDryRunService(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryIntelligenceClient client,
IChapterStructureValidator validator,
ILogger<ChapterStructureDryRunService> 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<ChapterStructureDryRunViewModel> 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<OpenAIResponseEnvelope>(rawResponseText, JsonOptions)
?? throw new JsonException("OpenAI response envelope was empty.");
var outputText = response.Output?
.SelectMany(item => item.Content ?? [])
.FirstOrDefault(content => string.Equals(content.Type, "output_text", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(content.Text))
?.Text;
if (string.IsNullOrWhiteSpace(outputText))
{
throw new JsonException("OpenAI response did not contain output_text content.");
}
return outputText.Trim();
}
private static ChapterStructureModel? DeserializeChapter(string chapterJson)
=> JsonSerializer.Deserialize<ChapterStructureModel>(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<OpenAIResponseOutputItem>? Output { get; init; }

View File

@ -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<ChapterStorySceneBlockViewModel> 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; }

View File

@ -0,0 +1,329 @@
@model ChapterStoryIntelligenceDryRunViewModel
@{
ViewData["Title"] = "Chapter Story Intelligence Test";
var result = Model.Result;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Chapter Story Intelligence Test</h1>
<p class="lead-text">This in-memory dry run detects suggested scene boundaries and then runs Scene Intelligence sequentially for each suggested scene. It does not save data.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / </span>
<a asp-action="StoryIntelligenceDiagnostics">Story Intelligence Diagnostics</a>
<span class="muted"> / Chapter Story Intelligence Test</span>
</nav>
<section class="edit-panel">
<h2>Manual combined pipeline test</h2>
<p>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.</p>
<form asp-action="ChapterStoryIntelligenceTest" method="post">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" asp-for="Form.ProjectID">ProjectID</label>
<input class="form-control" asp-for="Form.ProjectID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.BookID">BookID</label>
<input class="form-control" asp-for="Form.BookID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.ChapterID">ChapterID</label>
<input class="form-control" asp-for="Form.ChapterID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.ChapterNumber">ChapterNumber</label>
<input class="form-control" asp-for="Form.ChapterNumber" />
</div>
<div class="col-12">
<label class="form-label" asp-for="Form.SourceLabel">SourceLabel</label>
<input class="form-control" asp-for="Form.SourceLabel" />
</div>
<div class="col-12">
<label class="form-label" asp-for="Form.ChapterText">Chapter text</label>
<textarea class="form-control" asp-for="Form.ChapterText" rows="14"></textarea>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Run combined test</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
</div>
</form>
</section>
@if (result is not null)
{
<section class="edit-panel">
<h2>Combined pipeline result</h2>
@if (!string.IsNullOrWhiteSpace(result.ErrorMessage))
{
<div class="alert alert-warning">@result.ErrorMessage</div>
}
@if (result.PipelineStopped)
{
<div class="alert alert-warning">@result.StopReason</div>
}
<div class="row g-3">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Model</p>
<h3 class="mb-0">@Display(result.Model)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Paragraphs</p>
<h3 class="mb-0">@result.ParagraphCount.ToString("N0")</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Detected scenes</p>
<h3 class="mb-0">@result.SceneBlocks.Count.ToString("N0")</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Total duration</p>
<h3 class="mb-0">@Display(result.TotalDuration)</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Chapter prompt</dt>
<dd>@result.ChapterPromptVersion</dd>
<dt>Scene prompt</dt>
<dd>@result.ScenePromptVersion</dd>
<dt>Total input tokens</dt>
<dd>@Display(result.TotalInputTokens)</dd>
<dt>Total output tokens</dt>
<dd>@Display(result.TotalOutputTokens)</dd>
</dl>
</section>
<section class="edit-panel">
<h2>Chapter Structure</h2>
<div class="row g-3">
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Duration</p>
<h3 class="mb-0">@Display(result.ChapterDuration)</h3>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Input tokens</p>
<h3 class="mb-0">@Display(result.ChapterInputTokens)</h3>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Output tokens</p>
<h3 class="mb-0">@Display(result.ChapterOutputTokens)</h3>
</div>
</div>
</div>
<h3 class="mt-3">Validation</h3>
@if (result.ChapterValidation is null)
{
<div class="alert alert-warning">Chapter Structure validation did not run.</div>
}
else
{
<h4>@ValidationSummary(result.ChapterValidation)</h4>
@RenderIssues("Errors", result.ChapterValidation.Errors)
@RenderIssues("Warnings", result.ChapterValidation.Warnings)
@RenderIssues("Information", result.ChapterValidation.Information)
}
<h3 class="mt-3">Parsed Model</h3>
@if (result.ParsedChapter is null)
{
<p>No parsed Chapter Structure model is available.</p>
}
else
{
<p>@Display(result.ParsedChapter.ChapterSummary)</p>
@if (result.ParsedChapter.SceneBoundaries is not null)
{
<ol>
@foreach (var boundary in result.ParsedChapter.SceneBoundaries)
{
<li>Scene @Display(boundary.SceneNumber): paragraphs @Display(boundary.StartParagraph)-@Display(boundary.EndParagraph), confidence @Display(boundary.Confidence). @Display(boundary.Reason)</li>
}
</ol>
}
}
<h3 class="mt-3">Raw JSON</h3>
<label class="form-label" for="chapterStructureJson">Extracted Chapter Structure JSON</label>
<textarea class="form-control font-monospace" id="chapterStructureJson" rows="12" readonly>@result.ChapterJsonText</textarea>
<label class="form-label mt-3" for="chapterRawResponse">Raw OpenAI chapter response</label>
<textarea class="form-control font-monospace" id="chapterRawResponse" rows="12" readonly>@result.ChapterRawResponseText</textarea>
</section>
<section class="edit-panel">
<h2>Detected Scene List</h2>
@if (result.SceneBlocks.Count == 0)
{
<p>No in-memory scene blocks were produced.</p>
}
else
{
<ol class="mb-0">
@foreach (var block in result.SceneBlocks)
{
<li>
Scene @Display(block.TemporarySceneNumber), paragraphs @Display(block.StartParagraph)-@Display(block.EndParagraph):
@Display(block.SceneTextPreview)
</li>
}
</ol>
}
</section>
@foreach (var block in result.SceneBlocks)
{
<section class="edit-panel">
<h2>Suggested Scene @Display(block.TemporarySceneNumber)</h2>
<dl>
<dt>Paragraph range</dt>
<dd>@Display(block.StartParagraph)-@Display(block.EndParagraph)</dd>
<dt>Boundary confidence</dt>
<dd>@Display(block.BoundaryConfidence)</dd>
<dt>Boundary reason</dt>
<dd>@Display(block.BoundaryReason)</dd>
<dt>Scene Intelligence duration</dt>
<dd>@Display(block.Duration)</dd>
<dt>Input tokens</dt>
<dd>@Display(block.InputTokens)</dd>
<dt>Output tokens</dt>
<dd>@Display(block.OutputTokens)</dd>
</dl>
@if (!block.SplitValid)
{
<div class="alert alert-warning">@block.SplitErrorMessage</div>
}
@if (!string.IsNullOrWhiteSpace(block.ErrorMessage))
{
<div class="alert alert-warning">@block.ErrorMessage</div>
}
<h3>Scene Text Preview</h3>
<p>@Display(block.SceneTextPreview)</p>
<h3>Validation</h3>
@if (block.Validation is null)
{
<p>Scene validation did not run.</p>
}
else
{
<h4>@ValidationSummary(block.Validation)</h4>
@RenderIssues("Errors", block.Validation.Errors)
@RenderIssues("Warnings", block.Validation.Warnings)
}
<h3>Parsed Model</h3>
@if (block.ParsedScene is null)
{
<p>No parsed Scene Intelligence model is available.</p>
}
else
{
<dl>
<dt>Summary</dt>
<dd>@Display(block.ParsedScene.Summary?.Short)</dd>
<dt>Setting</dt>
<dd>@Display(block.ParsedScene.Setting?.LocationName) (@Display(block.ParsedScene.Setting?.LocationType))</dd>
<dt>Characters</dt>
<dd>@Display(block.ParsedScene.Characters?.Count)</dd>
<dt>Locations</dt>
<dd>@Display(block.ParsedScene.Locations?.Count)</dd>
<dt>Assets</dt>
<dd>@Display(block.ParsedScene.Assets?.Count)</dd>
<dt>Observations</dt>
<dd>@Display(block.ParsedScene.Observations?.Count)</dd>
</dl>
}
<h3>Raw Scene Intelligence Response</h3>
<label class="form-label" for="sceneJson-@block.TemporarySceneNumber">Extracted Scene Intelligence JSON</label>
<textarea class="form-control font-monospace" id="sceneJson-@block.TemporarySceneNumber" rows="12" readonly>@block.SceneJsonText</textarea>
<label class="form-label mt-3" for="rawScene-@block.TemporarySceneNumber">Raw OpenAI response</label>
<textarea class="form-control font-monospace" id="rawScene-@block.TemporarySceneNumber" rows="12" readonly>@block.RawResponseText</textarea>
</section>
}
}
@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<PlotLine.Models.StoryIntelligence.ValidationIssue> issues)
{
var builder = new System.Text.StringBuilder();
builder.Append("<section class=\"mt-3\">");
builder.Append("<h4>").Append(Encode(heading)).Append(" (").Append(issues.Count.ToString("N0")).Append(")</h4>");
if (issues.Count == 0)
{
builder.Append("<p class=\"mb-0\">None.</p>");
}
else
{
builder.Append("<div class=\"list-group\">");
foreach (var issue in issues)
{
builder.Append("<div class=\"list-group-item\">");
builder.Append("<p class=\"mb-1\"><strong>")
.Append(Encode(SeveritySymbol(issue.Severity)))
.Append(' ')
.Append(Encode(issue.Severity))
.Append("</strong> <code>")
.Append(Encode(issue.Path))
.Append("</code></p>");
builder.Append("<p class=\"mb-1\">").Append(Encode(issue.Message)).Append("</p>");
builder.Append("<p class=\"mb-0 muted\">").Append(Encode(issue.SuggestedFix)).Append("</p>");
builder.Append("</div>");
}
builder.Append("</div>");
}
builder.Append("</section>");
return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString());
}
private static string Encode(string? value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
}

View File

@ -0,0 +1,248 @@
@model ChapterStructureDryRunViewModel
@{
ViewData["Title"] = "Chapter Intelligence Test";
var result = Model.Result;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Admin utility</p>
<h1>Chapter Intelligence Test</h1>
<p class="lead-text">This is a structural dry run. It makes one live OpenAI request only when submitted, and it does not create scenes or save data.</p>
</div>
</div>
<nav class="mb-3" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<span class="muted"> / </span>
<a asp-action="Index">Admin</a>
<span class="muted"> / </span>
<a asp-action="StoryIntelligenceDiagnostics">Story Intelligence Diagnostics</a>
<span class="muted"> / Chapter Intelligence Test</span>
</nav>
<section class="edit-panel">
<h2>Manual chapter structure test</h2>
<p>Use fictional sample text only. This page loads <code>Chapter-Structure-Prompt-V1.md</code>, replaces runtime placeholders, asks for likely scene boundaries, and displays the raw response without saving it.</p>
<form asp-action="ChapterStructureTest" method="post">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label" asp-for="Form.ProjectID">ProjectID</label>
<input class="form-control" asp-for="Form.ProjectID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.BookID">BookID</label>
<input class="form-control" asp-for="Form.BookID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.ChapterID">ChapterID</label>
<input class="form-control" asp-for="Form.ChapterID" />
</div>
<div class="col-md-3">
<label class="form-label" asp-for="Form.ChapterNumber">ChapterNumber</label>
<input class="form-control" asp-for="Form.ChapterNumber" />
</div>
<div class="col-12">
<label class="form-label" asp-for="Form.SourceLabel">SourceLabel</label>
<input class="form-control" asp-for="Form.SourceLabel" />
</div>
<div class="col-12">
<label class="form-label" asp-for="Form.ChapterText">Chapter text</label>
<textarea class="form-control" asp-for="Form.ChapterText" rows="14"></textarea>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Run chapter test</button>
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceDiagnostics">Back to diagnostics</a>
</div>
</form>
</section>
@if (result is not null)
{
<section class="edit-panel">
<h2>Chapter structure result</h2>
@if (!result.Success)
{
<div class="alert alert-warning">@result.ErrorMessage</div>
}
<div class="row g-3">
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Prompt version</p>
<h3 class="mb-0">@result.PromptVersion</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Model</p>
<h3 class="mb-0">@result.Model</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Duration</p>
<h3 class="mb-0">@(string.IsNullOrWhiteSpace(result.Duration) ? "Not run" : result.Duration)</h3>
</div>
</div>
<div class="col-md-3">
<div class="border rounded p-3 h-100">
<p class="eyebrow">Paragraphs</p>
<h3 class="mb-0">@result.ParagraphCount.ToString("N0")</h3>
</div>
</div>
</div>
<dl class="mt-3 mb-0">
<dt>Input tokens</dt>
<dd>@(result.InputTokens.HasValue ? result.InputTokens.Value.ToString("N0") : "Not reported")</dd>
<dt>Output tokens</dt>
<dd>@(result.OutputTokens.HasValue ? result.OutputTokens.Value.ToString("N0") : "Not reported")</dd>
<dt>Retries</dt>
<dd>@result.RetryCount.ToString("N0")</dd>
</dl>
<ul class="nav nav-tabs mt-4" id="chapterStructureTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="raw-json-tab" data-bs-toggle="tab" data-bs-target="#raw-json" type="button" role="tab" aria-controls="raw-json" aria-selected="true">Raw JSON</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="validation-tab" data-bs-toggle="tab" data-bs-target="#validation" type="button" role="tab" aria-controls="validation" aria-selected="false">Validation</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="parsed-model-tab" data-bs-toggle="tab" data-bs-target="#parsed-model" type="button" role="tab" aria-controls="parsed-model" aria-selected="false">Parsed Model</button>
</li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3" id="chapterStructureTabContent">
<div class="tab-pane fade show active" id="raw-json" role="tabpanel" aria-labelledby="raw-json-tab" tabindex="0">
<div class="mb-3">
<label class="form-label" for="chapterContextJson">Chapter context JSON</label>
<textarea class="form-control font-monospace" id="chapterContextJson" rows="8" readonly>@result.ChapterContextJson</textarea>
</div>
<div class="mb-3">
<label class="form-label" for="chapterJsonText">Extracted Chapter Structure JSON</label>
<textarea class="form-control font-monospace" id="chapterJsonText" rows="12" readonly>@result.ChapterJsonText</textarea>
</div>
<div>
<label class="form-label" for="rawResponseText">Raw OpenAI response</label>
<textarea class="form-control font-monospace" id="rawResponseText" rows="18" readonly>@result.RawResponseText</textarea>
</div>
</div>
<div class="tab-pane fade" id="validation" role="tabpanel" aria-labelledby="validation-tab" tabindex="0">
@if (result.Validation is null)
{
<div class="alert alert-warning">Validation did not run.</div>
}
else
{
<h3>@ValidationSummary(result.Validation)</h3>
@RenderIssues("Errors", result.Validation.Errors)
@RenderIssues("Warnings", result.Validation.Warnings)
@RenderIssues("Information", result.Validation.Information)
}
</div>
<div class="tab-pane fade" id="parsed-model" role="tabpanel" aria-labelledby="parsed-model-tab" tabindex="0">
@if (result.ParsedChapter is null)
{
<div class="alert alert-warning">No parsed Chapter Structure model is available.</div>
}
else
{
<section class="border rounded p-3">
<h3>Chapter</h3>
<dl class="mb-0">
<dt>Schema</dt>
<dd>@Display(result.ParsedChapter.SchemaVersion)</dd>
<dt>Summary</dt>
<dd>@Display(result.ParsedChapter.ChapterSummary)</dd>
</dl>
</section>
<section class="border rounded p-3 mt-3">
<h3>Scene Boundaries</h3>
@if (result.ParsedChapter.SceneBoundaries is null || result.ParsedChapter.SceneBoundaries.Count == 0)
{
<p class="mb-0">No scene boundaries reported.</p>
}
else
{
<ol class="mb-0">
@foreach (var boundary in result.ParsedChapter.SceneBoundaries)
{
<li>
Scene @Display(boundary.SceneNumber):
paragraphs @Display(boundary.StartParagraph)-@Display(boundary.EndParagraph),
confidence @Display(boundary.Confidence).
@Display(boundary.Reason)
</li>
}
</ol>
}
</section>
}
</div>
</div>
</section>
}
@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<PlotLine.Models.StoryIntelligence.ValidationIssue> issues)
{
var builder = new System.Text.StringBuilder();
builder.Append("<section class=\"mt-3\">");
builder.Append("<h4>").Append(Encode(heading)).Append(" (").Append(issues.Count.ToString("N0")).Append(")</h4>");
if (issues.Count == 0)
{
builder.Append("<p class=\"mb-0\">None.</p>");
}
else
{
builder.Append("<div class=\"list-group\">");
foreach (var issue in issues)
{
builder.Append("<div class=\"list-group-item\">");
builder.Append("<p class=\"mb-1\"><strong>")
.Append(Encode(SeveritySymbol(issue.Severity)))
.Append(' ')
.Append(Encode(issue.Severity))
.Append("</strong> <code>")
.Append(Encode(issue.Path))
.Append("</code></p>");
builder.Append("<p class=\"mb-1\">").Append(Encode(issue.Message)).Append("</p>");
builder.Append("<p class=\"mb-0 muted\">").Append(Encode(issue.SuggestedFix)).Append("</p>");
builder.Append("</div>");
}
builder.Append("</div>");
}
builder.Append("</section>");
return new Microsoft.AspNetCore.Html.HtmlString(builder.ToString());
}
private static string Encode(string? value)
=> System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
}

View File

@ -83,6 +83,8 @@
<div class="project-card__actions">
<a class="btn btn-primary btn-sm" asp-action="StoryIntelligenceDiagnostics">Open</a>
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceTest">Dry run</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStructureTest">Chapter test</a>
<a class="btn btn-outline-primary btn-sm" asp-action="ChapterStoryIntelligenceTest">Pipeline test</a>
</div>
</article>
<article class="project-card">

View File

@ -95,6 +95,8 @@
<h2>Manual dry run</h2>
<p>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.</p>
<a class="btn btn-primary" asp-action="StoryIntelligenceTest">Open dry run</a>
<a class="btn btn-outline-primary" asp-action="ChapterStructureTest">Open chapter structure test</a>
<a class="btn btn-outline-primary" asp-action="ChapterStoryIntelligenceTest">Open combined pipeline test</a>
</section>
@functions {