Add canonical story intelligence scene context

This commit is contained in:
Nick Beckley 2026-08-28 13:18:19 +00:00
parent 58075232e7
commit 108df3db3e
19 changed files with 868 additions and 50 deletions

View File

@ -140,6 +140,11 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis),
("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution),
("Chapter POV derives from canonical scenes", ChapterPovDerivesFromCanonicalScenes),
("Permanent Analyse My Book starts from saved book", PermanentAnalyseMyBookStartsFromSavedBook),
("Story Intelligence canonical context carries provenance", StoryIntelligenceCanonicalContextCarriesProvenance),
("Scene prompt protects canonical scene context", ScenePromptProtectsCanonicalSceneContext),
("Structural setting rides existing chapter analysis", StructuralSettingRidesExistingChapterAnalysis),
("Explicit narrator labels detect deterministic POV", ExplicitNarratorLabelsDetectDeterministicPov),
("AI POV JSON parses into structural models", AiPovJsonParsesIntoStructuralModels),
("Post character review resolves persisted structural POV", PostCharacterReviewResolvesPersistedStructuralPov),
@ -3399,6 +3404,82 @@ static void StructuralPovPersistsThroughImportResolution()
Assert(migration.Contains("@OverwriteAuthor bit = 0", StringComparison.Ordinal), "Structural POV updates should not overwrite author POV by default.");
}
static void ChapterPovDerivesFromCanonicalScenes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var editView = File.ReadAllText(Path.Combine(root, "Views/Chapters/Edit.cshtml"));
Assert(service.Contains("DeriveUniformScenePov(sceneRows)", StringComparison.Ordinal), "Chapter edit/detail should derive chapter POV from canonical scene rows.");
Assert(service.Contains("return derived.DisplayLabel", StringComparison.Ordinal), "Chapter POV display should prefer scene-derived POV when scenes exist.");
Assert(service.Contains("POVCharacterID = hasCanonicalScenes ? null : model.POVCharacterID", StringComparison.Ordinal), "Chapter save should not create a competing persisted chapter POV when canonical scenes exist.");
Assert(service.Contains("chapters.SetScenesPovAsync(chapterId, model.POVCharacterID, overwriteAuthor: false)", StringComparison.Ordinal), "Changed chapter POV control should update the canonical scene POVs.");
Assert(viewModel.Contains("OriginalDerivedPOVCharacterID", StringComparison.Ordinal) && viewModel.Contains("HasCanonicalScenes", StringComparison.Ordinal), "Chapter edit model should preserve the original derived scene POV state.");
Assert(editView.Contains("Current scene POV", StringComparison.Ordinal), "Chapter edit UI should expose that POV is derived from scenes.");
}
static void PermanentAnalyseMyBookStartsFromSavedBook()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var bookView = File.ReadAllText(Path.Combine(root, "Views/Books/Details.cshtml"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
Assert(bookView.Contains("StartBookStoryIntelligence", StringComparison.Ordinal), "Book details should expose a permanent Analyse My Book action.");
Assert(controller.Contains("story-intelligence/book/{bookId:int}/start", StringComparison.Ordinal), "Permanent Story Intelligence start should have a book-scoped route.");
Assert(service.Contains("Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)", StringComparison.Ordinal), "Story Intelligence service should support starting from a saved book.");
Assert(service.Contains("sources.GetChapterSourceAsync(chapter.ChapterID)", StringComparison.Ordinal), "Saved-book analysis should use durable persisted chapter text.");
Assert(service.Contains("pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId)", StringComparison.Ordinal), "Saved-book analysis should attach queued runs to the existing book pipeline.");
Assert(core.Contains("BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)", StringComparison.Ordinal), "Book details should determine action state from persisted canonical scene sources.");
}
static void StoryIntelligenceCanonicalContextCarriesProvenance()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var context = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceSceneContextService.cs"));
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
var program = File.ReadAllText(Path.Combine(root, "Program.cs"));
Assert(context.Contains("StoryIntelligenceCanonicalSceneContext", StringComparison.Ordinal), "Canonical Scene Intelligence should have one explicit context model.");
Assert(context.Contains("sourceParagraphRange", StringComparison.Ordinal), "Canonical context should carry source paragraph provenance.");
Assert(context.Contains("neighbourScenes", StringComparison.Ordinal), "Canonical context should include neighbouring scene summaries without full text.");
Assert(context.Contains("sceneCharacters", StringComparison.Ordinal), "Canonical context should include deterministic scene-character mappings.");
Assert(context.Contains("[property: JsonIgnore] string SourceText", StringComparison.Ordinal), "Canonical source text should not be duplicated inside prompt JSON.");
Assert(runner.Contains("StoryIntelligenceSceneContextService.ToPromptJson(context)", StringComparison.Ordinal), "Runner should use the canonical context service in canonical scene mode.");
Assert(program.Contains("IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService", StringComparison.Ordinal), "Canonical context service should be registered in DI.");
}
static void ScenePromptProtectsCanonicalSceneContext()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
Assert(prompt.Contains("established PlotDirector context", StringComparison.Ordinal), "Scene prompt should distinguish saved PlotDirector context.");
Assert(prompt.Contains("the current scene text block is the only manuscript source to analyse", StringComparison.Ordinal), "Scene prompt should restrict analysis to current source text.");
Assert(prompt.Contains("Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting", StringComparison.Ordinal), "Scene prompt should protect core import facts.");
Assert(prompt.Contains("canonical PlotDirector POV metadata is authoritative", StringComparison.Ordinal), "Scene prompt should treat canonical POV as authoritative.");
Assert(prompt.Contains("do not create `Narrator` as a separate character", StringComparison.Ordinal), "Scene prompt should prevent Narrator character creation.");
}
static void StructuralSettingRidesExistingChapterAnalysis()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
var models = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
var analysis = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/167_Phase26C_CanonicalStoryIntelligenceContext.sql"));
Assert(prompt.Contains("structuralSetting", StringComparison.Ordinal), "Structural prompt should collect setting in the existing scene-boundary pass.");
Assert(models.Contains("public string? StructuralSetting", StringComparison.Ordinal), "Chapter structure model should parse structural settings.");
Assert(analysis.Contains("StructuralSetting = Trim(boundary.StructuralSetting, 300)", StringComparison.Ordinal), "Structural analysis should carry compact setting to import boundaries.");
Assert(repository.Contains("StructuralSetting = structuralSetting", StringComparison.Ordinal), "Structural setting should be persisted through a stored procedure parameter.");
Assert(migration.Contains("ALTER TABLE dbo.Scenes ADD StructuralSetting", StringComparison.Ordinal), "Forward migration should add durable scene structural setting.");
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.SceneStructuralSummary_Save", StringComparison.Ordinal), "Forward migration should update the stored procedure used by app code.");
}
static void ExplicitNarratorLabelsDetectDeterministicPov()
{
var beth = ManuscriptStructureAnalysisService.DetectExplicitNarrator("Narrated by Beth - 7th May 1980\n\nI opened the door.");

View File

@ -140,6 +140,34 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
}
[HttpPost("story-intelligence/book/{bookId:int}/start")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartBookStoryIntelligence(int bookId)
{
var existing = await storyIntelligence.ResumeBookAsync(bookId);
if (existing is not null)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = existing.BatchID });
}
try
{
var job = await storyIntelligence.StartBookAsync(bookId);
if (job is null)
{
TempData["ArchiveError"] = "Story Intelligence could not find that book.";
return RedirectToAction("Details", "Books", new { id = bookId });
}
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId = job.BatchID });
}
catch (InvalidOperationException ex)
{
TempData["ArchiveError"] = ex.Message;
return RedirectToAction("Details", "Books", new { id = bookId });
}
}
[HttpPost("story-intelligence/skip")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SkipStoryIntelligence()

View File

@ -15,7 +15,7 @@ public interface IOnboardingBuildRepository
Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null);
Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request);
Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary);
Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary, string? structuralSetting = null);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByChapterAsync(int chapterId);
Task<IReadOnlyList<SceneManuscriptSourceRecord>> ListSceneManuscriptSourcesByBookAsync(int bookId);
}
@ -168,12 +168,12 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa
commandType: CommandType.StoredProcedure);
}
public async Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary)
public async Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary, string? structuralSetting = null)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.SceneStructuralSummary_Save",
new { SceneID = sceneId, StructuralSummary = structuralSummary },
new { SceneID = sceneId, StructuralSummary = structuralSummary, StructuralSetting = structuralSetting },
commandType: CommandType.StoredProcedure);
}
@ -282,4 +282,5 @@ public sealed class SceneManuscriptSourceRecord
public int? WordEndParagraphIndex { get; init; }
public string SourceKind { get; init; } = string.Empty;
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
}

View File

@ -12,7 +12,7 @@ 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, create story entities or infer canon.
Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, provide a light factual summary for each detected scene, and record compact point-of-view evidence when it is clear.
Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, provide a light factual summary for each detected scene, record a compact structural setting where clear, and record compact point-of-view evidence when it is clear.
## Operating Principles
@ -139,6 +139,7 @@ Each `sceneBoundaries` item must contain exactly:
- `endParagraph`
- `suggestedTitle`
- `structuralSummary`
- `structuralSetting`
- `povOverride`
- `confidence`
- `reason`
@ -150,6 +151,8 @@ Incorrect examples: `0. 8`, `7 .5`, `0,8`, `"0.8"`.
Use `structuralSummary` for a factual, neutral summary of what happens in that scene. It should normally be 25 to 40 words, avoid interpretation, and only use information present in the supplied text.
Use `structuralSetting` for the scene's compact active place/time label when the chapter text supports it, such as `Beth's car at night`, `church office`, or `Ashdown Trust reception`. Use `null` when no active setting is clear. Do not invent named locations.
Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary.
Use `defaultPov` for the chapter's suggested default narrator/viewpoint character, or `null` when not clear. When present it must contain exactly `characterName`, `confidence`, `evidence`, and `isExplicit`.
@ -192,6 +195,7 @@ Do not output:
"endParagraph": 3,
"suggestedTitle": "Arrival at the Church",
"structuralSummary": "The opening scene follows the same continuous action across the supplied paragraphs, keeping the characters in one time and place while the immediate situation is established.",
"structuralSetting": "church",
"povOverride": null,
"confidence": 0.86,
"reason": "Opening scene remains in the same place and time."

View File

@ -77,7 +77,17 @@ It should not:
Use only the scene text and supplied context.
When `structuralSummary` is supplied in `sceneContext`, treat it as PlotDirector's canonical factual summary of the scene. Use it to orient interpretation, metrics, relationships, knowledge, narrative arcs, context and intent. Do not regenerate, rewrite or compete with that factual scene summary.
The supplied `sceneContext` distinguishes established PlotDirector context from narrative context and the current manuscript source:
- `book`, `chapter`, `currentScene`, `sceneCharacters`, `configuredMetrics` and `existingPlotLines` are established PlotDirector context from the saved database.
- `neighbourScenes` are narrative orientation only. They may include adjacent titles, summaries, POV and setting, but never full neighbouring scene text.
- the current scene text block is the only manuscript source to analyse.
When `currentScene.structuralSummary` is supplied in `sceneContext`, treat it as PlotDirector's canonical factual summary of the scene. Use it to orient interpretation, metrics, relationships, knowledge, narrative arcs, context and intent. Do not regenerate, rewrite or compete with that factual scene summary.
When `currentScene.structuralSetting` or `currentScene.primaryLocationName` is supplied, treat it as established structural setting context. You may use it for identity/context, but do not invent a new named location from a generic setting label.
Do not regenerate scene boundaries, scene titles, scene numbers, chapter numbers, structural summaries, canonical POV or primary setting. These belong to Core Import and the saved PlotDirector scene model.
When `knownCharacters` is supplied in the scene context, use it only as identity context for names already detected before analysis:
@ -258,14 +268,19 @@ Example: if Beth thinks `closer, perhaps, to Maggie`, do not create `Maggie at A
## Point Of View Rules
For first-person scenes, if the scene text does not explicitly name the narrator:
For first-person scenes, canonical PlotDirector POV metadata is authoritative when supplied in `sceneContext.currentScene.povCharacterName`.
- use `pointOfView.characterName: "Narrator"` or `null`;
- do not guess the narrator's identity from wider story context unless supplied directly in the scene/chapter metadata;
- if chapter-level narrator metadata is supplied, use it and cite that metadata as evidence;
- otherwise preserve ambiguity.
If canonical POV metadata is supplied:
PlotDirector may resolve narrator identity later from chapter narrator metadata. Scene Intelligence should not force narrator identity where the scene does not provide it.
- use that character name for `pointOfView.characterName`;
- cite the PlotDirector scene metadata in `evidence`;
- do not create `Narrator` as a separate character.
If canonical POV metadata is not supplied and the scene text does not explicitly name the narrator:
- use `pointOfView.characterName: null`;
- preserve ambiguity in `sourceLimits.ambiguityNotes`;
- do not create `Narrator` as a character.
## Asset Reporting Rules

View File

@ -22,6 +22,7 @@ public sealed class ChapterSceneBoundary
public int? EndParagraph { get; init; }
public string? SuggestedTitle { get; init; }
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public ChapterStructurePovSuggestion? PovOverride { get; init; }
public decimal? Confidence { get; init; }
public string? Reason { get; init; }

View File

@ -433,6 +433,7 @@ public sealed class Scene
public string? SceneTitleSource { get; set; }
public string? Summary { get; set; }
public string? StructuralSummary { get; set; }
public string? StructuralSetting { get; set; }
public int? POVCharacterID { get; set; }
public string? POVCharacterName { get; set; }
public string? POVSource { get; set; }

View File

@ -230,6 +230,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryIntelligenceSceneContextService, StoryIntelligenceSceneContextService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();

View File

@ -113,6 +113,7 @@ public static class ChapterStructureBoundaryNormaliser
EndParagraph = boundary.EndParagraph,
SuggestedTitle = boundary.SuggestedTitle,
StructuralSummary = boundary.StructuralSummary,
StructuralSetting = boundary.StructuralSetting,
PovOverride = boundary.PovOverride,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
@ -205,6 +206,7 @@ public static class ChapterStructureBoundaryNormaliser
EndParagraph = end,
SuggestedTitle = boundary.SuggestedTitle,
StructuralSummary = boundary.StructuralSummary,
StructuralSetting = boundary.StructuralSetting,
PovOverride = boundary.PovOverride,
Confidence = boundary.Confidence,
Reason = boundary.Reason,
@ -230,6 +232,7 @@ public static class ChapterStructureBoundaryNormaliser
EndParagraph = paragraphCount,
SuggestedTitle = final.SuggestedTitle,
StructuralSummary = final.StructuralSummary,
StructuralSetting = final.StructuralSetting,
PovOverride = final.PovOverride,
Confidence = final.Confidence,
Reason = final.Reason,

View File

@ -1164,6 +1164,7 @@ public sealed class BookService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
IOnboardingBuildRepository builds,
IManuscriptDocumentRepository manuscriptDocuments,
IBookCoverService bookCovers,
ISubscriptionService subscriptions,
@ -1185,6 +1186,11 @@ public sealed class BookService(
return null;
}
var pipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null;
var hasCanonicalSceneSources = (await builds.ListSceneManuscriptSourcesByBookAsync(bookId)).Count > 0;
return new BookDetailViewModel
{
Project = project,
@ -1193,9 +1199,8 @@ public sealed class BookService(
? await manuscriptDocuments.GetByBookAsync(bookId, userId)
: null,
Chapters = await chapters.ListByBookAsync(bookId),
StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
: null
StoryIntelligencePipeline = pipeline,
StoryIntelligenceAction = BuildStoryIntelligenceAction(pipeline, hasCanonicalSceneSources)
};
}
@ -1359,6 +1364,34 @@ public sealed class BookService(
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))
.ToList();
private static StoryIntelligenceBookActionViewModel BuildStoryIntelligenceAction(StoryIntelligenceBookPipelineState? pipeline, bool hasCanonicalSceneSources)
{
if (pipeline is null)
{
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = hasCanonicalSceneSources,
StatusLabel = hasCanonicalSceneSources ? "Not analysed" : "Core import required",
ActionLabel = "Analyse My Book",
Description = hasCanonicalSceneSources
? "Analyse this book from its saved chapters, scenes, characters and manuscript text."
: "Complete the core manuscript import before analysing this book."
};
}
return new StoryIntelligenceBookActionViewModel
{
HasCanonicalSceneSources = hasCanonicalSceneSources,
CanStart = false,
StatusLabel = pipeline.IsComplete ? "Complete" : pipeline.Status,
ActionLabel = pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence",
Description = pipeline.IsComplete
? "All currently available Story Intelligence stages are complete."
: "Story Intelligence has saved progress for this book."
};
}
public async Task ArchiveAsync(int bookId)
{
var book = await books.GetAsync(bookId);
@ -1478,13 +1511,18 @@ public sealed class ChapterService(
}
var lookupData = await lookups.GetAllAsync();
var sceneRows = await scenes.ListByChapterAsync(chapterId);
var derivedPov = DeriveUniformScenePov(sceneRows);
return new ChapterEditViewModel
{
ChapterID = chapter.ChapterID,
BookID = chapter.BookID,
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.ChapterTitle,
POVCharacterID = chapter.POVCharacterID,
POVCharacterID = derivedPov.CharacterID,
OriginalDerivedPOVCharacterID = derivedPov.CharacterID,
HasCanonicalScenes = sceneRows.Count > 0,
PovDisplayLabel = derivedPov.DisplayLabel,
Summary = chapter.Summary,
ChapterPurposeID = chapter.ChapterPurposeID,
RevisionStatusID = chapter.RevisionStatusID,
@ -1505,13 +1543,14 @@ public sealed class ChapterService(
}
var isNew = model.ChapterID == 0;
var hasCanonicalScenes = !isNew && (model.HasCanonicalScenes || (await scenes.ListByChapterAsync(model.ChapterID)).Count > 0);
var chapterId = await chapters.SaveAsync(new Chapter
{
ChapterID = model.ChapterID,
BookID = model.BookID,
ChapterNumber = model.ChapterNumber,
ChapterTitle = model.ChapterTitle,
POVCharacterID = model.POVCharacterID,
POVCharacterID = hasCanonicalScenes ? null : model.POVCharacterID,
Summary = model.Summary,
ChapterPurposeID = model.ChapterPurposeID,
RevisionStatusID = model.RevisionStatusID
@ -1521,6 +1560,10 @@ public sealed class ChapterService(
{
await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle);
}
if (hasCanonicalScenes && model.POVCharacterID != model.OriginalDerivedPOVCharacterID)
{
await chapters.SetScenesPovAsync(chapterId, model.POVCharacterID, overwriteAuthor: false);
}
return chapterId;
}
@ -1561,29 +1604,36 @@ public sealed class ChapterService(
private static string BuildChapterPovDisplay(Chapter chapter, IReadOnlyList<Scene> scenes)
{
if (!string.IsNullOrWhiteSpace(chapter.POVCharacterName))
var derived = DeriveUniformScenePov(scenes);
if (scenes.Count > 0)
{
return chapter.POVCharacterName;
return derived.DisplayLabel;
}
if (!string.IsNullOrWhiteSpace(chapter.POVSuggestedName))
{
return chapter.POVSuggestedName;
return FirstNonBlank(chapter.POVCharacterName, chapter.POVSuggestedName) ?? "Not detected";
}
private static ChapterDerivedPov DeriveUniformScenePov(IReadOnlyList<Scene> scenes)
{
var scenePovs = scenes
.Select(scene => scene.POVCharacterName ?? scene.POVSuggestedName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(scene => new { scene.POVCharacterID, DisplayName = FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName) })
.Where(scene => scene.POVCharacterID.HasValue || !string.IsNullOrWhiteSpace(scene.DisplayName))
.GroupBy(scene => scene.POVCharacterID?.ToString() ?? scene.DisplayName!, StringComparer.OrdinalIgnoreCase)
.Select(group => new ChapterDerivedPov(group.First().POVCharacterID, group.First().DisplayName ?? "Unresolved POV"))
.ToList();
return scenePovs.Count switch
{
0 => "Not detected",
1 => scenePovs[0]!,
_ => "Mixed"
0 => new ChapterDerivedPov(null, "Not detected"),
1 => scenePovs[0],
_ => new ChapterDerivedPov(null, "Mixed")
};
}
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
private sealed record ChapterDerivedPov(int? CharacterID, string DisplayLabel);
}
public sealed class SceneService(

View File

@ -153,6 +153,7 @@ public sealed partial class ManuscriptStructureAnalysisService(
WordStartPosition = startMap.WordParagraphIndex,
WordEndPosition = endMap.WordParagraphIndex,
StructuralSummary = Trim(boundary.StructuralSummary, 600),
StructuralSetting = Trim(boundary.StructuralSetting, 300),
Reason = boundary.Reason,
Confidence = boundary.Confidence,
SuggestedPovName = pov?.CharacterName,
@ -613,6 +614,7 @@ public sealed class ManuscriptStructureSceneBoundary
public int WordStartPosition { get; init; }
public int WordEndPosition { get; init; }
public string? StructuralSummary { get; init; }
public string? StructuralSetting { get; init; }
public string? Reason { get; init; }
public decimal? Confidence { get; init; }
public string? SuggestedPovName { get; init; }

View File

@ -1029,7 +1029,7 @@ public sealed class OnboardingService(
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
{
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary, scene.StructuralSetting);
}
}
}

View File

@ -28,6 +28,7 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId);
}
public sealed class OnboardingStoryIntelligenceService(
@ -57,6 +58,7 @@ public sealed class OnboardingStoryIntelligenceService(
{
private const string PromptVersionsSummary = "Chapter=Chapter-Structure-Prompt-V2; Scene=Scene-Prompt-V2";
private const string SourceType = "OnboardingWordCompanionChapter";
private const string BookSourceType = "BookCanonicalSceneSource";
public async Task<StoryIntelligenceOverviewViewModel> GetOverviewAsync()
{
@ -251,6 +253,107 @@ public sealed class OnboardingStoryIntelligenceService(
return batch;
}
public async Task<OnboardingStoryIntelligenceBatch?> StartBookAsync(int bookId)
{
var userId = RequireUserId();
await entitlement.EnsureCanQueueAsync(userId);
var book = await books.GetAsync(bookId);
if (book is null)
{
return null;
}
var chapterRows = (await chapters.ListByBookAsync(bookId))
.Where(chapter => !chapter.IsArchived)
.OrderBy(chapter => chapter.SortOrder)
.ThenBy(chapter => chapter.ChapterNumber)
.ThenBy(chapter => chapter.ChapterID)
.ToList();
if (chapterRows.Count == 0)
{
throw new InvalidOperationException("Add or import chapters before analysing this book.");
}
var clientStatus = client.GetConfigurationStatus();
var modelSummary = StoryIntelligenceOptions.BuildModelSummary(
clientStatus.ChapterStructureModel,
clientStatus.SceneIntelligenceModel);
var knownCharactersJson = await BuildCanonicalKnownCharacterContextJsonAsync(book.ProjectID, new Dictionary<int, IReadOnlyList<int>>());
var batchItems = new List<OnboardingStoryIntelligenceBatchItem>();
foreach (var chapter in chapterRows)
{
var durableSource = await sources.GetChapterSourceAsync(chapter.ChapterID);
var text = durableSource?.SourceText?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var paragraphs = StoryIntelligenceParagraphs.Split(text);
var runId = await runs.QueueAdminTextAsync(new StoryIntelligenceRunQueueRequest
{
UserID = userId,
ProjectID = book.ProjectID,
BookID = book.BookID,
ChapterID = chapter.ChapterID,
ChapterNumber = chapter.ChapterNumber,
SourceType = BookSourceType,
SourceLabel = $"{book.BookDisplayTitle}: {CanonicalChapterTitle(chapter, chapter.ChapterNumber)}",
ChapterText = text,
SourceFileName = durableSource?.BookTitle ?? book.BookDisplayTitle,
SourceWordCount = durableSource?.SourceWordCount ?? CountWords(text),
SourceCharacterCount = durableSource?.SourceCharacterCount ?? text.Length,
SourceParagraphCount = paragraphs.Count,
SourceChapterCount = 1,
Model = modelSummary,
PromptVersionsSummary = PromptVersionsSummary,
KnownCharactersJson = knownCharactersJson
});
batchItems.Add(new OnboardingStoryIntelligenceBatchItem
{
TemporaryChapterKey = $"book-{book.BookID}-chapter-{chapter.ChapterID}",
ChapterNumber = Convert.ToInt32(chapter.ChapterNumber),
ChapterTitle = CanonicalChapterTitle(chapter, chapter.ChapterNumber),
ChapterID = chapter.ChapterID,
RunID = runId
});
await pipelineState.RecordChapterAnalysisQueuedAsync(book.ProjectID, book.BookID, runId);
}
if (batchItems.Count == 0)
{
throw new InvalidOperationException("PlotDirector could not find saved manuscript text for this book. Complete the core manuscript import before analysing it.");
}
var batch = new OnboardingStoryIntelligenceBatch
{
UserID = userId,
OnboardingID = 0,
PreviewID = Guid.Empty,
ProjectID = book.ProjectID,
BookID = book.BookID,
ProjectName = string.Empty,
BookTitle = book.BookDisplayTitle,
StoryEra = CleanContext(BookContextOptions.StoryEraLabel(book.StoryEra)),
StoryLocation = CleanContext(BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode)),
Items = batchItems
};
await batchStore.SaveAsync(batch);
logger.LogInformation(
"Queued book Story Intelligence batch {BatchID}. UserID={UserID} ProjectID={ProjectID} BookID={BookID} Chapters={ChapterCount}",
batch.BatchID,
userId,
book.ProjectID,
book.BookID,
batchItems.Count);
return batch;
}
private static string NormaliseAppearancePreference(string? value)
=> value?.Trim() switch
{
@ -331,6 +434,44 @@ public sealed class OnboardingStoryIntelligenceService(
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
private async Task<string?> BuildCanonicalKnownCharacterContextJsonAsync(int projectId, IReadOnlyDictionary<int, IReadOnlyList<int>> sceneIdsByCharacter)
{
var allCharacters = (await characters.ListCharactersAsync(projectId))
.Where(character => !character.IsArchived)
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
.ToList();
if (allCharacters.Count == 0)
{
return null;
}
var context = new List<StoryIntelligenceKnownCharacterContext>();
foreach (var character in allCharacters)
{
var aliases = (await characters.ListAliasesAsync(character.CharacterID))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
var mappedScenes = sceneIdsByCharacter.GetValueOrDefault(character.CharacterID)?.OrderBy(id => id).ToList() ?? [];
context.Add(new StoryIntelligenceKnownCharacterContext
{
CanonicalName = character.CharacterName,
Aliases = aliases,
SexHint = CharacterSexHintService.Normalise(character.Sex, character.CharacterName),
ExistingCharacterID = character.CharacterID,
CandidateKeys = [],
SceneIDs = mappedScenes,
MentionCount = mappedScenes.Count,
Confidence = mappedScenes.Count > 0 ? 1m : 0.8m,
Source = "CanonicalBook"
});
}
return JsonSerializer.Serialize(context, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
public static string? BuildKnownCharacterContextJson(ManuscriptScanPreview preview, ManuscriptScanReviewDecision review)
{
var candidates = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);

View File

@ -20,6 +20,7 @@ public sealed class PersistedStoryIntelligenceRunner(
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
IStoryIntelligenceSceneContextService sceneContexts,
IStoryIntelligenceClient client,
IChapterStructureValidator chapterValidator,
IStorySceneValidator sceneValidator,
@ -505,9 +506,18 @@ public sealed class PersistedStoryIntelligenceRunner(
detail);
}
var sceneBlocks = canonicalScenes
.Select((scene, index) => BuildCanonicalSceneBlock(run, scene, sourcesByScene[scene.SceneID], index + 1, configuredMetrics, existingPlotLines))
.ToList();
var sceneBlocks = new List<StorySceneTextBlock>();
for (var index = 0; index < canonicalScenes.Count; index++)
{
sceneBlocks.Add(await BuildCanonicalSceneBlockAsync(
run,
canonicalScenes,
canonicalScenes[index],
sourcesByScene[canonicalScenes[index].SceneID],
index + 1,
configuredMetrics,
existingPlotLines));
}
if (sceneBlocks.Any(block => !block.SplitValid))
{
@ -994,8 +1004,9 @@ public sealed class PersistedStoryIntelligenceRunner(
? SceneIntelligenceMaxOutputTokens()
: settings.MaxOutputTokens;
private static StorySceneTextBlock BuildCanonicalSceneBlock(
private async Task<StorySceneTextBlock> BuildCanonicalSceneBlockAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
int ordinal,
@ -1013,6 +1024,10 @@ public sealed class PersistedStoryIntelligenceRunner(
&& (!source.SourceStartParagraph.HasValue || !source.SourceEndParagraph.HasValue || source.SourceStartParagraph <= source.SourceEndParagraph)
&& (!source.WordStartParagraphIndex.HasValue || !source.WordEndParagraphIndex.HasValue || source.WordStartParagraphIndex <= source.WordEndParagraphIndex);
var context = splitValid
? await sceneContexts.BuildAsync(run, orderedChapterScenes, scene, source, configuredMetrics, existingPlotLines)
: null;
return new StorySceneTextBlock(
scene.SceneID,
sceneNumber,
@ -1020,16 +1035,7 @@ public sealed class PersistedStoryIntelligenceRunner(
end,
sourceLabel,
sceneText,
splitValid
? BuildSceneContextJson(
run,
scene.SceneID,
scene.SceneNumber,
canonicalSummary,
sourceLabel,
configuredMetrics,
existingPlotLines)
: string.Empty,
context is null ? string.Empty : StoryIntelligenceSceneContextService.ToPromptJson(context),
SceneTitleDisplay.Format(scene.SceneNumber, scene.SceneTitle),
canonicalSummary,
splitValid);

View File

@ -0,0 +1,319 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IStoryIntelligenceSceneContextService
{
Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines);
}
public sealed class StoryIntelligenceSceneContextService(
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ICharacterRepository characters) : IStoryIntelligenceSceneContextService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
public async Task<StoryIntelligenceCanonicalSceneContext> BuildAsync(
StoryIntelligenceQueuedRun run,
IReadOnlyList<Scene> orderedChapterScenes,
Scene scene,
SceneManuscriptSourceRecord source,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var book = run.BookID.HasValue ? await books.GetAsync(run.BookID.Value) : null;
var chapter = run.ChapterID.HasValue ? await chapters.GetAsync(run.ChapterID.Value) : null;
var neighbourScenes = await BuildNeighbourScenesAsync(book, scene);
var currentCharacters = await BuildCurrentCharactersAsync(scene);
return new StoryIntelligenceCanonicalSceneContext(
new StoryIntelligenceBookContext(
run.ProjectID ?? book?.ProjectID,
run.BookID ?? book?.BookID,
book?.BookDisplayTitle,
book?.StoryEra,
BookContextOptions.StoryEraLabel(book?.StoryEra),
book?.PrimaryLocaleCode,
BookContextOptions.PrimaryLocaleLabel(book?.PrimaryLocaleCode)),
new StoryIntelligenceChapterContext(
run.ChapterID ?? chapter?.ChapterID,
run.ChapterNumber ?? chapter?.ChapterNumber,
chapter?.SortOrder,
chapter?.ChapterTitle,
chapter?.Summary),
new StoryIntelligenceCurrentSceneContext(
scene.SceneID,
scene.SceneNumber,
scene.SortOrder,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary, source.StructuralSummary),
scene.StructuralSetting,
scene.PrimaryLocationName,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName),
scene.POVSource,
source.SourceStartParagraph,
source.SourceEndParagraph,
source.WordStartParagraphIndex,
source.WordEndParagraphIndex,
source.SourceWordCount,
source.SourceCharacterCount,
source.SourceKind,
source.SourceText),
neighbourScenes,
currentCharacters,
configuredMetrics.Select(metric => new StoryIntelligenceMetricContext(
$"metric_{metric.MetricTypeID}",
metric.MetricTypeID,
metric.MetricName,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue)).ToList(),
existingPlotLines.Select(plotLine => new StoryIntelligencePlotLineContext(
plotLine.PlotLineID,
plotLine.PlotLineName,
plotLine.Description,
plotLine.PlotImportanceName,
plotLine.BookID)).ToList(),
run.SourceLabel ?? string.Empty);
}
private async Task<IReadOnlyList<StoryIntelligenceNeighbourSceneContext>> BuildNeighbourScenesAsync(Book? book, Scene currentScene)
{
if (book is null)
{
return [];
}
var orderedScenes = new List<Scene>();
foreach (var chapter in await chapters.ListByBookAsync(book.BookID))
{
orderedScenes.AddRange((await scenes.ListByChapterAsync(chapter.ChapterID)).Where(scene => !scene.IsArchived));
}
orderedScenes = orderedScenes
.OrderBy(scene => scene.ChapterSortOrder)
.ThenBy(scene => scene.ChapterNumber)
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID)
.ToList();
var currentIndex = orderedScenes.FindIndex(scene => scene.SceneID == currentScene.SceneID);
if (currentIndex < 0)
{
return [];
}
var neighbours = new List<StoryIntelligenceNeighbourSceneContext>();
if (currentIndex > 0)
{
neighbours.Add(BuildNeighbour("previous", orderedScenes[currentIndex - 1]));
}
if (currentIndex < orderedScenes.Count - 1)
{
neighbours.Add(BuildNeighbour("next", orderedScenes[currentIndex + 1]));
}
return neighbours;
}
private static StoryIntelligenceNeighbourSceneContext BuildNeighbour(string position, Scene scene)
=> new(
position,
scene.SceneID,
scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.SceneTitle,
FirstNonBlank(scene.StructuralSummary, scene.Summary),
scene.StructuralSetting,
scene.POVCharacterID,
FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName));
private async Task<IReadOnlyList<StoryIntelligenceSceneCharacterContext>> BuildCurrentCharactersAsync(Scene scene)
{
var appearances = await characters.ListSceneCharactersAsync(scene.SceneID);
var characterIds = appearances.Select(appearance => appearance.CharacterID).ToHashSet();
if (scene.POVCharacterID.HasValue)
{
characterIds.Add(scene.POVCharacterID.Value);
}
var rows = new List<StoryIntelligenceSceneCharacterContext>();
foreach (var characterId in characterIds.OrderBy(id => id))
{
var appearance = appearances.FirstOrDefault(row => row.CharacterID == characterId);
var aliases = (await characters.ListAliasesAsync(characterId))
.Select(alias => alias.Alias)
.Where(alias => !string.IsNullOrWhiteSpace(alias))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(alias => alias, StringComparer.OrdinalIgnoreCase)
.ToList();
rows.Add(new StoryIntelligenceSceneCharacterContext(
characterId,
appearance?.CharacterName ?? (characterId == scene.POVCharacterID ? FirstNonBlank(scene.POVCharacterName, scene.POVSuggestedName) : null) ?? $"Character {characterId:N0}",
aliases,
characterId == scene.POVCharacterID,
appearance is not null,
appearance?.RoleInSceneTypeName,
appearance?.PresenceTypeName,
appearance?.MatchSource,
appearance?.MatchedText,
appearance?.OccurrenceCount,
appearance?.FirstOccurrenceIndex));
}
return rows;
}
private static string? FirstNonBlank(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim();
public static string ToPromptJson(StoryIntelligenceCanonicalSceneContext context)
=> JsonSerializer.Serialize(new
{
context.SourceLabel,
book = new
{
projectId = context.Book.ProjectID,
bookId = context.Book.BookID,
context.Book.Title,
context.Book.StoryEra,
context.Book.StoryEraLabel,
context.Book.PrimaryLocaleCode,
context.Book.PrimaryLocaleLabel
},
chapter = new
{
chapterId = context.Chapter.ChapterID,
chapterNumber = context.Chapter.ChapterNumber,
context.Chapter.SortOrder,
context.Chapter.Title,
context.Chapter.StructuralSummary
},
currentScene = new
{
sceneId = context.CurrentScene.SceneID,
sceneNumber = context.CurrentScene.SceneNumber,
context.CurrentScene.SortOrder,
context.CurrentScene.Title,
context.CurrentScene.StructuralSummary,
context.CurrentScene.StructuralSetting,
context.CurrentScene.PrimaryLocationName,
context.CurrentScene.POVCharacterID,
context.CurrentScene.POVCharacterName,
context.CurrentScene.POVSource,
sourceParagraphRange = new
{
context.CurrentScene.SourceStartParagraph,
context.CurrentScene.SourceEndParagraph,
context.CurrentScene.WordStartParagraphIndex,
context.CurrentScene.WordEndParagraphIndex,
context.CurrentScene.SourceWordCount,
context.CurrentScene.SourceCharacterCount,
context.CurrentScene.SourceKind
}
},
neighbourScenes = context.NeighbourScenes.Select(scene => new
{
scene.Position,
sceneId = scene.SceneID,
chapterId = scene.ChapterID,
scene.ChapterNumber,
scene.SceneNumber,
scene.Title,
scene.StructuralSummary,
scene.StructuralSetting,
povCharacterId = scene.POVCharacterID,
povCharacterName = scene.POVCharacterName
}),
sceneCharacters = context.SceneCharacters.Select(character => new
{
characterId = character.CharacterID,
character.CanonicalName,
character.Aliases,
character.IsPointOfView,
character.IsSceneMapped,
character.RoleInScene,
character.PresenceType,
character.MatchSource,
character.MatchedText,
character.OccurrenceCount,
character.FirstOccurrenceIndex
}),
configuredMetrics = context.ConfiguredMetrics.Select(metric => new
{
metric.Key,
metricTypeId = metric.MetricTypeID,
metric.Name,
metric.Description,
metric.MinValue,
metric.MaxValue,
metric.DefaultValue
}),
existingPlotLines = context.ExistingPlotLines.Select(plotLine => new
{
plotLineId = plotLine.PlotLineID,
plotLine.Name,
plotLine.Description,
plotLine.Status,
bookId = plotLine.BookID
})
}, JsonOptions);
}
public sealed record StoryIntelligenceCanonicalSceneContext(
StoryIntelligenceBookContext Book,
StoryIntelligenceChapterContext Chapter,
StoryIntelligenceCurrentSceneContext CurrentScene,
IReadOnlyList<StoryIntelligenceNeighbourSceneContext> NeighbourScenes,
IReadOnlyList<StoryIntelligenceSceneCharacterContext> SceneCharacters,
IReadOnlyList<StoryIntelligenceMetricContext> ConfiguredMetrics,
IReadOnlyList<StoryIntelligencePlotLineContext> ExistingPlotLines,
string SourceLabel);
public sealed record StoryIntelligenceBookContext(int? ProjectID, int? BookID, string? Title, string? StoryEra, string? StoryEraLabel, string? PrimaryLocaleCode, string? PrimaryLocaleLabel);
public sealed record StoryIntelligenceChapterContext(int? ChapterID, decimal? ChapterNumber, int? SortOrder, string? Title, string? StructuralSummary);
public sealed record StoryIntelligenceCurrentSceneContext(
int SceneID,
decimal SceneNumber,
int SortOrder,
string Title,
string? StructuralSummary,
string? StructuralSetting,
string? PrimaryLocationName,
int? POVCharacterID,
string? POVCharacterName,
string? POVSource,
int? SourceStartParagraph,
int? SourceEndParagraph,
int? WordStartParagraphIndex,
int? WordEndParagraphIndex,
int SourceWordCount,
int SourceCharacterCount,
string SourceKind,
[property: JsonIgnore] string SourceText);
public sealed record StoryIntelligenceNeighbourSceneContext(string Position, int SceneID, int ChapterID, decimal ChapterNumber, decimal SceneNumber, string Title, string? StructuralSummary, string? StructuralSetting, int? POVCharacterID, string? POVCharacterName);
public sealed record StoryIntelligenceSceneCharacterContext(int CharacterID, string CanonicalName, IReadOnlyList<string> Aliases, bool IsPointOfView, bool IsSceneMapped, string? RoleInScene, string? PresenceType, string? MatchSource, string? MatchedText, int? OccurrenceCount, int? FirstOccurrenceIndex);
public sealed record StoryIntelligenceMetricContext(string Key, int MetricTypeID, string Name, string? Description, int MinValue, int MaxValue, int DefaultValue);
public sealed record StoryIntelligencePlotLineContext(int PlotLineID, string Name, string? Description, string? Status, int? BookID);

View File

@ -0,0 +1,131 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.Scenes', N'StructuralSetting') IS NULL
ALTER TABLE dbo.Scenes ADD StructuralSetting nvarchar(300) NULL;
GO
CREATE OR ALTER PROCEDURE dbo.SceneStructuralSummary_Save
@SceneID int,
@StructuralSummary nvarchar(max),
@StructuralSetting nvarchar(300) = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.Scenes
SET StructuralSummary = NULLIF(LTRIM(RTRIM(@StructuralSummary)), N''),
StructuralSetting = COALESCE(NULLIF(LTRIM(RTRIM(@StructuralSetting)), N''), StructuralSetting),
Summary = CASE
WHEN NULLIF(LTRIM(RTRIM(Summary)), N'') IS NULL THEN NULLIF(LTRIM(RTRIM(@StructuralSummary)), N'')
ELSE Summary
END,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_ListByChapter
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT s.SceneID, s.ChapterID, c.BookID, b.BookNumber, b.SortOrder AS BookSortOrder, b.BookTitle, b.Subtitle AS BookSubtitle,
c.ChapterNumber, c.SortOrder AS ChapterSortOrder, c.ChapterTitle,
s.SceneNumber, s.SceneTitle, s.Summary, s.StructuralSummary, s.StructuralSetting,
s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName,
s.PrimaryLocationID, location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath,
s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder,
s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount,
s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID,
tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID,
rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived,
s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID,
s.ExternalReference, s.LinkedUtc, s.ImportSource, s.ImportRunID,
s.SourceStartParagraph, s.SourceEndParagraph
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID
LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID
WHERE s.ChapterID = @ChapterID AND s.IsArchived = 0
ORDER BY s.SortOrder, s.SceneNumber, s.SceneTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Scene_Get
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
EXEC dbo.SceneMetric_EnsureDefaults @SceneID;
SELECT s.SceneID, s.ChapterID, c.BookID, b.BookNumber, b.SortOrder AS BookSortOrder, b.BookTitle, b.Subtitle AS BookSubtitle,
c.ChapterNumber, c.SortOrder AS ChapterSortOrder, c.ChapterTitle,
s.SceneNumber, s.SceneTitle, s.SceneTitleSource, s.Summary, s.StructuralSummary, s.StructuralSetting,
s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName,
s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID,
location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath,
s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID,
du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes,
s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived,
s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, s.ExternalReference, s.LinkedUtc,
s.ImportSource, s.ImportRunID, s.SourceStartParagraph, s.SourceEndParagraph
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID
LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID
WHERE s.SceneID = @SceneID AND s.IsArchived = 0;
SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID;
EXEC dbo.SceneMetric_ListByScene @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByChapter
@ChapterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, scene.SceneNumber, scene.SceneTitle, scene.SceneTitleSource,
source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind,
scene.StructuralSummary, scene.StructuralSetting, source.CreatedUtc, source.UpdatedUtc
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
WHERE source.ChapterID = @ChapterID
AND scene.IsArchived = 0
ORDER BY scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.SceneManuscriptSource_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT source.SceneID, scene.SceneNumber, scene.SceneTitle, scene.SceneTitleSource,
source.ProjectID, source.BookID, source.ChapterID, source.SourceText,
source.SourceWordCount, source.SourceCharacterCount, source.SourceStartParagraph, source.SourceEndParagraph,
source.WordStartParagraphIndex, source.WordEndParagraphIndex, source.SourceKind,
scene.StructuralSummary, scene.StructuralSetting, source.CreatedUtc, source.UpdatedUtc
FROM dbo.SceneManuscriptSources source
INNER JOIN dbo.Scenes scene ON scene.SceneID = source.SceneID
INNER JOIN dbo.Chapters chapter ON chapter.ChapterID = scene.ChapterID
WHERE source.BookID = @BookID
AND scene.IsArchived = 0
ORDER BY chapter.SortOrder, chapter.ChapterNumber, scene.SortOrder, scene.SceneNumber, scene.SceneID;
END;
GO

View File

@ -256,6 +256,16 @@ public sealed class BookDetailViewModel
public ManuscriptDocumentModel? ManuscriptDocument { get; set; }
public IReadOnlyList<Chapter> Chapters { get; set; } = [];
public StoryIntelligenceBookPipelineState? StoryIntelligencePipeline { get; set; }
public StoryIntelligenceBookActionViewModel StoryIntelligenceAction { get; set; } = new();
}
public sealed class StoryIntelligenceBookActionViewModel
{
public bool HasCanonicalSceneSources { get; set; }
public bool CanStart { get; set; }
public string StatusLabel { get; set; } = "Not analysed";
public string ActionLabel { get; set; } = "Analyse My Book";
public string Description { get; set; } = "Analyse this book from its saved chapters and scenes.";
}
public sealed class ChapterEditViewModel
@ -278,6 +288,10 @@ public sealed class ChapterEditViewModel
[Display(Name = "POV character")]
public int? POVCharacterID { get; set; }
public int? OriginalDerivedPOVCharacterID { get; set; }
public bool HasCanonicalScenes { get; set; }
public string PovDisplayLabel { get; set; } = "Not detected";
[Display(Name = "Revision status")]
public int RevisionStatusID { get; set; }

View File

@ -16,6 +16,10 @@
{
<div class="alert alert-info">@archiveMessage</div>
}
@if (TempData["ArchiveError"] is string archiveError)
{
<div class="alert alert-danger">@archiveError</div>
}
@if (TempData["ManuscriptMessage"] is string manuscriptMessage)
{
<div class="alert alert-info">@manuscriptMessage</div>
@ -58,20 +62,30 @@
</div>
</div>
@if (Model.StoryIntelligencePipeline is not null)
@if (Model.StoryIntelligenceAction.HasCanonicalSceneSources || Model.StoryIntelligencePipeline is not null)
{
var pipeline = Model.StoryIntelligencePipeline;
var action = Model.StoryIntelligenceAction;
<section class="list-section">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<p class="eyebrow">Story Intelligence</p>
<h2>@(pipeline.IsComplete ? "Story Intelligence Complete" : "Continue Story Intelligence")</h2>
<p class="muted mb-0">@StoryIntelligenceDescription(pipeline)</p>
<h2>@action.StatusLabel</h2>
<p class="muted mb-0">@(pipeline is null ? action.Description : StoryIntelligenceDescription(pipeline))</p>
</div>
@if (pipeline is null)
{
<form asp-controller="Onboarding" asp-action="StartBookStoryIntelligence" asp-route-bookId="@Model.Book.BookID" method="post">
<button class="btn btn-primary" type="submit" disabled="@(!action.CanStart)">@action.ActionLabel</button>
</form>
}
else
{
<a class="btn btn-primary"
asp-controller="Onboarding"
asp-action="ContinueStoryIntelligence"
asp-route-bookId="@Model.Book.BookID">@(pipeline.IsComplete ? "View Story Intelligence Summary" : "Continue Story Intelligence")</a>
asp-route-bookId="@Model.Book.BookID">@action.ActionLabel</a>
}
</div>
</section>
}

View File

@ -14,6 +14,8 @@
<form asp-action="Save" method="post" class="edit-panel">
<input asp-for="ChapterID" type="hidden" />
<input asp-for="BookID" type="hidden" />
<input asp-for="OriginalDerivedPOVCharacterID" type="hidden" />
<input asp-for="HasCanonicalScenes" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="row g-3">
<div class="col-md-3">
@ -36,6 +38,10 @@
<div class="col-md-3">
<label asp-for="POVCharacterID" class="form-label"></label>
<select asp-for="POVCharacterID" asp-items="Model.CharacterOptions" class="form-select"></select>
@if (Model.HasCanonicalScenes)
{
<span class="form-text">Current scene POV: @Model.PovDisplayLabel</span>
}
</div>
<div class="col-12">
<label asp-for="Summary" class="form-label"></label>