Preserve author plot seed provenance

This commit is contained in:
Nick Beckley 2026-08-29 20:25:40 +00:00
parent 80d13c6a7f
commit 99d818ed9f
7 changed files with 230 additions and 2 deletions

View File

@ -204,6 +204,12 @@ var tests = new (string Name, Action Test)[]
("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic), ("Narrative production code remains fixture agnostic", NarrativeProductionCodeRemainsFixtureAgnostic),
("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext), ("Whole-book Plot Intelligence prompt carries full manuscript context", WholeBookPlotIntelligencePromptCarriesFullManuscriptContext),
("Whole-book Plot Intelligence schema requires timeline event types", WholeBookPlotIntelligenceSchemaRequiresTimelineEventTypes), ("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), ("Whole-book Plot Intelligence production code remains fixture agnostic", WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic),
("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs), ("Story Intelligence narrative review creates canonical records via existing procs", StoryIntelligenceNarrativeReviewCreatesCanonicalRecordsViaExistingProcs),
("Story Intelligence narrative stage follows knowledge", StoryIntelligenceNarrativeStageFollowsKnowledge), ("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."); 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() static void WholeBookPlotIntelligenceProductionCodeRemainsFixtureAgnostic()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -41,6 +41,8 @@ public sealed class StoryIntelligenceNarrativeCandidate
public string? ExistingPlotThreadTitle { get; set; } public string? ExistingPlotThreadTitle { get; set; }
public int? WholeBookPlotSynthesisRunID { get; set; } public int? WholeBookPlotSynthesisRunID { get; set; }
public int? AuthorPlotSeedID { get; set; } public int? AuthorPlotSeedID { get; set; }
public string? AuthorPlotSeedDescription { get; set; }
public string? AuthorPlotSeedSource { get; set; }
public decimal? ExistingPlotLineMatchConfidence { get; set; } public decimal? ExistingPlotLineMatchConfidence { get; set; }
public string? ExistingPlotLineMatchReason { get; set; } public string? ExistingPlotLineMatchReason { get; set; }
public decimal? SeedMatchConfidence { get; set; } public decimal? SeedMatchConfidence { get; set; }

View File

@ -600,6 +600,13 @@ public sealed class StoryIntelligenceNarrativeReviewService(
Significance = candidate.Significance ?? string.Empty, Significance = candidate.Significance ?? string.Empty,
Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown", Confidence = candidate.Confidence.HasValue ? $"{candidate.Confidence.Value:P0}" : "Unknown",
ExistingPlotLineID = candidate.ExistingPlotLineID, 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, RelatedPlotLineID = candidate.RelatedPlotLineID,
RelatedPlotLineName = candidate.RelatedPlotLineName, RelatedPlotLineName = candidate.RelatedPlotLineName,
ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID, ParentNarrativeCandidateID = candidate.ParentNarrativeCandidateID,

View File

@ -138,7 +138,9 @@ public sealed class WholeBookPlotIntelligenceService(
settings.EffectiveWholeBookPlotSynthesisModel, settings.EffectiveWholeBookPlotSynthesisModel,
responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract); responseContract: WholeBookPlotSynthesisStructuredOutputSchema.Contract);
var outputJson = ExtractOutputText(result.RawResponseText); var outputJson = ExtractOutputText(result.RawResponseText);
var parsed = WholeBookPlotSynthesisResult.FromJson(outputJson); var parsed = PreserveUnmatchedAuthorSeedsForReview(
WholeBookPlotSynthesisResult.FromJson(outputJson),
context.AuthorSeeds);
stopwatch.Stop(); stopwatch.Stop();
await repository.CompleteAsync(new WholeBookPlotSynthesisCompletionRequest await repository.CompleteAsync(new WholeBookPlotSynthesisCompletionRequest
@ -181,6 +183,57 @@ public sealed class WholeBookPlotIntelligenceService(
public StoryIntelligenceResponseContract BuildResponseContractForTest() public StoryIntelligenceResponseContract BuildResponseContractForTest()
=> WholeBookPlotSynthesisStructuredOutputSchema.Contract; => 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) private static string BuildPrompt(WholeBookPlotSynthesisContext context, bool includeOptionalContext = true)
{ {
var builder = new StringBuilder(); var builder = new StringBuilder();

View File

@ -696,7 +696,9 @@ BEGIN
nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, nc.ChangeType, nc.Significance, nc.ProposedTitle, nc.ProposedDescription, nc.Category, nc.InferredState, nc.ChangeType, nc.Significance,
nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName, nc.ExistingPlotLineID, epl.PlotLineName AS ExistingPlotLineName,
nc.ExistingPlotThreadID, ept.ThreadTitle AS ExistingPlotThreadTitle, 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.ExistingPlotLineMatchReason, nc.SeedMatchConfidence, nc.SeedMatchReason, nc.Source,
nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName, nc.RelatedPlotLineID, rpl.PlotLineName AS RelatedPlotLineName,
nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle, nc.ParentCandidateKey, nc.ParentNarrativeCandidateID, parent.ProposedTitle AS ParentCandidateTitle,
@ -706,6 +708,7 @@ BEGIN
nc.CreatedUtc, nc.UpdatedUtc nc.CreatedUtc, nc.UpdatedUtc
FROM dbo.StoryIntelligenceNarrativeCandidates nc FROM dbo.StoryIntelligenceNarrativeCandidates nc
LEFT JOIN dbo.PlotLines epl ON epl.PlotLineID = nc.ExistingPlotLineID 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.PlotThreads ept ON ept.PlotThreadID = nc.ExistingPlotThreadID
LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID LEFT JOIN dbo.PlotLines rpl ON rpl.PlotLineID = nc.RelatedPlotLineID
LEFT JOIN dbo.StoryIntelligenceNarrativeCandidates parent ON parent.NarrativeCandidateID = nc.ParentNarrativeCandidateID LEFT JOIN dbo.StoryIntelligenceNarrativeCandidates parent ON parent.NarrativeCandidateID = nc.ParentNarrativeCandidateID

View File

@ -279,7 +279,10 @@ public sealed class StoryIntelligenceNarrativeReviewCandidateViewModel
public string Significance { get; init; } = string.Empty; public string Significance { get; init; } = string.Empty;
public string Confidence { get; init; } = "Unknown"; public string Confidence { get; init; } = "Unknown";
public int? ExistingPlotLineID { get; init; } public int? ExistingPlotLineID { get; init; }
public string? ExistingPlotLineName { get; init; }
public int? AuthorPlotSeedID { 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 Source { get; init; } = string.Empty;
public string? ExistingPlotLineMatchReason { get; init; } public string? ExistingPlotLineMatchReason { get; init; }
public string? SeedMatchReason { get; init; } public string? SeedMatchReason { get; init; }

View File

@ -76,6 +76,25 @@
<label>Description</label> <label>Description</label>
<textarea class="form-control" name="Candidates[@i].Description" rows="3">@candidate.ProposedDescription</textarea> <textarea class="form-control" name="Candidates[@i].Description" rows="3">@candidate.ProposedDescription</textarea>
</div> </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.Equals(candidate.CandidateType, StoryIntelligenceNarrativeCandidateTypes.Thread, StringComparison.OrdinalIgnoreCase))
{ {
@if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle)) @if (!string.IsNullOrWhiteSpace(candidate.ParentCandidateTitle))