Phase 20M – Chapter Structural Analysis
Phase 20N – In-Memory Chapter-to-Scene Pipeline
This commit is contained in:
parent
7e7ac46880
commit
7ccc0447a3
@ -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)
|
||||
{
|
||||
|
||||
126
PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md
Normal file
126
PlotLine/Docs/AI/Chapter-Structure-Prompt-V1.md
Normal 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.
|
||||
26
PlotLine/Models/ChapterStructureModels.cs
Normal file
26
PlotLine/Models/ChapterStructureModels.cs
Normal 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; }
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
350
PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs
Normal file
350
PlotLine/Services/ChapterStoryIntelligenceDryRunService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
179
PlotLine/Services/ChapterStructureValidator.cs
Normal file
179
PlotLine/Services/ChapterStructureValidator.cs
Normal 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 });
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
329
PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml
Normal file
329
PlotLine/Views/Admin/ChapterStoryIntelligenceTest.cshtml
Normal 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);
|
||||
}
|
||||
248
PlotLine/Views/Admin/ChapterStructureTest.cshtml
Normal file
248
PlotLine/Views/Admin/ChapterStructureTest.cshtml
Normal 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);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user