Add Story Intelligence narrative review stage

This commit is contained in:
Nick Beckley 2026-08-25 14:49:19 +00:00
parent 398579c5c9
commit a6145a25e8
22 changed files with 1364 additions and 54 deletions

View File

@ -96,6 +96,12 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels),
("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave),
("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues),
("Story Intelligence scene prompt uses configured metrics", StoryIntelligenceScenePromptUsesConfiguredMetrics),
("Story Intelligence runner supplies metrics and Plot Lines", StoryIntelligenceRunnerSuppliesMetricsAndPlotLines),
("Story Intelligence metric persistence uses configured keys", StoryIntelligenceMetricPersistenceUsesConfiguredKeys),
("Story Intelligence narrative candidates require review", StoryIntelligenceNarrativeCandidatesRequireReview),
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes),
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
@ -2501,6 +2507,102 @@ static void ProjectMetricsAddDefaultsBackfillsSceneValues()
Assert(selfHealingDefaults.Contains("Overall Intensity", StringComparison.Ordinal), "Core metric seed should include the default PlotDirector metrics.");
}
static void StoryIntelligenceScenePromptUsesConfiguredMetrics()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Scene-Prompt-V2.md"));
var validator = File.ReadAllText(Path.Combine(root, "Services/StorySceneValidator.cs"));
Assert(prompt.Contains("configuredMetrics", StringComparison.Ordinal), "Scene prompt should read configured metrics from scene context.");
Assert(prompt.Contains("use the exact `key` supplied in `sceneContext`", StringComparison.Ordinal), "Scene prompt should use runtime metric keys.");
Assert(prompt.Contains("Do not invent a separate metric catalogue", StringComparison.Ordinal), "Scene prompt should not embed an independent metric catalogue.");
Assert(!prompt.Contains("Preferred V1 metrics:", StringComparison.Ordinal), "Scene prompt should not force the old V1 metric list.");
Assert(!validator.Contains("PreferredMetrics", StringComparison.Ordinal), "Validator should not reject custom configured metric names.");
Assert(validator.Contains("configured metric scale", StringComparison.Ordinal), "Validator error guidance should refer to configured metric scales.");
}
static void StoryIntelligenceRunnerSuppliesMetricsAndPlotLines()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var runner = File.ReadAllText(Path.Combine(root, "Services/PersistedStoryIntelligenceRunner.cs"));
Assert(runner.Contains("ISceneMetricTypeRepository metricTypes", StringComparison.Ordinal), "Runner should load metric definitions from the configured project repository.");
Assert(runner.Contains("IPlotRepository plots", StringComparison.Ordinal), "Runner should load existing Plot Lines for AI matching context.");
Assert(runner.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Runner should only pass active project-enabled metric types.");
Assert(runner.Contains("configuredMetrics = configuredMetrics.Select", StringComparison.Ordinal), "Scene context should include configured metrics.");
Assert(runner.Contains("existingPlotLines = existingPlotLines.Select", StringComparison.Ordinal), "Scene context should include existing author-created Plot Lines.");
Assert(runner.Contains("key = MetricContextKey(metric)", StringComparison.Ordinal), "Scene prompt metrics should use stable configured keys.");
Assert(runner.Contains("metricTypeId = metric.MetricTypeID", StringComparison.Ordinal), "Scene context should preserve MetricTypeID for later persistence.");
}
static void StoryIntelligenceMetricPersistenceUsesConfiguredKeys()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/StoryIntelligenceResultRepository.cs"));
Assert(service.Contains("map[$\"metric_{metric.MetricTypeID}\"] = metric", StringComparison.Ordinal), "Metric persistence should map configured context keys back to MetricTypeID.");
Assert(service.Contains("map.TryAdd(cleanedName, metric)", StringComparison.Ordinal), "Metric persistence should also accept configured metric names.");
Assert(service.Contains("Math.Clamp(metric.Score.Value, metricType.MinValue, metricType.MaxValue)", StringComparison.Ordinal), "Metric values should be clamped to configured scale.");
Assert(repository.Contains("\"dbo.SceneMetric_SaveValue\"", StringComparison.Ordinal), "Metric values should persist through the existing stored procedure.");
Assert(repository.Contains("new { SceneID = sceneId, metric.MetricTypeID, metric.Value, metric.Notes }", StringComparison.Ordinal), "Metrics should persist by SceneID and MetricTypeID.");
}
static void StoryIntelligenceNarrativeCandidatesRequireReview()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
var projectDelete = File.ReadAllText(Path.Combine(root, "Sql/124_Phase20Z2_StoryIntelligenceCommitAndProjectHardDelete.sql"));
var bookDelete = File.ReadAllText(Path.Combine(root, "Sql/134_Phase20AV_ArchivedBookProjectPermanentDeletion.sql"));
Assert(sql.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Narrative candidates should be persisted for review.");
Assert(sql.Contains("Status nvarchar(20) NOT NULL", StringComparison.Ordinal), "Narrative candidates should have review status.");
Assert(sql.Contains("CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending'", StringComparison.Ordinal), "Narrative candidates should default to pending review.");
Assert(models.Contains("StoryIntelligenceNarrativeCandidateTypes", StringComparison.Ordinal), "Candidate types should be explicit.");
Assert(service.Contains("Where(signal => signal.SceneIds.Count > 1)", StringComparison.Ordinal), "Thread discovery should filter one-off incidental details.");
Assert(view.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "A dedicated narrative review page should be visible.");
Assert(view.Contains("Accept new", StringComparison.Ordinal) && view.Contains("Merge with existing", StringComparison.Ordinal) && view.Contains("Reject", StringComparison.Ordinal), "Review UI should support accept, merge and reject.");
Assert(projectDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && projectDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Project hard delete should clear narrative review candidates.");
Assert(bookDelete.Contains("StoryIntelligenceNarrativeCandidateEvidence", StringComparison.Ordinal) && bookDelete.Contains("StoryIntelligenceNarrativeCandidates", StringComparison.Ordinal), "Book hard delete should clear narrative review candidates.");
}
static void StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
var sql = File.ReadAllText(Path.Combine(root, "Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql"));
Assert(service.Contains("plots.SavePlotLineAsync(new PlotLineItem", StringComparison.Ordinal), "Accepted Plot Line candidates should create canonical PlotLines through the existing plot service.");
Assert(service.Contains("plots.SavePlotThreadAsync(new PlotThread", StringComparison.Ordinal), "Accepted Thread candidates should create canonical PlotThreads through the existing plot service.");
Assert(service.Contains("plots.SaveThreadEventAsync(new ThreadEvent", StringComparison.Ordinal), "Accepted Thread evidence should create canonical ThreadEvents.");
Assert(repository.Contains("\"dbo.PlotLine_Save\"", StringComparison.Ordinal), "PlotLine creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.PlotThread_Save\"", StringComparison.Ordinal), "PlotThread creation should remain stored-procedure based.");
Assert(repository.Contains("\"dbo.ThreadEvent_Save\"", StringComparison.Ordinal), "ThreadEvent creation should remain stored-procedure based.");
Assert(sql.Contains("StoryIntelligenceNarrativeCandidate_MarkReviewed", StringComparison.Ordinal), "Candidate decisions should be persisted.");
}
static void StoryIntelligenceNarrativeStageFollowsKnowledge()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var stages = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligencePersistenceModels.cs"));
var pipeline = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligencePipelineStateService.cs"));
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
var knowledgeComplete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceKnowledgeComplete.cshtml"));
Assert(stages.Contains("NarrativeReview", StringComparison.Ordinal) && stages.Contains("NarrativeImport", StringComparison.Ordinal), "Pipeline stages should include narrative review/import.");
Assert(stages.Contains("LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport", StringComparison.Ordinal), "Pipeline completion should require narrative import.");
Assert(pipeline.Contains("CurrentStage = StoryIntelligencePipelineStages.NarrativeReview", StringComparison.Ordinal), "Knowledge import should lead to narrative review.");
Assert(pipeline.Contains("RecordNarrativeImportAsync", StringComparison.Ordinal), "Narrative import should mark the pipeline complete.");
Assert(onboarding.Contains("StoryIntelligenceResumeRoutes.Narrative", StringComparison.Ordinal), "Resume logic should route to narrative review.");
Assert(controller.Contains("StoryIntelligenceNarrative", StringComparison.Ordinal), "Onboarding controller should expose the narrative review stage.");
Assert(knowledgeComplete.Contains("Review Plot Lines & Threads", StringComparison.Ordinal), "Knowledge completion should lead to narrative review.");
}
static void WordCompanionReplaysLiveCurrentSceneToWritingPanes()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -99,6 +99,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return target.Route switch
{
StoryIntelligenceResumeRoutes.Complete => RedirectToAction(nameof(StoryIntelligenceComplete), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Narrative => RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Knowledge => RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Relationships => RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId = target.BatchID }),
StoryIntelligenceResumeRoutes.Assets => RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId = target.BatchID }),
@ -479,6 +480,73 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/narrative")]
public async Task<IActionResult> StoryIntelligenceNarrative(Guid batchId)
{
var model = await storyIntelligence.GetProgressAsync(batchId);
if (model is null)
{
return NotFound();
}
if (model.HasActiveRuns)
{
return RedirectToAction(nameof(StoryIntelligenceProgress), new { batchId });
}
if (!model.AllCommitted)
{
return RedirectToAction(nameof(StoryIntelligenceReview), new { batchId });
}
if (!model.PipelineDashboard.CharacterStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceCharacters), new { batchId });
}
if (!model.PipelineDashboard.LocationStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId });
}
if (!model.PipelineDashboard.AssetStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceAssets), new { batchId });
}
if (!model.PipelineDashboard.RelationshipStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceRelationships), new { batchId });
}
return !model.PipelineDashboard.KnowledgeStageComplete
? RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId })
: View(model);
}
[HttpPost("story-intelligence/narrative")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceNarrative(StoryIntelligenceNarrativeImportForm form)
{
var (progress, result) = await storyIntelligence.ImportNarrativeAsync(form.BatchID, form);
if (progress is null)
{
return NotFound();
}
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
return result.Success
? RedirectToAction(nameof(StoryIntelligenceNarrativeComplete), new { batchId = form.BatchID })
: RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId = form.BatchID });
}
[HttpGet("story-intelligence/narrative/complete")]
public async Task<IActionResult> StoryIntelligenceNarrativeComplete(Guid batchId)
{
var model = await storyIntelligence.GetNarrativeImportResultAsync(batchId);
return model is null ? NotFound() : View(model);
}
[HttpGet("story-intelligence/complete")]
public async Task<IActionResult> StoryIntelligenceComplete(Guid batchId)
{
@ -513,6 +581,11 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(StoryIntelligenceKnowledge), new { batchId });
}
if (!progress.PipelineDashboard.NarrativeStageComplete)
{
return RedirectToAction(nameof(StoryIntelligenceNarrative), new { batchId });
}
var model = await storyIntelligence.GetCompletionAsync(batchId);
return model is null ? NotFound() : View(model);
}

View File

@ -166,6 +166,14 @@ public interface IPlotRepository
Task<IReadOnlyList<SceneOption>> ListSceneOptionsAsync(int projectId);
}
public interface IStoryIntelligenceNarrativeCandidateRepository
{
Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate);
Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder);
Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId);
Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId);
}
public interface IAssetRepository
{
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
@ -3319,6 +3327,86 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP
}
}
public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceNarrativeCandidateRepository
{
public async Task<int> SaveAsync(StoryIntelligenceNarrativeCandidateSeed candidate)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.StoryIntelligenceNarrativeCandidate_Save",
new
{
candidate.ProjectID,
candidate.BookID,
candidate.CandidateType,
candidate.CandidateKey,
candidate.ProposedTitle,
candidate.ProposedDescription,
candidate.Category,
candidate.InferredState,
candidate.ExistingPlotLineID,
candidate.ExistingPlotThreadID,
candidate.RelatedPlotLineID,
candidate.Confidence
},
commandType: CommandType.StoredProcedure);
}
public async Task SaveEvidenceAsync(int candidateId, int sceneId, string? contributionType, string? evidence, int sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidateEvidence_Save",
new
{
NarrativeCandidateID = candidateId,
SceneID = sceneId,
ContributionType = contributionType,
Evidence = evidence,
SortOrder = sortOrder
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<StoryIntelligenceNarrativeCandidate>> ListByBookAsync(int bookId)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.StoryIntelligenceNarrativeCandidate_ListByBook",
new { BookID = bookId },
commandType: CommandType.StoredProcedure);
var candidates = (await result.ReadAsync<StoryIntelligenceNarrativeCandidate>()).ToList();
var evidence = (await result.ReadAsync<StoryIntelligenceNarrativeCandidateEvidence>()).ToList();
var evidenceByCandidate = evidence
.GroupBy(item => item.NarrativeCandidateID)
.ToDictionary(group => group.Key, group => (IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence>)group.ToList());
foreach (var candidate in candidates)
{
candidate.Evidence = evidenceByCandidate.TryGetValue(candidate.NarrativeCandidateID, out var items) ? items : [];
}
return candidates;
}
public async Task MarkReviewedAsync(int candidateId, string status, string reviewAction, int userId, int? acceptedPlotLineId, int? acceptedPlotThreadId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed",
new
{
NarrativeCandidateID = candidateId,
Status = status,
ReviewAction = reviewAction,
ReviewedByUserID = userId,
AcceptedPlotLineID = acceptedPlotLineId,
AcceptedPlotThreadID = acceptedPlotThreadId
},
commandType: CommandType.StoredProcedure);
}
}
public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository
{
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)

View File

@ -172,32 +172,23 @@ Bad summary style:
## Metric Scoring Rules
Metrics are descriptive intensity scores from 1 to 10. They are not quality ratings.
Metrics are descriptive intensity scores. They are not quality ratings.
Use:
Use the `configuredMetrics` array supplied in `sceneContext`. Do not invent a separate metric catalogue.
- `1`: barely present;
- `2`: very low;
- `3`: low;
- `4`: mild;
- `5`: moderate;
- `6`: noticeable;
- `7`: strong;
- `8`: very strong;
- `9`: dominant;
- `10`: defining feature of the scene.
For each configured metric:
Preferred V1 metrics:
- use the exact `key` supplied in `sceneContext` as the property name in `metrics`;
- use the supplied `name`, `description`, `minValue`, `maxValue` and `defaultValue` to understand the metric;
- score only what occurs in this scene, not imagined future consequences;
- treat the scale comparatively across scenes in the book;
- use the configured minimum when the metric is absent or barely present;
- use the low range for minor/background presence;
- use the middle range for meaningful/moderate presence;
- use the high range for dominant/intense presence;
- use the configured maximum only for exceptional/extreme presence.
- `action`
- `emotion`
- `tension`
- `conflict`
- `mystery`
- `romance`
- `humour`
- `revelation`
- `pacingIntensity`
If no configured metrics are supplied, return an empty `metrics` object.
Each metric must be an object:
@ -205,7 +196,7 @@ Each metric must be an object:
{ "score": 8, "confidence": 0.75 }
```
Always emit every preferred V1 metric. If a metric is barely present, use score `1` with an appropriate confidence. Do not omit preferred metric objects.
Always emit every configured metric. If a metric is barely present, use the configured minimum value with an appropriate confidence. Do not omit configured metric objects.
## Character Reporting Rules
@ -680,15 +671,7 @@ Return exactly one JSON object matching this shape:
}
],
"metrics": {
"action": { "score": 1, "confidence": 0.0 },
"emotion": { "score": 1, "confidence": 0.0 },
"tension": { "score": 1, "confidence": 0.0 },
"conflict": { "score": 1, "confidence": 0.0 },
"mystery": { "score": 1, "confidence": 0.0 },
"romance": { "score": 1, "confidence": 0.0 },
"humour": { "score": 1, "confidence": 0.0 },
"revelation": { "score": 1, "confidence": 0.0 },
"pacingIntensity": { "score": 1, "confidence": 0.0 }
"configuredMetricKey": { "score": 1, "confidence": 0.0 }
},
"sourceLimits": {
"containsAmbiguity": false,

View File

@ -0,0 +1,84 @@
namespace PlotLine.Models;
public static class StoryIntelligenceNarrativeCandidateTypes
{
public const string PlotLine = "PlotLine";
public const string Thread = "Thread";
}
public static class StoryIntelligenceNarrativeCandidateStatuses
{
public const string Pending = "Pending";
public const string Accepted = "Accepted";
public const string Rejected = "Rejected";
public const string Merged = "Merged";
}
public static class StoryIntelligenceNarrativeReviewActions
{
public const string AcceptNew = "AcceptNew";
public const string MergeExisting = "MergeExisting";
public const string Reject = "Reject";
}
public sealed class StoryIntelligenceNarrativeCandidate
{
public int NarrativeCandidateID { get; set; }
public int ProjectID { get; set; }
public int BookID { get; set; }
public string CandidateType { get; set; } = string.Empty;
public string CandidateKey { get; set; } = string.Empty;
public string ProposedTitle { get; set; } = string.Empty;
public string? ProposedDescription { get; set; }
public string? Category { get; set; }
public string? InferredState { get; set; }
public int? ExistingPlotLineID { get; set; }
public string? ExistingPlotLineName { get; set; }
public int? ExistingPlotThreadID { get; set; }
public string? ExistingPlotThreadTitle { get; set; }
public int? RelatedPlotLineID { get; set; }
public string? RelatedPlotLineName { get; set; }
public decimal? Confidence { get; set; }
public string Status { get; set; } = StoryIntelligenceNarrativeCandidateStatuses.Pending;
public string? ReviewAction { get; set; }
public int? AcceptedPlotLineID { get; set; }
public string? AcceptedPlotLineName { get; set; }
public int? AcceptedPlotThreadID { get; set; }
public string? AcceptedPlotThreadTitle { get; set; }
public int? ReviewedByUserID { get; set; }
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public IReadOnlyList<StoryIntelligenceNarrativeCandidateEvidence> Evidence { get; set; } = [];
}
public sealed class StoryIntelligenceNarrativeCandidateEvidence
{
public int NarrativeCandidateEvidenceID { get; set; }
public int NarrativeCandidateID { get; set; }
public int SceneID { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
public decimal ChapterNumber { get; set; }
public string ChapterTitle { get; set; } = string.Empty;
public string? ContributionType { get; set; }
public string? Evidence { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class StoryIntelligenceNarrativeCandidateSeed
{
public int ProjectID { get; init; }
public int BookID { get; init; }
public string CandidateType { get; init; } = string.Empty;
public string CandidateKey { get; init; } = string.Empty;
public string ProposedTitle { get; init; } = string.Empty;
public string? ProposedDescription { get; init; }
public string? Category { get; init; }
public string? InferredState { get; init; }
public int? ExistingPlotLineID { get; init; }
public int? ExistingPlotThreadID { get; init; }
public int? RelatedPlotLineID { get; init; }
public decimal? Confidence { get; init; }
}

View File

@ -36,6 +36,8 @@ public static class StoryIntelligencePipelineStages
public const string RelationshipImport = "RelationshipImport";
public const string KnowledgeReview = "KnowledgeReview";
public const string KnowledgeImport = "KnowledgeImport";
public const string NarrativeReview = "NarrativeReview";
public const string NarrativeImport = "NarrativeImport";
public const string Complete = "Complete";
}
@ -66,7 +68,7 @@ public sealed class StoryIntelligenceBookPipelineState
public bool IsComplete
=> string.Equals(Status, StoryIntelligencePipelineStatuses.Complete, StringComparison.OrdinalIgnoreCase)
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase);
&& string.Equals(LastCompletedStage, StoryIntelligencePipelineStages.NarrativeImport, StringComparison.OrdinalIgnoreCase);
}
public sealed class StoryIntelligenceImportNotificationTarget

View File

@ -122,6 +122,7 @@ public class Program
builder.Services.AddScoped<ITimelineSettingsRepository, TimelineSettingsRepository>();
builder.Services.AddScoped<ISceneMetricTypeRepository, SceneMetricTypeRepository>();
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeCandidateRepository, StoryIntelligenceNarrativeCandidateRepository>();
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();
builder.Services.AddScoped<ILocationRepository, LocationRepository>();
@ -226,6 +227,7 @@ public class Program
builder.Services.AddScoped<IStoryIntelligenceAssetImportService, StoryIntelligenceAssetImportService>();
builder.Services.AddScoped<IStoryIntelligenceRelationshipImportService, StoryIntelligenceRelationshipImportService>();
builder.Services.AddScoped<IStoryIntelligenceKnowledgeImportService, StoryIntelligenceKnowledgeImportService>();
builder.Services.AddScoped<IStoryIntelligenceNarrativeReviewService, StoryIntelligenceNarrativeReviewService>();
builder.Services.AddScoped<IStoryMemoryService, StoryMemoryService>();
builder.Services.AddScoped<ICharacterIntelligenceService, CharacterIntelligenceService>();
builder.Services.AddScoped<INameIntelligenceService, NameIntelligenceService>();

View File

@ -24,6 +24,8 @@ public interface IOnboardingStoryIntelligenceService
Task<StoryIntelligenceRelationshipImportResultViewModel?> GetRelationshipImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportKnowledgeAsync(Guid batchId, StoryIntelligenceKnowledgeImportForm form);
Task<StoryIntelligenceKnowledgeImportResultViewModel?> GetKnowledgeImportResultAsync(Guid batchId);
Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId);
Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId);
Task<OnboardingStoryIntelligenceResumeTarget?> ResumeBookAsync(int bookId);
}
@ -39,6 +41,7 @@ public sealed class OnboardingStoryIntelligenceService(
IStoryIntelligenceAssetImportService assetImport,
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
IStoryIntelligenceNarrativeReviewService narrativeReview,
IStoryIntelligenceSourceRepository sources,
IStoryIntelligencePipelineStateService pipelineState,
IStoryMemoryRepository storyMemory,
@ -414,6 +417,7 @@ public sealed class OnboardingStoryIntelligenceService(
var assetReview = await assetImport.BuildReviewAsync(batch);
var relationshipReview = await relationshipImport.BuildReviewAsync(batch);
var knowledgeReview = await knowledgeImport.BuildReviewAsync(batch);
var narrative = await narrativeReview.GetReviewAsync(batch);
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
return new StoryIntelligenceProgressViewModel
{
@ -430,6 +434,7 @@ public sealed class OnboardingStoryIntelligenceService(
AssetReview = assetReview,
RelationshipReview = relationshipReview,
KnowledgeReview = knowledgeReview,
NarrativeReview = narrative,
PipelineDashboard = new StoryIntelligencePipelineDashboardViewModel
{
ChaptersAnalysed = chapters.Count(chapter => chapter.IsRunComplete),
@ -448,7 +453,12 @@ public sealed class OnboardingStoryIntelligenceService(
RelationshipStageComplete = batch.RelationshipStageComplete || relationshipReview.IsComplete,
KnowledgeIdentified = knowledgeReview.Candidates.Count + batch.KnowledgeDecisions.Count,
KnowledgeCreatedOrLinked = batch.KnowledgeDecisions.Count(decision => decision.CreatedOrLinked),
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete
KnowledgeStageComplete = batch.KnowledgeStageComplete || knowledgeReview.IsComplete,
NarrativeCandidatesIdentified = narrative.Candidates.Count + (batch.LastNarrativeImportResult?.TotalReviewed ?? 0),
NarrativeCandidatesApplied = (batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0)
+ (batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0)
+ (batch.LastNarrativeImportResult?.ThreadsCreated ?? 0),
NarrativeStageComplete = batch.NarrativeStageComplete || narrative.IsComplete
}
};
}
@ -682,6 +692,25 @@ public sealed class OnboardingStoryIntelligenceService(
};
}
public async Task<(StoryIntelligenceProgressViewModel? Progress, StoryIntelligenceImportCommitResult Result)> ImportNarrativeAsync(Guid batchId, StoryIntelligenceNarrativeImportForm form)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
if (batch is null)
{
return (null, new StoryIntelligenceImportCommitResult { Success = false, Message = "This Story Intelligence batch could not be found." });
}
var result = await narrativeReview.ImportAsync(batch, form, RequireUserId());
await batchStore.SaveAsync(batch);
return (await GetProgressAsync(batchId), result);
}
public async Task<StoryIntelligenceNarrativeImportResultViewModel?> GetNarrativeImportResultAsync(Guid batchId)
{
var batch = await batchStore.GetAsync(RequireUserId(), batchId);
return batch is null ? null : await narrativeReview.GetResultAsync(batch);
}
public async Task<StoryIntelligenceCompletionViewModel?> GetCompletionAsync(Guid batchId)
{
var progress = await GetProgressAsync(batchId);
@ -774,6 +803,8 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var locationStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.LocationImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.AssetReview
@ -782,29 +813,39 @@ public sealed class OnboardingStoryIntelligenceService(
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var assetStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.AssetImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview
or StoryIntelligencePipelineStages.RelationshipImport
or StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var relationshipStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.RelationshipImport)
|| state.CurrentStage is StoryIntelligencePipelineStages.KnowledgeReview
or StoryIntelligencePipelineStages.KnowledgeImport
or StoryIntelligencePipelineStages.NarrativeReview
or StoryIntelligencePipelineStages.NarrativeImport
or StoryIntelligencePipelineStages.Complete;
var knowledgeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.KnowledgeImport);
var narrativeStageComplete = HasCompletedStage(state, StoryIntelligencePipelineStages.NarrativeImport);
batch.CharacterStageComplete = characterStageComplete;
batch.LocationStageComplete = locationStageComplete;
batch.AssetStageComplete = assetStageComplete;
batch.RelationshipStageComplete = relationshipStageComplete;
batch.KnowledgeStageComplete = knowledgeStageComplete;
batch.NarrativeStageComplete = narrativeStageComplete;
await batchStore.SaveAsync(batch);
var route = knowledgeStageComplete
var route = narrativeStageComplete
? StoryIntelligenceResumeRoutes.Complete
: knowledgeStageComplete
? StoryIntelligenceResumeRoutes.Narrative
: relationshipStageComplete
? StoryIntelligenceResumeRoutes.Knowledge
: assetStageComplete
@ -819,6 +860,8 @@ public sealed class OnboardingStoryIntelligenceService(
StoryIntelligencePipelineStages.RelationshipImport => StoryIntelligenceResumeRoutes.Relationships,
StoryIntelligencePipelineStages.KnowledgeReview => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.KnowledgeImport => StoryIntelligenceResumeRoutes.Knowledge,
StoryIntelligencePipelineStages.NarrativeReview => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.NarrativeImport => StoryIntelligenceResumeRoutes.Narrative,
StoryIntelligencePipelineStages.CharacterReview => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.CharacterImport => StoryIntelligenceResumeRoutes.Characters,
StoryIntelligencePipelineStages.SceneReview => StoryIntelligenceResumeRoutes.SceneReview,
@ -1075,11 +1118,13 @@ public sealed class OnboardingStoryIntelligenceBatch
public StoryIntelligenceAssetImportBatchResult? LastAssetImportResult { get; set; }
public StoryIntelligenceRelationshipImportBatchResult? LastRelationshipImportResult { get; set; }
public StoryIntelligenceKnowledgeImportBatchResult? LastKnowledgeImportResult { get; set; }
public StoryIntelligenceNarrativeImportBatchResult? LastNarrativeImportResult { get; set; }
public bool CharacterStageComplete { get; set; }
public bool LocationStageComplete { get; set; }
public bool AssetStageComplete { get; set; }
public bool RelationshipStageComplete { get; set; }
public bool KnowledgeStageComplete { get; set; }
public bool NarrativeStageComplete { get; set; }
}
public sealed class OnboardingStoryIntelligenceBatchItem
@ -1194,6 +1239,15 @@ public sealed class StoryIntelligenceKnowledgeImportBatchResult
public int KnowledgeIgnored { get; init; }
}
public sealed class StoryIntelligenceNarrativeImportBatchResult
{
public int PlotLinesCreated { get; init; }
public int PlotLinesMerged { get; init; }
public int ThreadsCreated { get; init; }
public int CandidatesRejected { get; init; }
public int TotalReviewed => PlotLinesCreated + PlotLinesMerged + ThreadsCreated + CandidatesRejected;
}
public static class StoryIntelligenceResumeRoutes
{
public const string SceneReview = "SceneReview";
@ -1202,6 +1256,7 @@ public static class StoryIntelligenceResumeRoutes
public const string Assets = "Assets";
public const string Relationships = "Relationships";
public const string Knowledge = "Knowledge";
public const string Narrative = "Narrative";
public const string Complete = "Complete";
}

View File

@ -14,6 +14,8 @@ public interface IPersistedStoryIntelligenceRunner
public sealed class PersistedStoryIntelligenceRunner(
IStoryIntelligenceResultRepository repository,
IStoryIntelligencePipelineRepository pipelines,
ISceneMetricTypeRepository metricTypes,
IPlotRepository plots,
IStoryPromptRepository prompts,
IStoryPromptVersionService versions,
IStoryPromptBuilder scenePromptBuilder,
@ -207,8 +209,22 @@ public sealed class PersistedStoryIntelligenceRunner(
BuildValidationDetail(chapterValidation));
}
var configuredMetrics = run.ProjectID.HasValue
? (await metricTypes.ListForManagementAsync(run.ProjectID.Value))
.Where(metric => metric.IsActive && metric.IsEnabledForProject)
.OrderBy(metric => metric.SortOrder)
.ThenBy(metric => metric.MetricName)
.ToList()
: [];
var existingPlotLines = run.ProjectID.HasValue
? (await plots.ListPlotLinesAsync(run.ProjectID.Value))
.Where(plotLine => !run.BookID.HasValue || !plotLine.BookID.HasValue || plotLine.BookID == run.BookID)
.OrderBy(plotLine => plotLine.SortOrder)
.ThenBy(plotLine => plotLine.PlotLineName)
.ToList()
: [];
var sceneBlocks = chapterForProcessing.SceneBoundaries
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary))
.Select(boundary => BuildSceneBlock(run, paragraphs, boundary, configuredMetrics, existingPlotLines))
.ToList();
failedScenes += sceneBlocks.Count(block => !block.SplitValid);
currentFailureStage = StoryIntelligenceFailureStages.SceneSplit;
@ -843,7 +859,12 @@ public sealed class PersistedStoryIntelligenceRunner(
? SceneIntelligenceMaxOutputTokens()
: settings.MaxOutputTokens;
private static StorySceneTextBlock BuildSceneBlock(StoryIntelligenceQueuedRun run, IReadOnlyList<string> paragraphs, ChapterSceneBoundary boundary)
private static StorySceneTextBlock BuildSceneBlock(
StoryIntelligenceQueuedRun run,
IReadOnlyList<string> paragraphs,
ChapterSceneBoundary boundary,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
{
var sceneNumber = boundary.SceneNumber ?? 0;
var start = boundary.StartParagraph ?? 0;
@ -862,7 +883,7 @@ public sealed class PersistedStoryIntelligenceRunner(
end,
sourceLabel,
sceneText,
BuildSceneContextJson(run, boundary),
BuildSceneContextJson(run, boundary, configuredMetrics, existingPlotLines),
true);
}
@ -877,7 +898,11 @@ public sealed class PersistedStoryIntelligenceRunner(
knownCharacters = KnownCharacters(run)
}, JsonOptions);
private static string BuildSceneContextJson(StoryIntelligenceQueuedRun run, ChapterSceneBoundary boundary)
private static string BuildSceneContextJson(
StoryIntelligenceQueuedRun run,
ChapterSceneBoundary boundary,
IReadOnlyList<SceneMetricType> configuredMetrics,
IReadOnlyList<PlotLineItem> existingPlotLines)
=> JsonSerializer.Serialize(new
{
projectId = run.ProjectID,
@ -888,9 +913,30 @@ public sealed class PersistedStoryIntelligenceRunner(
sceneNumber = boundary.SceneNumber,
structuralSummary = boundary.StructuralSummary,
sourceLabel = $"{SourceLabel(run)}, suggested scene {boundary.SceneNumber}",
knownCharacters = KnownCharacters(run)
knownCharacters = KnownCharacters(run),
configuredMetrics = configuredMetrics.Select(metric => new
{
key = MetricContextKey(metric),
metricTypeId = metric.MetricTypeID,
name = metric.MetricName,
description = metric.Description,
minValue = metric.MinValue,
maxValue = metric.MaxValue,
defaultValue = metric.DefaultValue
}),
existingPlotLines = existingPlotLines.Select(plotLine => new
{
plotLineId = plotLine.PlotLineID,
name = plotLine.PlotLineName,
description = plotLine.Description,
status = plotLine.PlotImportanceName,
bookId = plotLine.BookID
})
}, JsonOptions);
private static string MetricContextKey(SceneMetricType metric)
=> $"metric_{metric.MetricTypeID}";
private static IReadOnlyList<StoryIntelligenceKnownCharacterContext> KnownCharacters(StoryIntelligenceQueuedRun run)
{
if (string.IsNullOrWhiteSpace(run.KnownCharactersJson))

View File

@ -358,6 +358,16 @@ public sealed class StoryIntelligenceImportCommitService(
{
var byName = metricTypes.ToDictionary(metric => Clean(metric.MetricName), metric => metric, StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, SceneMetricType>(StringComparer.OrdinalIgnoreCase);
foreach (var metric in metricTypes)
{
map[$"metric_{metric.MetricTypeID}"] = metric;
var cleanedName = Clean(metric.MetricName);
if (!string.IsNullOrWhiteSpace(cleanedName))
{
map.TryAdd(cleanedName, metric);
}
}
foreach (var (schemaName, aliases) in MetricAliases)
{
var metric = aliases.Select(alias => byName.TryGetValue(Clean(alias), out var match) ? match : null).FirstOrDefault(match => match is not null);

View File

@ -0,0 +1,403 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IStoryIntelligenceNarrativeReviewService
{
Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch);
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId);
Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch);
}
public sealed class StoryIntelligenceNarrativeReviewService(
IStoryIntelligenceNarrativeCandidateRepository candidates,
IStoryIntelligenceResultRepository runs,
ISceneRepository scenes,
IPlotRepository plots,
IStoryIntelligencePipelineStateService pipelineState) : IStoryIntelligenceNarrativeReviewService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
public async Task<StoryIntelligenceNarrativeReviewViewModel> GetReviewAsync(OnboardingStoryIntelligenceBatch batch)
{
await EnsureCandidatesAsync(batch);
var rows = await candidates.ListByBookAsync(batch.BookID);
var pending = rows
.Where(candidate => string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
.ToList();
var plotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
return new StoryIntelligenceNarrativeReviewViewModel
{
HasCommittedScenes = await HasCommittedScenesAsync(batch),
CanImport = pending.Count > 0,
IsComplete = rows.Count > 0 && pending.Count == 0,
PlotLineOptions = plotLines
.OrderBy(plotLine => plotLine.SortOrder)
.ThenBy(plotLine => plotLine.PlotLineName)
.Select(plotLine => new SelectListItem(plotLine.PlotLineName, plotLine.PlotLineID.ToString()))
.ToList(),
Candidates = pending.Select(ToViewModel).ToList()
};
}
public async Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceNarrativeImportForm form, int userId)
{
await EnsureCandidatesAsync(batch);
var rows = (await candidates.ListByBookAsync(batch.BookID)).ToDictionary(candidate => candidate.NarrativeCandidateID);
var lookups = await plots.GetLookupsAsync();
var plotLineTypeId = lookups.PlotLineTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Main Plot", StringComparison.OrdinalIgnoreCase))?.PlotLineTypeID
?? lookups.PlotLineTypes.FirstOrDefault()?.PlotLineTypeID
?? 0;
var plotImportanceId = lookups.PlotImportance.FirstOrDefault(importance => string.Equals(importance.ImportanceName, "Primary", StringComparison.OrdinalIgnoreCase))?.PlotImportanceID
?? lookups.PlotImportance.FirstOrDefault()?.PlotImportanceID
?? 0;
var threadTypeId = lookups.ThreadTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Question", StringComparison.OrdinalIgnoreCase))?.ThreadTypeID
?? lookups.ThreadTypes.FirstOrDefault()?.ThreadTypeID
?? 0;
var threadStatusId = lookups.ThreadStatuses.FirstOrDefault(status => string.Equals(status.StatusName, "Open", StringComparison.OrdinalIgnoreCase))?.ThreadStatusID
?? lookups.ThreadStatuses.FirstOrDefault()?.ThreadStatusID
?? 0;
var threadEventTypeId = lookups.ThreadEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Raised", StringComparison.OrdinalIgnoreCase))?.ThreadEventTypeID
?? lookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0;
var plotEventTypeId = lookups.PlotEventTypes.FirstOrDefault(type => string.Equals(type.TypeName, "Progress", StringComparison.OrdinalIgnoreCase))?.PlotEventTypeID
?? lookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0;
var plotLinesCreated = 0;
var plotLinesMerged = 0;
var threadsCreated = 0;
var rejected = 0;
foreach (var choice in form.Candidates.Where(choice => choice.NarrativeCandidateID > 0))
{
if (!rows.TryGetValue(choice.NarrativeCandidateID, out var candidate)
|| !string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var action = string.IsNullOrWhiteSpace(choice.Action) ? StoryIntelligenceNarrativeReviewActions.AcceptNew : choice.Action;
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.Reject, StringComparison.OrdinalIgnoreCase))
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, action, userId, null, null);
rejected++;
continue;
}
if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.PlotLine, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(action, StoryIntelligenceNarrativeReviewActions.MergeExisting, StringComparison.OrdinalIgnoreCase) && choice.ExistingPlotLineID is > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Merged, action, userId, choice.ExistingPlotLineID, null);
plotLinesMerged++;
continue;
}
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
PlotLineName = CleanTitle(choice.Title, candidate.ProposedTitle),
Description = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
PlotLineTypeID = plotLineTypeId,
PlotImportanceID = plotImportanceId,
SortOrder = 0,
Colour = "#2f6f63",
IsVisibleOnTimeline = true
});
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, plotLineId, null);
plotLinesCreated++;
continue;
}
var relatedPlotLineId = choice.RelatedPlotLineID ?? candidate.RelatedPlotLineID ?? choice.ExistingPlotLineID;
if (relatedPlotLineId is not > 0)
{
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Rejected, StoryIntelligenceNarrativeReviewActions.Reject, userId, null, null);
rejected++;
continue;
}
var firstSceneId = candidate.Evidence.OrderBy(evidence => evidence.SortOrder).FirstOrDefault()?.SceneID;
var threadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = relatedPlotLineId.Value,
ThreadTitle = CleanTitle(choice.Title, candidate.ProposedTitle),
Summary = CleanNullable(choice.Description) ?? candidate.ProposedDescription,
ThreadTypeID = threadTypeId,
ThreadStatusID = threadStatusId,
Importance = 5,
IntroducedSceneID = firstSceneId
});
foreach (var evidence in candidate.Evidence.Where(evidence => evidence.SceneID > 0))
{
await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = threadId,
SceneID = evidence.SceneID,
EventTypeID = threadEventTypeId,
PlotEventTypeID = plotEventTypeId,
EventTitle = string.IsNullOrWhiteSpace(evidence.ContributionType) ? candidate.ProposedTitle : evidence.ContributionType,
EventDescription = evidence.Evidence
});
}
await candidates.MarkReviewedAsync(candidate.NarrativeCandidateID, StoryIntelligenceNarrativeCandidateStatuses.Accepted, action, userId, null, threadId);
threadsCreated++;
}
batch.LastNarrativeImportResult = new StoryIntelligenceNarrativeImportBatchResult
{
PlotLinesCreated = plotLinesCreated,
PlotLinesMerged = plotLinesMerged,
ThreadsCreated = threadsCreated,
CandidatesRejected = rejected
};
batch.NarrativeStageComplete = true;
await pipelineState.RecordNarrativeImportAsync(batch.ProjectID, batch.BookID);
return new StoryIntelligenceImportCommitResult
{
Success = true,
Message = $"Narrative review applied. {plotLinesCreated:N0} Plot Line(s) created, {threadsCreated:N0} Thread(s) created, {plotLinesMerged:N0} merged, {rejected:N0} rejected."
};
}
public Task<StoryIntelligenceNarrativeImportResultViewModel?> GetResultAsync(OnboardingStoryIntelligenceBatch batch)
=> Task.FromResult<StoryIntelligenceNarrativeImportResultViewModel?>(new StoryIntelligenceNarrativeImportResultViewModel
{
BatchID = batch.BatchID,
ProjectID = batch.ProjectID,
BookID = batch.BookID,
PlotLinesCreated = batch.LastNarrativeImportResult?.PlotLinesCreated ?? 0,
PlotLinesMerged = batch.LastNarrativeImportResult?.PlotLinesMerged ?? 0,
ThreadsCreated = batch.LastNarrativeImportResult?.ThreadsCreated ?? 0,
CandidatesRejected = batch.LastNarrativeImportResult?.CandidatesRejected ?? 0
});
private async Task EnsureCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
{
var existingPlotLines = await plots.ListPlotLinesAsync(batch.ProjectID);
var existingThreads = await plots.ListPlotThreadsByProjectAsync(batch.ProjectID);
var existingPlotLineByKey = existingPlotLines
.GroupBy(plotLine => Key(plotLine.PlotLineName), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var existingThreadByKey = existingThreads
.GroupBy(thread => Key(thread.ThreadTitle), StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var signals = new Dictionary<string, NarrativeSignal>(StringComparer.OrdinalIgnoreCase);
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is null || !string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var importedScenes = (await scenes.ListByChapterAsync(item.ChapterID))
.Where(scene => scene.ImportRunID == item.RunID)
.ToList();
var importedByRange = importedScenes
.Where(scene => scene.SourceStartParagraph.HasValue && scene.SourceEndParagraph.HasValue)
.ToDictionary(scene => $"{scene.SourceStartParagraph}-{scene.SourceEndParagraph}", scene => scene, StringComparer.OrdinalIgnoreCase);
var importedByNumber = importedScenes.ToDictionary(scene => Convert.ToInt32(scene.SceneNumber), scene => scene);
var sceneResults = await runs.ListSceneResultsAsync(item.RunID);
foreach (var sceneResult in sceneResults)
{
var importedScene = ResolveImportedScene(sceneResult, importedByRange, importedByNumber);
var parsed = TryReadScene(sceneResult);
if (importedScene is null || parsed is null)
{
continue;
}
foreach (var question in parsed.QuestionsRaised ?? [])
{
AddSignal(signals, batch, importedScene, CleanNullable(question.Question), "Question", "Open", question.Evidence, question.Confidence);
}
foreach (var clue in parsed.TimelineClues ?? [])
{
AddSignal(signals, batch, importedScene, CleanNullable(clue.Clue), "Timeline clue", "Introduced", clue.Evidence, clue.Confidence);
}
}
}
foreach (var signal in signals.Values.Where(signal => signal.SceneIds.Count > 1).OrderBy(signal => signal.Title))
{
var existingThread = existingThreadByKey.GetValueOrDefault(Key(signal.Title));
var existingPlotLine = existingPlotLineByKey.GetValueOrDefault(Key(signal.Title));
var candidateType = signal.SceneIds.Count >= 3 && existingThread is null
? StoryIntelligenceNarrativeCandidateTypes.PlotLine
: StoryIntelligenceNarrativeCandidateTypes.Thread;
var candidateId = await candidates.SaveAsync(new StoryIntelligenceNarrativeCandidateSeed
{
ProjectID = batch.ProjectID,
BookID = batch.BookID,
CandidateType = candidateType,
CandidateKey = $"{candidateType}:{Key(signal.Title)}",
ProposedTitle = signal.Title,
ProposedDescription = signal.Description,
Category = signal.Category,
InferredState = signal.State,
ExistingPlotLineID = existingPlotLine?.PlotLineID,
ExistingPlotThreadID = existingThread?.PlotThreadID,
RelatedPlotLineID = existingThread?.PlotLineID,
Confidence = signal.Confidence
});
var sortOrder = 0;
foreach (var item in signal.Evidence.OrderBy(item => item.SceneID))
{
await candidates.SaveEvidenceAsync(candidateId, item.SceneID, item.ContributionType, item.Evidence, sortOrder++);
}
}
}
private async Task<bool> HasCommittedScenesAsync(OnboardingStoryIntelligenceBatch batch)
{
foreach (var item in batch.Items)
{
var commit = await runs.GetImportCommitAsync(item.RunID);
if (commit is not null && string.Equals(commit.Status, StoryIntelligenceImportCommitStatuses.Completed, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static StoryIntelligenceNarrativeReviewCandidateViewModel ToViewModel(StoryIntelligenceNarrativeCandidate candidate)
=> new()
{
NarrativeCandidateID = candidate.NarrativeCandidateID,
CandidateType = candidate.CandidateType,
ProposedTitle = candidate.ProposedTitle,
ProposedDescription = candidate.ProposedDescription ?? string.Empty,
Category = candidate.Category ?? string.Empty,
InferredState = candidate.InferredState ?? string.Empty,
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
ExistingPlotLineID = candidate.ExistingPlotLineID,
RelatedPlotLineID = candidate.RelatedPlotLineID,
RelatedPlotLineName = candidate.RelatedPlotLineName,
DefaultAction = candidate.ExistingPlotLineID.HasValue || candidate.ExistingPlotThreadID.HasValue
? StoryIntelligenceNarrativeReviewActions.MergeExisting
: StoryIntelligenceNarrativeReviewActions.AcceptNew,
Evidence = candidate.Evidence.Select(evidence => new StoryIntelligenceNarrativeEvidenceViewModel
{
SceneID = evidence.SceneID,
SceneLabel = $"Chapter {evidence.ChapterNumber:0.##}, Scene {evidence.SceneNumber:0.##}: {evidence.SceneTitle}",
ContributionType = evidence.ContributionType ?? string.Empty,
Evidence = evidence.Evidence ?? string.Empty
}).ToList()
};
private static void AddSignal(
Dictionary<string, NarrativeSignal> signals,
OnboardingStoryIntelligenceBatch batch,
Scene scene,
string? title,
string category,
string state,
string? evidence,
decimal? confidence)
{
if (string.IsNullOrWhiteSpace(title) || title.Length < 12)
{
return;
}
var key = Key(title);
if (!signals.TryGetValue(key, out var signal))
{
signal = new NarrativeSignal(batch.ProjectID, batch.BookID, title, category, state);
signals[key] = signal;
}
signal.SceneIds.Add(scene.SceneID);
signal.Confidence = confidence.HasValue
? Math.Max(signal.Confidence ?? 0m, confidence.Value)
: signal.Confidence;
signal.Evidence.Add(new NarrativeSignalEvidence(scene.SceneID, category, evidence));
}
private static Scene? ResolveImportedScene(
StoryIntelligenceSavedSceneResult sceneResult,
IReadOnlyDictionary<string, Scene> importedByRange,
IReadOnlyDictionary<int, Scene> importedByNumber)
{
if (sceneResult.StartParagraph.HasValue
&& sceneResult.EndParagraph.HasValue
&& importedByRange.TryGetValue($"{sceneResult.StartParagraph}-{sceneResult.EndParagraph}", out var byRange))
{
return byRange;
}
return importedByNumber.TryGetValue(sceneResult.TemporarySceneNumber, out var byNumber) ? byNumber : null;
}
private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result)
{
foreach (var json in new[] { result.ParsedJson, result.OutputTextJson })
{
if (string.IsNullOrWhiteSpace(json))
{
continue;
}
try
{
var direct = JsonSerializer.Deserialize<SceneIntelligenceScene>(json, JsonOptions);
if (direct?.Summary is not null)
{
return direct;
}
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("parsedScene", out var parsedScene))
{
return parsedScene.Deserialize<SceneIntelligenceScene>(JsonOptions);
}
}
catch (JsonException)
{
// Try the next persisted JSON form.
}
}
return null;
}
private static string CleanTitle(string? preferred, string fallback)
=> string.IsNullOrWhiteSpace(preferred) ? fallback.Trim() : preferred.Trim();
private static string? CleanNullable(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string Key(string? value)
=> new((value ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private sealed record NarrativeSignal(int ProjectID, int BookID, string Title, string Category, string State)
{
public HashSet<int> SceneIds { get; } = [];
public List<NarrativeSignalEvidence> Evidence { get; } = [];
public decimal? Confidence { get; set; }
public string Description => $"{Category} recurring across {SceneIds.Count:N0} scenes.";
}
private sealed record NarrativeSignalEvidence(int SceneID, string ContributionType, string? Evidence);
}

View File

@ -15,6 +15,7 @@ public interface IStoryIntelligencePipelineStateService
Task RecordAssetImportAsync(int projectId, int bookId);
Task RecordRelationshipImportAsync(int projectId, int bookId);
Task RecordKnowledgeImportAsync(int projectId, int bookId);
Task RecordNarrativeImportAsync(int projectId, int bookId);
Task<IReadOnlyList<StoryIntelligenceCommittedRunSummary>> ListCommittedRunsByBookAsync(int bookId, int userId);
}
@ -160,8 +161,23 @@ public sealed class StoryIntelligencePipelineStateService(
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
CurrentStage = StoryIntelligencePipelineStages.NarrativeReview,
LastCompletedStage = StoryIntelligencePipelineStages.KnowledgeImport,
CurrentReviewStage = StoryIntelligencePipelineStages.NarrativeReview,
Status = StoryIntelligencePipelineStatuses.NeedsReview,
CompletedUtc = null,
LastRunID = null
});
}
public async Task RecordNarrativeImportAsync(int projectId, int bookId)
{
await pipelines.UpsertAsync(new StoryIntelligenceBookPipelineSaveRequest
{
ProjectID = projectId,
BookID = bookId,
CurrentStage = StoryIntelligencePipelineStages.Complete,
LastCompletedStage = StoryIntelligencePipelineStages.NarrativeImport,
CurrentReviewStage = null,
Status = StoryIntelligencePipelineStatuses.Complete,
CompletedUtc = DateTime.UtcNow,

View File

@ -16,7 +16,6 @@ public sealed class StorySceneValidator : IStorySceneValidator
private static readonly HashSet<string> KnowledgeChangeTypes = CreateSet("Learns", "Realises", "IsTold", "Discovers", "Confirms", "Suspects", "Misunderstands");
private static readonly HashSet<string> ObservationTypes = CreateSet("CharacterAction", "CharacterKnowledge", "CharacterLocation", "RelationshipSignal", "AssetInteraction", "LocationSignal", "TimelineSignal", "QuestionSignal");
private static readonly HashSet<string> EntityTypes = CreateSet("Character", "Location", "Asset", "Knowledge", "Relationship", "Timeline", "Scene", "Unknown");
private static readonly HashSet<string> PreferredMetrics = CreateSet("action", "emotion", "tension", "conflict", "mystery", "romance", "humour", "revelation", "pacingIntensity");
private static readonly HashSet<string> RelationshipSignals = CreateSet("trust", "mistrust", "affection", "fear", "authority", "secrecy", "conflict", "dependence", "protection", "rivalry", "obligation");
public ValidationResult Validate(SceneIntelligenceScene? scene)
@ -399,24 +398,15 @@ public sealed class StorySceneValidator : IStorySceneValidator
foreach (var (name, metric) in metrics)
{
var path = $"metrics.{name}";
if (!PreferredMetrics.Contains(name))
{
AddWarning(result, path, "Metric is outside the preferred V1 metric set.", "Use preferred V1 metrics unless the schema is intentionally expanded.");
}
if (metric is null)
{
AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": 1-10, \"confidence\": 0.0-1.0 }.");
AddError(result, path, "Metric object is required when a metric is present.", "Return { \"score\": configured-scale-number, \"confidence\": 0.0-1.0 }.");
continue;
}
if (!metric.Score.HasValue)
{
AddError(result, $"{path}.score", "Metric score is required.", "Return a score from 1 to 10.");
}
else if (metric.Score < 1 || metric.Score > 10)
{
AddError(result, $"{path}.score", "Metric score must be from 1 to 10.", "Use the V1 1-10 descriptive intensity scale.");
AddError(result, $"{path}.score", "Metric score is required.", "Return a numeric score using the configured metric scale.");
}
Confidence(metric.Confidence, $"{path}.confidence", result);

View File

@ -111,6 +111,18 @@ BEGIN
WHERE consent.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = consent.BookID);
DELETE evidence
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID
WHERE candidate.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID)
OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID);
DELETE candidate
FROM dbo.StoryIntelligenceNarrativeCandidates candidate
WHERE candidate.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = candidate.BookID);
DELETE FROM dbo.ProjectActivity WHERE ProjectID = @ProjectID;
DELETE prh
FROM dbo.ProjectRestoreHistory prh

View File

@ -118,6 +118,14 @@ BEGIN
DELETE FROM dbo.StoryIntelligenceConsents WHERE BookID = @BookID;
DELETE evidence
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence evidence
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates candidate ON candidate.NarrativeCandidateID = evidence.NarrativeCandidateID
WHERE candidate.BookID = @BookID
OR EXISTS (SELECT 1 FROM @SceneIDs scene WHERE scene.SceneID = evidence.SceneID);
DELETE FROM dbo.StoryIntelligenceNarrativeCandidates WHERE BookID = @BookID;
DELETE petpl
FROM dbo.PlotEventTargetPlotLines petpl
INNER JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID

View File

@ -0,0 +1,209 @@
IF OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvidence', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence
(
NarrativeCandidateEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceNarrativeCandidateEvidence PRIMARY KEY,
NarrativeCandidateID int NOT NULL,
SceneID int NOT NULL,
ContributionType nvarchar(40) NULL,
Evidence nvarchar(1000) NULL,
SortOrder int NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvidence_SortOrder DEFAULT 0,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidateEvidence_CreatedUtc DEFAULT SYSUTCDATETIME()
);
END;
IF OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates', N'U') IS NULL
BEGIN
CREATE TABLE dbo.StoryIntelligenceNarrativeCandidates
(
NarrativeCandidateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_StoryIntelligenceNarrativeCandidates PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NOT NULL,
CandidateType nvarchar(20) NOT NULL,
CandidateKey nvarchar(220) NOT NULL,
ProposedTitle nvarchar(200) NOT NULL,
ProposedDescription nvarchar(1000) NULL,
Category nvarchar(80) NULL,
InferredState nvarchar(40) NULL,
ExistingPlotLineID int NULL,
ExistingPlotThreadID int NULL,
RelatedPlotLineID int NULL,
Confidence decimal(5,4) NULL,
Status nvarchar(20) NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_Status DEFAULT N'Pending',
ReviewAction nvarchar(40) NULL,
AcceptedPlotLineID int NULL,
AcceptedPlotThreadID int NULL,
ReviewedByUserID int NULL,
ReviewedUtc datetime2 NULL,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_StoryIntelligenceNarrativeCandidates_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_ExistingPlotLines FOREIGN KEY (ExistingPlotLineID) REFERENCES dbo.PlotLines(PlotLineID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_RelatedPlotLines FOREIGN KEY (RelatedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_AcceptedPlotLines FOREIGN KEY (AcceptedPlotLineID) REFERENCES dbo.PlotLines(PlotLineID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_ExistingPlotThreads FOREIGN KEY (ExistingPlotThreadID) REFERENCES dbo.PlotThreads(PlotThreadID),
CONSTRAINT FK_StoryIntelligenceNarrativeCandidates_AcceptedPlotThreads FOREIGN KEY (AcceptedPlotThreadID) REFERENCES dbo.PlotThreads(PlotThreadID)
);
END;
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidateEvidence_Candidates')
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence
ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvidence_Candidates
FOREIGN KEY (NarrativeCandidateID) REFERENCES dbo.StoryIntelligenceNarrativeCandidates(NarrativeCandidateID) ON DELETE CASCADE;
END;
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_StoryIntelligenceNarrativeCandidateEvidence_Scenes')
BEGIN
ALTER TABLE dbo.StoryIntelligenceNarrativeCandidateEvidence
ADD CONSTRAINT FK_StoryIntelligenceNarrativeCandidateEvidence_Scenes
FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID);
END;
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_StoryIntelligenceNarrativeCandidates_BookTypeKey' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates'))
CREATE UNIQUE INDEX UX_StoryIntelligenceNarrativeCandidates_BookTypeKey ON dbo.StoryIntelligenceNarrativeCandidates(BookID, CandidateType, CandidateKey);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceNarrativeCandidates_Status' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidates'))
CREATE INDEX IX_StoryIntelligenceNarrativeCandidates_Status ON dbo.StoryIntelligenceNarrativeCandidates(BookID, Status, CandidateType);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceNarrativeCandidateEvidence_Candidate' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceNarrativeCandidateEvidence'))
CREATE INDEX IX_StoryIntelligenceNarrativeCandidateEvidence_Candidate ON dbo.StoryIntelligenceNarrativeCandidateEvidence(NarrativeCandidateID, SceneID);
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_Save
@ProjectID int,
@BookID int,
@CandidateType nvarchar(20),
@CandidateKey nvarchar(220),
@ProposedTitle nvarchar(200),
@ProposedDescription nvarchar(1000) = NULL,
@Category nvarchar(80) = NULL,
@InferredState nvarchar(40) = NULL,
@ExistingPlotLineID int = NULL,
@ExistingPlotThreadID int = NULL,
@RelatedPlotLineID int = NULL,
@Confidence decimal(5,4) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @NarrativeCandidateID int;
SELECT @NarrativeCandidateID = NarrativeCandidateID
FROM dbo.StoryIntelligenceNarrativeCandidates
WHERE BookID = @BookID AND CandidateType = @CandidateType AND CandidateKey = @CandidateKey;
IF @NarrativeCandidateID IS NULL
BEGIN
INSERT dbo.StoryIntelligenceNarrativeCandidates
(ProjectID, BookID, CandidateType, CandidateKey, ProposedTitle, ProposedDescription, Category, InferredState,
ExistingPlotLineID, ExistingPlotThreadID, RelatedPlotLineID, Confidence)
VALUES
(@ProjectID, @BookID, @CandidateType, @CandidateKey, @ProposedTitle, @ProposedDescription, @Category, @InferredState,
@ExistingPlotLineID, @ExistingPlotThreadID, @RelatedPlotLineID, @Confidence);
SET @NarrativeCandidateID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.StoryIntelligenceNarrativeCandidates
SET ProposedTitle = @ProposedTitle,
ProposedDescription = @ProposedDescription,
Category = @Category,
InferredState = @InferredState,
ExistingPlotLineID = @ExistingPlotLineID,
ExistingPlotThreadID = @ExistingPlotThreadID,
RelatedPlotLineID = @RelatedPlotLineID,
Confidence = @Confidence,
UpdatedUtc = SYSUTCDATETIME()
WHERE NarrativeCandidateID = @NarrativeCandidateID
AND Status = N'Pending';
END;
SELECT @NarrativeCandidateID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidateEvidence_Save
@NarrativeCandidateID int,
@SceneID int,
@ContributionType nvarchar(40) = NULL,
@Evidence nvarchar(1000) = NULL,
@SortOrder int = 0
AS
BEGIN
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence
WHERE NarrativeCandidateID = @NarrativeCandidateID
AND SceneID = @SceneID
AND ISNULL(Evidence, N'') = ISNULL(@Evidence, N'')
)
BEGIN
INSERT dbo.StoryIntelligenceNarrativeCandidateEvidence (NarrativeCandidateID, SceneID, ContributionType, Evidence, SortOrder)
VALUES (@NarrativeCandidateID, @SceneID, @ContributionType, @Evidence, @SortOrder);
END;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_ListByBook
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT nc.NarrativeCandidateID, nc.ProjectID, nc.BookID, nc.CandidateType, nc.CandidateKey,
nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState,
nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName,
nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle,
nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName,
nc.Confidence, nc.Status, nc.ReviewAction, nc.AcceptedPlotLineID, apl.PlotLineName AS AcceptedPlotLineName,
nc.AcceptedPlotThreadID, apt.ThreadTitle AS AcceptedPlotThreadTitle,
nc.ReviewedByUserID, nc.ReviewedUtc, nc.CreatedUtc, nc.UpdatedUtc
FROM dbo.StoryIntelligenceNarrativeCandidates nc
LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID
LEFT JOIN dbo.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID
LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID
LEFT JOIN dbo.PlotLines apl ON apl.PlotLineID = nc.AcceptedPlotLineID
LEFT JOIN dbo.PlotThreads apt ON apt.PlotThreadID = nc.AcceptedPlotThreadID
WHERE nc.BookID = @BookID
ORDER BY CASE nc.Status WHEN N'Pending' THEN 0 ELSE 1 END, nc.CandidateType, nc.ProposedTitle;
SELECT e.NarrativeCandidateEvidenceID, e.NarrativeCandidateID, e.SceneID,
s.SceneNumber, s.SceneTitle, c.ChapterNumber, c.ChapterTitle,
e.ContributionType, e.Evidence, e.SortOrder, e.CreatedUtc
FROM dbo.StoryIntelligenceNarrativeCandidateEvidence e
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc ON nc.NarrativeCandidateID = e.NarrativeCandidateID
INNER JOIN dbo.Scenes s ON s.SceneID = e.SceneID
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE nc.BookID = @BookID
ORDER BY e.NarrativeCandidateID, c.SortOrder, s.SortOrder, e.SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceNarrativeCandidate_MarkReviewed
@NarrativeCandidateID int,
@Status nvarchar(20),
@ReviewAction nvarchar(40),
@ReviewedByUserID int,
@AcceptedPlotLineID int = NULL,
@AcceptedPlotThreadID int = NULL
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.StoryIntelligenceNarrativeCandidates
SET Status = @Status,
ReviewAction = @ReviewAction,
ReviewedByUserID = @ReviewedByUserID,
ReviewedUtc = SYSUTCDATETIME(),
AcceptedPlotLineID = @AcceptedPlotLineID,
AcceptedPlotThreadID = @AcceptedPlotThreadID,
UpdatedUtc = SYSUTCDATETIME()
WHERE NarrativeCandidateID = @NarrativeCandidateID;
END;
GO

View File

@ -153,6 +153,7 @@ public sealed class StoryIntelligenceProgressViewModel
public StoryIntelligenceAssetReviewViewModel AssetReview { get; init; } = new();
public StoryIntelligenceRelationshipReviewViewModel RelationshipReview { get; init; } = new();
public StoryIntelligenceKnowledgeReviewViewModel KnowledgeReview { get; init; } = new();
public StoryIntelligenceNarrativeReviewViewModel NarrativeReview { get; init; } = new();
public StoryIntelligencePipelineDashboardViewModel PipelineDashboard { get; init; } = new();
public int ChapterCount => Chapters.Count;
public int CompletedChapterCount => Chapters.Count(chapter => chapter.IsRunComplete);
@ -172,6 +173,7 @@ public sealed class StoryIntelligenceProgressViewModel
public bool HasAssetsToReview => AssetReview.Candidates.Count > 0;
public bool HasRelationshipsToReview => RelationshipReview.Candidates.Count > 0;
public bool HasKnowledgeToReview => KnowledgeReview.Candidates.Count > 0;
public bool HasNarrativeToReview => NarrativeReview.Candidates.Count > 0;
}
public sealed class StoryIntelligencePipelineDashboardViewModel
@ -193,6 +195,70 @@ public sealed class StoryIntelligencePipelineDashboardViewModel
public int KnowledgeIdentified { get; init; }
public int KnowledgeCreatedOrLinked { get; init; }
public bool KnowledgeStageComplete { get; init; }
public int NarrativeCandidatesIdentified { get; init; }
public int NarrativeCandidatesApplied { get; init; }
public bool NarrativeStageComplete { get; init; }
}
public sealed class StoryIntelligenceNarrativeReviewViewModel
{
public bool CanImport { get; init; }
public bool HasCommittedScenes { get; init; }
public bool IsComplete { get; init; }
public IReadOnlyList<StoryIntelligenceNarrativeReviewCandidateViewModel> Candidates { get; init; } = [];
public IReadOnlyList<SelectListItem> PlotLineOptions { get; init; } = [];
}
public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
{
public int NarrativeCandidateID { get; init; }
public string CandidateType { get; init; } = string.Empty;
public string ProposedTitle { get; init; } = string.Empty;
public string ProposedDescription { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
public string InferredState { get; init; } = string.Empty;
public string Confidence { get; init; } = "Unknown";
public int? ExistingPlotLineID { get; init; }
public int? RelatedPlotLineID { get; init; }
public string? RelatedPlotLineName { get; init; }
public string DefaultAction { get; init; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
public IReadOnlyList<StoryIntelligenceNarrativeEvidenceViewModel> Evidence { get; init; } = [];
}
public sealed class StoryIntelligenceNarrativeEvidenceViewModel
{
public int SceneID { get; init; }
public string SceneLabel { get; init; } = string.Empty;
public string ContributionType { get; init; } = string.Empty;
public string Evidence { get; init; } = string.Empty;
}
public sealed class StoryIntelligenceNarrativeImportForm
{
public Guid BatchID { get; set; }
public List<StoryIntelligenceNarrativeImportChoiceForm> Candidates { get; set; } = [];
}
public sealed class StoryIntelligenceNarrativeImportChoiceForm
{
public int NarrativeCandidateID { get; set; }
public string CandidateType { get; set; } = string.Empty;
public string Action { get; set; } = StoryIntelligenceNarrativeReviewActions.AcceptNew;
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public int? ExistingPlotLineID { get; set; }
public int? RelatedPlotLineID { get; set; }
}
public sealed class StoryIntelligenceNarrativeImportResultViewModel
{
public Guid BatchID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int PlotLinesCreated { get; init; }
public int PlotLinesMerged { get; init; }
public int ThreadsCreated { get; init; }
public int CandidatesRejected { get; init; }
}
public sealed class StoryIntelligenceCharacterReviewViewModel

View File

@ -200,11 +200,13 @@
return pipeline.CurrentReviewStage switch
{
StoryIntelligencePipelineStages.NarrativeReview => "Review detected Plot Lines and Threads before adding them to the story database.",
StoryIntelligencePipelineStages.RelationshipReview => "Review detected relationships and decide what to create or link.",
StoryIntelligencePipelineStages.AssetReview => "Review detected assets and decide what to create or link.",
StoryIntelligencePipelineStages.LocationReview => "Review detected locations and decide what to create or link.",
StoryIntelligencePipelineStages.CharacterReview => "Scene creation is complete. Review detected characters to continue building the story database.",
StoryIntelligencePipelineStages.SceneReview => "Review prepared scenes before creating them in PlotDirector.",
_ when StoryIntelligenceReadyForNarrative(pipeline) => "Review detected Plot Lines and Threads before adding them to the story database.",
_ when StoryIntelligenceReadyForRelationships(pipeline) => "Review detected relationships and decide what to create or link.",
_ when StoryIntelligenceReadyForAssets(pipeline) => "Review detected assets and decide what to create or link.",
_ when StoryIntelligenceReadyForLocations(pipeline) => "Review detected locations and decide what to create or link.",
@ -212,6 +214,10 @@
};
}
private static bool StoryIntelligenceReadyForNarrative(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.KnowledgeImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.NarrativeReview or StoryIntelligencePipelineStages.NarrativeImport;
private static bool StoryIntelligenceReadyForRelationships(StoryIntelligenceBookPipelineState pipeline)
=> string.Equals(pipeline.LastCompletedStage, StoryIntelligencePipelineStages.AssetImport, StringComparison.OrdinalIgnoreCase)
|| pipeline.CurrentStage is StoryIntelligencePipelineStages.RelationshipReview or StoryIntelligencePipelineStages.RelationshipImport;

View File

@ -41,7 +41,7 @@
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceNarrative" asp-route-batchId="@Model.BatchID">Review Plot Lines & Threads</a>
</div>
</div>
</section>

View File

@ -0,0 +1,117 @@
@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Review Plot Lines & Threads";
}
<section class="onboarding-shell" aria-labelledby="story-narrative-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Plot Lines & Threads" })" />
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Phase 3</p>
<p class="story-review-heading__phase">Review Story Intelligence</p>
<p class="story-review-heading__stage">Stage 7 of 7</p>
<h1 id="story-narrative-title">Review Plot Lines & Threads</h1>
<p>Approve recurring narrative arcs and useful thread candidates before PlotDirector adds them to the canonical story database.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
@if (Model.NarrativeReview.Candidates.Count == 0)
{
<div class="story-review-note">
<strong>No Plot Line or Thread candidates are waiting.</strong>
<p class="mb-0">PlotDirector did not find recurring narrative candidates that require approval, or this review has already been completed.</p>
</div>
<form asp-action="ImportStoryIntelligenceNarrative" method="post" class="onboarding-actions">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<button class="btn btn-primary" type="submit">Complete narrative review</button>
</form>
}
else
{
<form asp-action="ImportStoryIntelligenceNarrative" method="post">
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<div class="story-character-card-grid">
@for (var i = 0; i < Model.NarrativeReview.Candidates.Count; i++)
{
var candidate = Model.NarrativeReview.Candidates[i];
<details class="story-character-card" open>
<summary>
<span>
<strong>@candidate.ProposedTitle</strong>
<small>@candidate.CandidateType @(!string.IsNullOrWhiteSpace(candidate.Category) ? $"- {candidate.Category}" : string.Empty)</small>
</span>
<em>@candidate.Confidence</em>
</summary>
<input type="hidden" name="Candidates[@i].NarrativeCandidateID" value="@candidate.NarrativeCandidateID" />
<input type="hidden" name="Candidates[@i].CandidateType" value="@candidate.CandidateType" />
<div class="story-character-card__body">
<div>
<label>Title</label>
<input class="form-control" name="Candidates[@i].Title" value="@candidate.ProposedTitle" maxlength="200" />
</div>
<div>
<label>Description</label>
<textarea class="form-control" name="Candidates[@i].Description" rows="3">@candidate.ProposedDescription</textarea>
</div>
@if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase))
{
<div>
<label>Related Plot Line</label>
<select class="form-select" name="Candidates[@i].RelatedPlotLineID">
<option value="">Choose Plot Line</option>
@foreach (var option in Model.NarrativeReview.PlotLineOptions)
{
<option value="@option.Value" selected="@(candidate.RelatedPlotLineID?.ToString() == option.Value)">@option.Text</option>
}
</select>
</div>
}
<fieldset>
<legend>Decision</legend>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.AcceptNew" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.AcceptNew)" /> Accept new</label>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.MergeExisting" checked="@(candidate.DefaultAction == StoryIntelligenceNarrativeReviewActions.MergeExisting)" /> Merge with existing</label>
<label><input type="radio" name="Candidates[@i].Action" value="@StoryIntelligenceNarrativeReviewActions.Reject" /> Reject</label>
</fieldset>
<div>
<label>Existing Plot Line for merge</label>
<select class="form-select" name="Candidates[@i].ExistingPlotLineID">
<option value="">Choose existing Plot Line</option>
@foreach (var option in Model.NarrativeReview.PlotLineOptions)
{
<option value="@option.Value" selected="@(candidate.ExistingPlotLineID?.ToString() == option.Value)">@option.Text</option>
}
</select>
</div>
<div class="story-review-note">
<strong>Supporting scenes</strong>
<ul>
@foreach (var evidence in candidate.Evidence)
{
<li><strong>@evidence.SceneLabel</strong>: @(string.IsNullOrWhiteSpace(evidence.Evidence) ? evidence.ContributionType : evidence.Evidence)</li>
}
</ul>
</div>
</div>
</details>
}
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceKnowledge" asp-route-batchId="@Model.BatchID">Back to knowledge</a>
<button class="btn btn-primary" type="submit">Apply Plot Lines & Threads</button>
</div>
</form>
}
</div>
</section>

View File

@ -0,0 +1,30 @@
@model StoryIntelligenceNarrativeImportResultViewModel
@{
ViewData["Title"] = "Narrative review complete";
}
<section class="onboarding-shell" aria-labelledby="story-narrative-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Complete" })" />
<div class="onboarding-copy">
<p class="eyebrow">Build Story Database</p>
<div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span>
<h1 id="story-narrative-complete-title">Plot Lines & Threads reviewed</h1>
</div>
<p>PlotDirector applied the approved narrative candidates and left rejected candidates out of the canonical story database.</p>
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div><span>Plot Lines created</span><strong>@Model.PlotLinesCreated.ToString("N0")</strong></div>
<div><span>Plot Lines merged</span><strong>@Model.PlotLinesMerged.ToString("N0")</strong></div>
<div><span>Threads created</span><strong>@Model.ThreadsCreated.ToString("N0")</strong></div>
<div><span>Rejected</span><strong>@Model.CandidatesRejected.ToString("N0")</strong></div>
</div>
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceNarrative" asp-route-batchId="@Model.BatchID">Back to review</a>
<a class="btn btn-primary" asp-action="StoryIntelligenceComplete" asp-route-batchId="@Model.BatchID">Continue</a>
</div>
</div>
</section>

View File

@ -49,4 +49,12 @@
<span>Knowledge created / linked</span>
<strong>@(Model.KnowledgeStageComplete ? $"Done: {Model.KnowledgeCreatedOrLinked:N0}" : "In review")</strong>
</div>
<div>
<span>Plot Lines / Threads found</span>
<strong>@Model.NarrativeCandidatesIdentified.ToString("N0")</strong>
</div>
<div>
<span>Plot Lines / Threads applied</span>
<strong>@(Model.NarrativeStageComplete ? $"Done: {Model.NarrativeCandidatesApplied:N0}" : "In review")</strong>
</div>
</section>