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,
|
IFeatureRequestService featureRequests,
|
||||||
IEmailService emails,
|
IEmailService emails,
|
||||||
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
|
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics,
|
||||||
IStoryIntelligenceDryRunService storyIntelligenceDryRun) : Controller
|
IStoryIntelligenceDryRunService storyIntelligenceDryRun,
|
||||||
|
IChapterStructureDryRunService chapterStructureDryRun,
|
||||||
|
IChapterStoryIntelligenceDryRunService chapterStoryIntelligenceDryRun) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -50,6 +52,32 @@ public sealed class AdminController(
|
|||||||
return View(await storyIntelligenceDryRun.ExecuteAsync(form, cancellationToken));
|
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")]
|
[HttpGet("feature-requests")]
|
||||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
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.AddSingleton<IStoryPromptVersionService, StoryPromptVersionService>();
|
||||||
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
|
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
|
||||||
builder.Services.AddScoped<IStorySceneValidator, StorySceneValidator>();
|
builder.Services.AddScoped<IStorySceneValidator, StorySceneValidator>();
|
||||||
|
builder.Services.AddScoped<IChapterStructureValidator, ChapterStructureValidator>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
|
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
builder.Services.AddScoped<IStoryIntelligenceDryRunService, StoryIntelligenceDryRunService>();
|
||||||
|
builder.Services.AddScoped<IChapterStructureDryRunService, ChapterStructureDryRunService>();
|
||||||
|
builder.Services.AddScoped<IChapterStoryIntelligenceDryRunService, ChapterStoryIntelligenceDryRunService>();
|
||||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
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);
|
Task<StoryIntelligenceDryRunViewModel> ExecuteAsync(StoryIntelligenceDryRunForm form, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IChapterStructureDryRunService
|
||||||
|
{
|
||||||
|
ChapterStructureDryRunViewModel GetDefault();
|
||||||
|
Task<ChapterStructureDryRunViewModel> ExecuteAsync(ChapterStructureDryRunForm form, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryPromptRepository(
|
public sealed class StoryPromptRepository(
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
IMemoryCache cache,
|
IMemoryCache cache,
|
||||||
@ -487,6 +493,172 @@ public sealed class StoryIntelligenceDryRunService(
|
|||||||
=> JsonSerializer.Deserialize<SceneIntelligenceScene>(sceneJson, JsonOptions);
|
=> 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
|
internal sealed class OpenAIResponseEnvelope
|
||||||
{
|
{
|
||||||
public List<OpenAIResponseOutputItem>? Output { get; init; }
|
public List<OpenAIResponseOutputItem>? Output { get; init; }
|
||||||
|
|||||||
@ -142,6 +142,136 @@ public sealed class StoryIntelligenceDryRunResultViewModel
|
|||||||
public string? ErrorMessage { get; init; }
|
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 sealed class AdminFeatureRequestFilter
|
||||||
{
|
{
|
||||||
public string? Status { get; set; }
|
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">
|
<div class="project-card__actions">
|
||||||
<a class="btn btn-primary btn-sm" asp-action="StoryIntelligenceDiagnostics">Open</a>
|
<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="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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="project-card">
|
<article class="project-card">
|
||||||
|
|||||||
@ -95,6 +95,8 @@
|
|||||||
<h2>Manual dry run</h2>
|
<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>
|
<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-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>
|
</section>
|
||||||
|
|
||||||
@functions {
|
@functions {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user