Add canonical story intelligence scene context
This commit is contained in:
parent
58075232e7
commit
108df3db3e
@ -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.");
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
319
PlotLine/Services/StoryIntelligenceSceneContextService.cs
Normal file
319
PlotLine/Services/StoryIntelligenceSceneContextService.cs
Normal 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);
|
||||
131
PlotLine/Sql/167_Phase26C_CanonicalStoryIntelligenceContext.sql
Normal file
131
PlotLine/Sql/167_Phase26C_CanonicalStoryIntelligenceContext.sql
Normal 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
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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>
|
||||
<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>
|
||||
@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">@action.ActionLabel</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user