Preserve author plot seed provenance
This commit is contained in:
parent
80d13c6a7f
commit
99d818ed9f
@ -204,6 +204,12 @@ var tests = new (string Name, Action Test)[]
|
||||
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
|
||||
("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext),
|
||||
("Whole-book Plot Intelligence schema requires timeline event types", WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes),
|
||||
("Whole-book Plot Intelligence author seed inputs persist safely", WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely),
|
||||
("Whole-book Plot Intelligence structured author seed match survives", WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives),
|
||||
("Whole-book Plot Intelligence unmatched seeds become review candidates", WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates),
|
||||
("Story Intelligence narrative review displays author seed provenance", StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance),
|
||||
("Story Intelligence narrative review avoids raw author-facing IDs", StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds),
|
||||
("Story Intelligence author seed decisions are preserved", StoryIntelligenceAuthorSeedDecisionsArePreserved),
|
||||
("Whole-book Plot Intelligence production code remains fixture agnostic", WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic),
|
||||
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
|
||||
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge),
|
||||
@ -5386,6 +5392,141 @@ static void WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes()
|
||||
Assert(WholeBookPlotSynthesisStructuredOutputSchema.Contract.Strict, "Whole-book schema should use strict structured output.");
|
||||
}
|
||||
|
||||
static void WholeBookPlotIntelligenceAuthorSeedInputsPersistSafely()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs"));
|
||||
var contextView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAuthorPlotContext.cshtml"));
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs"));
|
||||
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
|
||||
|
||||
Assert(contextView.Contains("Descriptions.Count >= 3", StringComparison.Ordinal) && contextView.Contains("Enumerable.Repeat(string.Empty, 3 - Model.Descriptions.Count)", StringComparison.Ordinal), "Author context page should render the three default seed inputs.");
|
||||
Assert(contextView.Contains("data-add-plot-context", StringComparison.Ordinal) && contextView.Contains("PlotSeeds[${index}]", StringComparison.Ordinal), "Author context page should allow arbitrary additional seed inputs.");
|
||||
Assert(repository.Contains(".Where(description => !string.IsNullOrWhiteSpace(description))", StringComparison.Ordinal), "Repository should ignore empty seed text before persistence.");
|
||||
Assert(repository.Contains(".Select(description => new { description = description.Trim() })", StringComparison.Ordinal), "Repository should persist trimmed non-empty seed text.");
|
||||
Assert(sql.Contains("NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.description'))), N'') IS NOT NULL", StringComparison.Ordinal), "Database save procedure should also ignore empty seed text.");
|
||||
|
||||
var saveIndex = controller.IndexOf("await wholeBookPlotIntelligence.SaveAuthorPlotContextAsync", StringComparison.Ordinal);
|
||||
var startBookIndex = controller.IndexOf("await storyIntelligence.StartBookAsync(form.BookID)", StringComparison.Ordinal);
|
||||
Assert(saveIndex >= 0 && startBookIndex > saveIndex, "Author seed persistence must complete before Analyse My Book queues analysis.");
|
||||
}
|
||||
|
||||
static void WholeBookPlotIntelligenceStructuredAuthorSeedMatchSurvives()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"metadata": { "bookStatus": "Draft", "summary": "A search is under way." },
|
||||
"plotLines": [
|
||||
{
|
||||
"key": "PL-A",
|
||||
"proposedTitle": "Hidden partnership",
|
||||
"description": "A visible rivalry conceals cooperation.",
|
||||
"significance": "Central author intent.",
|
||||
"bookStatus": "Active",
|
||||
"existingPlotLineID": 7001,
|
||||
"existingPlotLineMatchConfidence": 0.82,
|
||||
"existingPlotLineMatchReason": "Same long-running project line.",
|
||||
"authorPlotSeedID": 9001,
|
||||
"seedMatchConfidence": 0.91,
|
||||
"seedMatchReason": "Matches the supplied author seed.",
|
||||
"confidence": 0.88,
|
||||
"threads": []
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var parsed = WholeBookPlotSynthesisResult.FromJson(json);
|
||||
var result = parsed.PlotLines.Single();
|
||||
|
||||
Assert(result.AuthorPlotSeedID == 9001, "Structured AuthorPlotSeedID should survive response parsing.");
|
||||
Assert(result.SeedMatchConfidence == 0.91m, "Seed match confidence should survive response parsing.");
|
||||
Assert(result.SeedMatchReason == "Matches the supplied author seed.", "Seed match reason should survive response parsing.");
|
||||
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/WholeBookPlotIntelligenceRepository.cs"));
|
||||
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
|
||||
Assert(repository.Contains("plotLine.AuthorPlotSeedID", StringComparison.Ordinal), "Candidate materialisation should pass AuthorPlotSeedID to persistence.");
|
||||
Assert(repository.Contains("plotLine.SeedMatchConfidence", StringComparison.Ordinal), "Candidate materialisation should pass seed confidence to persistence.");
|
||||
Assert(repository.Contains("plotLine.SeedMatchReason", StringComparison.Ordinal), "Candidate materialisation should pass seed reason to persistence.");
|
||||
Assert(sql.Contains("AuthorPlotSeedID = @AuthorPlotSeedID", StringComparison.Ordinal), "Candidate save procedure should retain AuthorPlotSeedID updates.");
|
||||
}
|
||||
|
||||
static void WholeBookPlotIntelligenceUnmatchedSeedsBecomeReviewCandidates()
|
||||
{
|
||||
var result = new WholeBookPlotSynthesisResult
|
||||
{
|
||||
Metadata = new WholeBookPlotSynthesisMetadata { BookStatus = "Draft", Summary = "One detected line." },
|
||||
PlotLines =
|
||||
[
|
||||
new WholeBookPlotSynthesisPlotLineResult
|
||||
{
|
||||
Key = "detected",
|
||||
ProposedTitle = "Detected line",
|
||||
Description = "A detected line.",
|
||||
Significance = "Detected",
|
||||
BookStatus = "Active",
|
||||
AuthorPlotSeedID = 1,
|
||||
Threads = []
|
||||
}
|
||||
]
|
||||
};
|
||||
var seeds = new[]
|
||||
{
|
||||
new AuthorPlotSeed { AuthorPlotSeedID = 1, Description = "A matched seed." },
|
||||
new AuthorPlotSeed { AuthorPlotSeedID = 2, Description = "A dormant author seed with no current-book evidence.", MatchedPlotLineID = 42 }
|
||||
};
|
||||
|
||||
var preserved = WholeBookPlotIntelligenceService.PreserveUnmatchedAuthorSeedsForReviewForTest(result, seeds);
|
||||
var unmatched = preserved.PlotLines.Single(plotLine => plotLine.AuthorPlotSeedID == 2);
|
||||
|
||||
Assert(unmatched.Description == "A dormant author seed with no current-book evidence.", "Unmatched author seed candidate should show the exact seed description.");
|
||||
Assert(unmatched.BookStatus == "Limited current-book evidence", "Unmatched author seed candidate should explicitly represent limited evidence.");
|
||||
Assert(unmatched.Threads.Count == 0, "Unmatched author seed candidate must not fabricate thread or scene evidence.");
|
||||
Assert(unmatched.ExistingPlotLineID == 42, "Unmatched author seed candidate should retain an existing Plot Line match when one is already linked.");
|
||||
Assert(unmatched.ExistingPlotLineMatchConfidence == 1m, "Existing author-linked Plot Line should default to merge rather than duplicate creation.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceNarrativeReviewDisplaysAuthorSeedProvenance()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var models = File.ReadAllText(Path.Combine(root, "Models/StoryIntelligenceNarrativeModels.cs"));
|
||||
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
|
||||
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
|
||||
|
||||
Assert(models.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Narrative candidate model should carry author seed text.");
|
||||
Assert(viewModels.Contains("AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel should expose author seed text.");
|
||||
Assert(service.Contains("AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review ViewModel mapping should retain author seed text.");
|
||||
Assert(sql.Contains("aps.Description AS AuthorPlotSeedDescription", StringComparison.Ordinal), "Candidate list proc should join author seed descriptions.");
|
||||
Assert(view.Contains("<strong>Author provided</strong>", StringComparison.Ordinal), "Review UI should render quiet author provenance.");
|
||||
Assert(view.Contains("@candidate.AuthorPlotSeedDescription", StringComparison.Ordinal), "Review UI should render exact author seed text.");
|
||||
Assert(view.Contains("@candidate.SeedMatchReason", StringComparison.Ordinal), "Review UI should show the match or limited-evidence reason.");
|
||||
Assert(view.Contains("Discovered by PlotDirector", StringComparison.Ordinal), "Review UI should distinguish discovered candidates.");
|
||||
Assert(view.Contains("Existing Plot Line: @candidate.ExistingPlotLineName", StringComparison.Ordinal), "Review UI should display existing Plot Line names, not database IDs.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceNarrativeReviewAvoidsRawAuthorFacingIds()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceNarrative.cshtml"));
|
||||
|
||||
Assert(!view.Contains("Scene @item.SceneID", StringComparison.Ordinal), "Review UI should not expose raw SceneIDs as visible text.");
|
||||
Assert(!view.Contains(">PlotLineID", StringComparison.Ordinal) && !view.Contains("PlotLineID:", StringComparison.Ordinal), "Review UI should not expose raw PlotLineID labels.");
|
||||
Assert(!view.Contains(">AuthorPlotSeedID", StringComparison.Ordinal) && !view.Contains("AuthorPlotSeedID:", StringComparison.Ordinal), "Review UI should not expose raw AuthorPlotSeedID labels.");
|
||||
Assert(view.Contains("@item.SceneLabel", StringComparison.Ordinal), "Review UI should display readable chapter/scene labels.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceAuthorSeedDecisionsArePreserved()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var sql = File.ReadAllText(Path.Combine(root, "Sql/174_Phase27_WholeBookPlotIntelligence.sql"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceNarrativeReviewService.cs"));
|
||||
|
||||
Assert(sql.Contains("AND Status = N'Pending'", StringComparison.Ordinal), "Candidate save/update should not overwrite already reviewed author decisions.");
|
||||
Assert(service.Contains("string.Equals(candidate.Status, StoryIntelligenceNarrativeCandidateStatuses.Pending", StringComparison.Ordinal), "Narrative import should only apply pending candidates.");
|
||||
}
|
||||
|
||||
static void WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
|
||||
@ -41,6 +41,8 @@ public sealed class StoryIntelligenceNarrativeCandidate
|
||||
public string? ExistingPlotThreadTitle { get; set; }
|
||||
public int? WholeBookPlotSynthesisRunID { get; set; }
|
||||
public int? AuthorPlotSeedID { get; set; }
|
||||
public string? AuthorPlotSeedDescription { get; set; }
|
||||
public string? AuthorPlotSeedSource { get; set; }
|
||||
public decimal? ExistingPlotLineMatchConfidence { get; set; }
|
||||
public string? ExistingPlotLineMatchReason { get; set; }
|
||||
public decimal? SeedMatchConfidence { get; set; }
|
||||
|
||||
@ -600,6 +600,13 @@ public sealed class StoryIntelligenceNarrativeReviewService(
|
||||
Significance = candidate.Significance ?? string.Empty,
|
||||
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
|
||||
ExistingPlotLineID = candidate.ExistingPlotLineID,
|
||||
ExistingPlotLineName = candidate.ExistingPlotLineName,
|
||||
AuthorPlotSeedID = candidate.AuthorPlotSeedID,
|
||||
AuthorPlotSeedDescription = candidate.AuthorPlotSeedDescription ?? string.Empty,
|
||||
AuthorPlotSeedSource = candidate.AuthorPlotSeedSource ?? string.Empty,
|
||||
Source = candidate.Source ?? string.Empty,
|
||||
ExistingPlotLineMatchReason = candidate.ExistingPlotLineMatchReason,
|
||||
SeedMatchReason = candidate.SeedMatchReason,
|
||||
RelatedPlotLineID = candidate.RelatedPlotLineID,
|
||||
RelatedPlotLineName = candidate.RelatedPlotLineName,
|
||||
ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID,
|
||||
|
||||
@ -138,7 +138,9 @@ public sealed class WholeBookPlotIntelligenceService(
|
||||
settings.EffectiveWholeBookPlotSynthesisModel,
|
||||
responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract);
|
||||
var outputJson = ExtractOutputText(result.RawResponseText);
|
||||
var parsed = WholeBookPlotSynthesisResult.FromJson(outputJson);
|
||||
var parsed = PreserveUnmatchedAuthorSeedsForReview(
|
||||
WholeBookPlotSynthesisResult.FromJson(outputJson),
|
||||
context.AuthorSeeds);
|
||||
stopwatch.Stop();
|
||||
|
||||
await repository.CompleteAsync(new WholeBookPlotSynthesisCompletionRequest
|
||||
@ -181,6 +183,57 @@ public sealed class WholeBookPlotIntelligenceService(
|
||||
public StoryIntelligenceResponseContract BuildResponseContractForTest()
|
||||
=> WholeBookPlotSynthesisStructuredOutputSchema.Contract;
|
||||
|
||||
public static WholeBookPlotSynthesisResult PreserveUnmatchedAuthorSeedsForReviewForTest(
|
||||
WholeBookPlotSynthesisResult result,
|
||||
IReadOnlyList<AuthorPlotSeed> authorSeeds)
|
||||
=> PreserveUnmatchedAuthorSeedsForReview(result, authorSeeds);
|
||||
|
||||
private static WholeBookPlotSynthesisResult PreserveUnmatchedAuthorSeedsForReview(
|
||||
WholeBookPlotSynthesisResult result,
|
||||
IReadOnlyList<AuthorPlotSeed> authorSeeds)
|
||||
{
|
||||
var matchedSeedIds = result.PlotLines
|
||||
.Where(plotLine => plotLine.AuthorPlotSeedID.HasValue)
|
||||
.Select(plotLine => plotLine.AuthorPlotSeedID!.Value)
|
||||
.ToHashSet();
|
||||
var unmatchedSeeds = authorSeeds
|
||||
.Where(seed => !matchedSeedIds.Contains(seed.AuthorPlotSeedID))
|
||||
.ToList();
|
||||
if (unmatchedSeeds.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var plotLines = result.PlotLines.ToList();
|
||||
foreach (var seed in unmatchedSeeds)
|
||||
{
|
||||
plotLines.Add(new WholeBookPlotSynthesisPlotLineResult
|
||||
{
|
||||
Key = $"author-seed-{seed.AuthorPlotSeedID}",
|
||||
ProposedTitle = "Author-provided Plot Line",
|
||||
Description = seed.Description,
|
||||
Significance = "Author intent",
|
||||
BookStatus = "Limited current-book evidence",
|
||||
ExistingPlotLineID = seed.MatchedPlotLineID,
|
||||
ExistingPlotLineMatchConfidence = seed.MatchedPlotLineID.HasValue ? 1m : null,
|
||||
ExistingPlotLineMatchReason = seed.MatchedPlotLineID.HasValue
|
||||
? "Author seed is already linked to an existing Project Plot Line."
|
||||
: null,
|
||||
AuthorPlotSeedID = seed.AuthorPlotSeedID,
|
||||
SeedMatchConfidence = null,
|
||||
SeedMatchReason = "Author-provided seed retained for review; no detected whole-book Plot Line referenced this seed.",
|
||||
Confidence = null,
|
||||
Threads = []
|
||||
});
|
||||
}
|
||||
|
||||
return new WholeBookPlotSynthesisResult
|
||||
{
|
||||
Metadata = result.Metadata,
|
||||
PlotLines = plotLines
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPrompt(WholeBookPlotSynthesisContext context, bool includeOptionalContext = true)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
@ -696,7 +696,9 @@ BEGIN
|
||||
nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, nc.ChangeType, nc.Significance,
|
||||
nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName,
|
||||
nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle,
|
||||
nc.WholeBookPlotSynthesisRunID, nc.AuthorPlotSeedID, nc.ExistingPlotLineMatchConfidence,
|
||||
nc.WholeBookPlotSynthesisRunID, nc.AuthorPlotSeedID,
|
||||
aps.Description AS AuthorPlotSeedDescription, aps.Source AS AuthorPlotSeedSource,
|
||||
nc.ExistingPlotLineMatchConfidence,
|
||||
nc.ExistingPlotLineMatchReason, nc.SeedMatchConfidence, nc.SeedMatchReason, nc.Source,
|
||||
nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName,
|
||||
nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle,
|
||||
@ -706,6 +708,7 @@ BEGIN
|
||||
nc.CreatedUtc, nc.UpdatedUtc
|
||||
FROM dbo.StoryIntelligenceNarrativeCandidates nc
|
||||
LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID
|
||||
LEFT JOIN dbo.AuthorPlotSeeds aps ON aps.AuthorPlotSeedID = nc.AuthorPlotSeedID
|
||||
LEFT JOIN dbo.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID
|
||||
LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID
|
||||
LEFT JOIN dbo.StoryIntelligenceNarrativeCandidates parent ON parent.NarrativeCandidateID = nc.ParentNarrativeCandidateID
|
||||
|
||||
@ -279,7 +279,10 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
|
||||
public string Significance { get; init; } = string.Empty;
|
||||
public string Confidence { get; init; } = "Unknown";
|
||||
public int? ExistingPlotLineID { get; init; }
|
||||
public string? ExistingPlotLineName { get; init; }
|
||||
public int? AuthorPlotSeedID { get; init; }
|
||||
public string AuthorPlotSeedDescription { get; init; } = string.Empty;
|
||||
public string AuthorPlotSeedSource { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? ExistingPlotLineMatchReason { get; init; }
|
||||
public string? SeedMatchReason { get; init; }
|
||||
|
||||
@ -76,6 +76,25 @@
|
||||
<label>Description</label>
|
||||
<textarea class="form-control" name="Candidates[@i].Description" rows="3">@candidate.ProposedDescription</textarea>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.AuthorPlotSeedDescription))
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>Author provided</strong>
|
||||
<p>@candidate.AuthorPlotSeedDescription</p>
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.SeedMatchReason))
|
||||
{
|
||||
<p class="text-muted mb-0">@candidate.SeedMatchReason</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (string.Equals(candidate.Source, WholeBookPlotSynthesisSources.WholeBook, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<p class="text-muted">Discovered by PlotDirector</p>
|
||||
}
|
||||
@if (candidate.ExistingPlotLineID.HasValue && !string.IsNullOrWhiteSpace(candidate.ExistingPlotLineName))
|
||||
{
|
||||
<p class="text-muted">Existing Plot Line: @candidate.ExistingPlotLineName</p>
|
||||
}
|
||||
@if (string.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user