Add Story Intelligence narrative review stage
This commit is contained in:
parent
398579c5c9
commit
a6145a25e8
@ -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");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
84
PlotLine/Models/StoryIntelligenceNarrativeModels.cs
Normal file
84
PlotLine/Models/StoryIntelligenceNarrativeModels.cs
Normal 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; }
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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);
|
||||
|
||||
403
PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs
Normal file
403
PlotLine/Services/StoryIntelligenceNarrativeReviewService.cs
Normal 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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
209
PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql
Normal file
209
PlotLine/Sql/155_Phase25_StoryIntelligenceNarrativeReview.sql
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
117
PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml
Normal file
117
PlotLine/Views/Onboarding/StoryIntelligenceNarrative.cshtml
Normal 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>
|
||||
@ -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">✓</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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user