Rank location review by narrative significance and surface genuine uncertainty
This commit is contained in:
parent
aa74df89e8
commit
54eced0ad1
108
PlotLine.Tests/LocationSignificanceTests.cs
Normal file
108
PlotLine.Tests/LocationSignificanceTests.cs
Normal file
@ -0,0 +1,108 @@
|
||||
using PlotLine.Models;
|
||||
using PlotLine.Data;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
internal static class LocationSignificanceTests
|
||||
{
|
||||
private static void Check(bool condition, string message) { if (!condition) throw new Exception(message); }
|
||||
private static LocationSignificanceEvidence Evidence(int scene, int chapter, bool setting = false, int eventScore = 0, int context = 0)
|
||||
=> new(scene, chapter, 1, $"Chapter {chapter}, Scene {scene}", setting, eventScore, context, [], [], []);
|
||||
private static StoryIntelligenceLocationReviewCandidateViewModel Candidate(string key, int score, string? parent = null, string confidence = "High", string status = "Pending")
|
||||
=> new() { Key = key, LocationName = key, Category = "Named location", ProposedParentCandidateKey = parent, Confidence = confidence, DecisionStatus = status,
|
||||
Significance = new() { Score = score, Classification = score >= 40 ? "Significant Location" : "Incidental Location" } };
|
||||
|
||||
public static void ConsequenceOutranksFrequency()
|
||||
{
|
||||
var hallway = LocationSignificanceService.Assess("Room / interior area", Enumerable.Range(1, 100).Select(i => Evidence(i, i, true, context: 18)));
|
||||
var observatory = LocationSignificanceService.Assess("Named location", [Evidence(200, 1, true, 50, 18)]);
|
||||
Check(observatory.Score > hallway.Score && observatory.IsSignificant && !hallway.IsSignificant, "One consequential event must outrank 100 routine setting scenes.");
|
||||
Check(hallway.Score == 26 && hallway.Classification == "Recurring Location", "Frequency must be capped at eight and cannot alone create a primary setting.");
|
||||
var duplicates = LocationSignificanceService.Assess("Named location", Enumerable.Repeat(Evidence(200, 1, true, 50, 18), 100));
|
||||
Check(duplicates.Score == observatory.Score, "Repeated observations in the same scene must not inflate score.");
|
||||
Check(LocationSignificanceService.Assess("Named location", [Evidence(1, 1)]).Classification == "Incidental Location", "A distinctive name alone is incidental.");
|
||||
Check(LocationSignificanceService.Assess("Named location", Enumerable.Range(1, 6).Select(i => Evidence(i, i, true, 50, 18))).Classification == "Primary Setting", "Consequential multi-chapter settings should be primary.");
|
||||
}
|
||||
|
||||
public static void SceneContextBelongsToSetting()
|
||||
{
|
||||
var parsed = new SceneIntelligenceScene { ScenePurpose = new() { ObservedFunction = "A betrayal and rescue transform the investigation." },
|
||||
PointOfView = new() { CharacterName = "Leena" },
|
||||
Metrics = new() { ["tension"] = new() { Score = 9 } },
|
||||
KnowledgeChanges = [new() { KnowledgeItem = "The captain is the informant" }],
|
||||
NarrativeArcs = [new() { Title = "The missing expedition", Significance = "high" }] };
|
||||
var scene = new Scene { SceneID = 7, ChapterID = 3, BookID = 1 };
|
||||
var site = LocationSignificanceService.Observe(scene, parsed, "Chapter 3, Scene 7", true, true, false, null);
|
||||
var mention = LocationSignificanceService.Observe(scene, parsed, "Chapter 3, Scene 7", false, false, true, "They mention the pantry.");
|
||||
Check(site.EventScore == 50 && site.ContextScore > 18 && site.Characters.Contains("Leena") && site.PlotLines.Contains("The missing expedition"), "Reuse structured scene purpose, metrics, knowledge, plot and POV evidence.");
|
||||
Check(mention.EventScore == 0 && mention.ContextScore == 0 && mention.Characters.Count == 0, "An incidental mention cannot inherit the scene's consequential context.");
|
||||
var hypothetical = LocationSignificanceService.Observe(scene, parsed, "scene", false, false, true, "They might discover a body at the beach.");
|
||||
Check(hypothetical.EventScore == 0, "Hypothetical consequential vocabulary must not create an actual event site.");
|
||||
var routine = new SceneIntelligenceScene { ScenePurpose = new() { ObservedFunction = "The conversation reveals the employee staffing schedule." },
|
||||
NarrativeArcs = [new() { Title = "Work routines", Significance = "minor" }],
|
||||
KnowledgeChanges = [new() { KnowledgeItem = "The secretary works on Tuesdays." }] };
|
||||
var routineScore = LocationSignificanceService.Assess("Named location", [LocationSignificanceService.Observe(scene, routine, "scene", true, true, false, null)]);
|
||||
Check(!routineScore.IsSignificant, "Ordinary exposition and trivial knowledge must not be mistaken for major revelations.");
|
||||
var explicitSite = LocationSignificanceService.Observe(scene, parsed, "scene", false, false, true, "The abduction occurred at the lighthouse.");
|
||||
Check(explicitSite.EventScore == 50, "Location-specific evidence can support a consequential site even when off-stage.");
|
||||
}
|
||||
|
||||
public static void PersistedAnalysisKeepsContextAndParentEvidence()
|
||||
{
|
||||
Scene[] scenes = [
|
||||
new() { SceneID = 1, ChapterID = 1, SceneNumber = 1, POVCharacterName = "Maggie", StructuralSetting = "Maggie's bedroom" },
|
||||
new() { SceneID = 2, ChapterID = 1, SceneNumber = 2, POVCharacterName = "Beth", StructuralSetting = "Beth's bedroom" },
|
||||
new() { SceneID = 3, ChapterID = 1, SceneNumber = 3 }
|
||||
];
|
||||
var ordinary = new SceneIntelligenceScene { SchemaVersion = "1", Setting = new() { LocationName = "bedroom", GenericRoomType = "bedroom" } };
|
||||
var major = new SceneIntelligenceScene { SchemaVersion = "1", Setting = new() { LocationName = "library", GenericRoomType = "library", ParentLocationHint = "Westmere House" },
|
||||
ScenePurpose = new() { ObservedFunction = "A rescue follows the abduction." } };
|
||||
IReadOnlyList<StoryIntelligenceSavedSceneResult> results = scenes.Select(x => new StoryIntelligenceSavedSceneResult { SceneID = x.SceneID,
|
||||
ParsedJson = JsonSerializer.Serialize(x.SceneID == 3 ? major : ordinary, new JsonSerializerOptions(JsonSerializerDefaults.Web)) }).ToList();
|
||||
var runs = Proxy<IStoryIntelligenceResultRepository>((method, _) => method switch {
|
||||
"GetRunAsync" => Task.FromResult<StoryIntelligenceSavedRun?>(new() { Status = "Completed" }),
|
||||
"ListSceneResultsAsync" => Task.FromResult(results),
|
||||
_ => throw new Exception($"Unexpected run operation: {method}") });
|
||||
var locations = Proxy<ILocationRepository>((method, _) => method == "ListByProjectAsync"
|
||||
? Task.FromResult<IReadOnlyList<LocationItem>>([]) : throw new Exception($"Unexpected location write/read: {method}"));
|
||||
var sceneRepository = Proxy<ISceneRepository>((method, _) => method == "ListByChapterAsync"
|
||||
? Task.FromResult<IReadOnlyList<Scene>>(scenes) : throw new Exception($"Unexpected scene write/read: {method}"));
|
||||
var importer = new StoryIntelligenceLocationImportService(runs, locations, sceneRepository, null!, NullLogger<StoryIntelligenceLocationImportService>.Instance);
|
||||
var result = importer.BuildSignificanceAsync(new() { ProjectID = 1, BookID = 1, Items = [new() { RunID = 1, ChapterID = 1 }] }).GetAwaiter().GetResult();
|
||||
Check(result.Keys.Any(x => x.Contains("maggie", StringComparison.OrdinalIgnoreCase)) && result.Keys.Any(x => x.Contains("beth", StringComparison.OrdinalIgnoreCase)), "Assessment preparation must preserve separate contextual bedrooms.");
|
||||
Check(result.Any(x => x.Key.Contains("westmere", StringComparison.OrdinalIgnoreCase) && x.Value.IsSignificant), "A house must retain the significance of consequential scenes within it.");
|
||||
Check(result.Values.SelectMany(x => x.KeyScenes).All(x => x.BookID == 1), "Stored assessment evidence must retain canonical book/scene provenance.");
|
||||
}
|
||||
|
||||
private static T Proxy<T>(Func<string, object?[]?, object?> handler) where T : class
|
||||
{
|
||||
var proxy = DispatchProxy.Create<T, StoryPointProxy>();
|
||||
((StoryPointProxy)(object)proxy).Handler = handler;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
public static void HierarchyAndUncertainty()
|
||||
{
|
||||
var parent = Candidate("Estate", 8);
|
||||
var high = Candidate("Tower", 90, "Estate");
|
||||
var low = Candidate("Kitchen", 3, "Estate");
|
||||
var medium = Candidate("Library", 60, "Estate");
|
||||
var unrelated = Candidate("Harbour", 70);
|
||||
var unsure = Candidate("Unresolved room", 0, confidence: "Medium");
|
||||
var reviewed = Candidate("Reviewed ambiguity", 50, confidence: "Low", status: "Reviewed");
|
||||
var input = new[] { low, unrelated, medium, unsure, parent, high, reviewed };
|
||||
var all = LocationSignificanceService.OrderHierarchy(input, "all");
|
||||
Check(all.Take(4).Select(x => x.Key).SequenceEqual(new[] { "Estate", "Tower", "Library", "Kitchen" }), "Descendants must remain grouped; significant siblings first, significant subtrees first.");
|
||||
Check(high.HierarchyDepth == 1 && parent.HierarchyDepth == 0, "Hierarchy depth must be retained.");
|
||||
var significant = LocationSignificanceService.OrderHierarchy(input, "significant");
|
||||
Check(significant.Contains(parent) && significant.Contains(high) && !significant.Contains(low) && significant.Contains(reviewed), "Significant filter must include ancestors and keep already reviewed evidence accessible.");
|
||||
var uncertain = LocationSignificanceService.OrderHierarchy(input, "uncertain");
|
||||
Check(uncertain.Count == 1 && uncertain[0] == unsure, "Only genuine unresolved ambiguity belongs in the uncertainty filter, independently of significance.");
|
||||
Check(LocationSignificanceService.OrderHierarchy([Candidate("A", 50, "B"), Candidate("B", 0, "A")], "significant").Count == 2, "Legacy cyclic parent hints must terminate and retain all candidates.");
|
||||
var page = new StoryIntelligenceLocationReviewViewModel { FilteredCount = 52, PendingCandidateCount = 2, PageNumber = 3, PageSize = 25, Candidates = [parent, high] };
|
||||
Check(page.PageCount == 3 && page.ShowingStart == 51 && page.ShowingEnd == 52 && !page.HasNextPage, "Browse pagination must use filter totals, independently of pending decision counts.");
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,10 @@ using PlotLine.ViewModels;
|
||||
|
||||
var tests = new (string Name, Action Test)[]
|
||||
{
|
||||
("Location persisted analysis preserves contextual bedrooms and setting provenance", LocationSignificanceTests.PersistedAnalysisKeepsContextAndParentEvidence),
|
||||
("Location consequence outranks capped recurrence", LocationSignificanceTests.ConsequenceOutranksFrequency),
|
||||
("Location scene significance is attributed only to actual settings", LocationSignificanceTests.SceneContextBelongsToSetting),
|
||||
("Location significance preserves hierarchy and independent uncertainty", LocationSignificanceTests.HierarchyAndUncertainty),
|
||||
("Character profile age uses canonical reference chronology", CharacterProfileAgeTests.ProfileUsesStoryDate),
|
||||
("Character detail and date-only save retain canonical DOB", CharacterProfileAgeTests.DetailAndSaveUseCanonicalBirthDate),
|
||||
("Story Point presence, previous appearances and age cross book boundaries", StoryPointTests.PeopleAndChronology),
|
||||
@ -829,7 +833,7 @@ static void StoryIntelligenceReviewCentreUsesIndependentTileGrid()
|
||||
"\"Characters\"",
|
||||
"\"Assets\"",
|
||||
"\"Knowledge\"",
|
||||
"\"Locations\"",
|
||||
"<h2>Locations</h2>",
|
||||
"ReviewPlotSynthesis()",
|
||||
"\"Relationships\"",
|
||||
"ReviewMetrics"
|
||||
@ -1144,7 +1148,7 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
|
||||
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs"));
|
||||
var module = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "Location" : "Asset";
|
||||
var candidateVariable = string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal) ? "locationCandidates" : "assetCandidates";
|
||||
Assert(onboarding.Contains($"reviewCandidates.Get{module}ReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages.");
|
||||
Assert(onboarding.Contains($"reviewCandidates.Get{module}ReviewAsync(frame.Batch, pageNumber, pageSize{(module == "Location" ? ", scope" : "")})", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages.");
|
||||
Assert(view.Contains($"var {candidateVariable} = Model.{module}Review.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page.");
|
||||
}
|
||||
else
|
||||
@ -1152,7 +1156,7 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering()
|
||||
Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page.");
|
||||
}
|
||||
Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path.");
|
||||
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal) || view.Contains("PendingCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions.");
|
||||
Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal) || view.Contains("PendingCandidateCount", StringComparison.Ordinal) || view.Contains("FilteredCount", StringComparison.Ordinal), $"{viewName} should disclose the full result count.");
|
||||
}
|
||||
|
||||
var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml"));
|
||||
@ -1378,7 +1382,7 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
|
||||
var expectedActions = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["StoryIntelligenceCharacters"] = "GetCharacterReviewAsync(batchId, page)",
|
||||
["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId, page)",
|
||||
["StoryIntelligenceLocations"] = "GetLocationReviewAsync(batchId, page, scope: scope)",
|
||||
["StoryIntelligenceAssets"] = "GetAssetReviewAsync(batchId, page)",
|
||||
["StoryIntelligenceRelationships"] = "GetRelationshipReviewAsync(batchId, page)",
|
||||
["StoryIntelligenceKnowledge"] = "GetKnowledgeReviewAsync(batchId, page)",
|
||||
@ -1412,7 +1416,7 @@ static void StoryIntelligenceDetailedReviewGetsUseSelectedModulePaths()
|
||||
|
||||
var persistedReviewService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||
Assert(service.Contains("reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Character GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Location GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize, scope)", StringComparison.Ordinal), "Location GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Asset GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Relationship GET should read persisted review candidates.");
|
||||
Assert(service.Contains("reviewCandidates.GetKnowledgeReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Knowledge GET should read persisted review candidates.");
|
||||
@ -1470,8 +1474,8 @@ static void StoryIntelligenceReviewCentreCompletedModulesUseCanonicalDestination
|
||||
Assert(centre.Contains("\"View Characters\"", StringComparison.Ordinal), "Complete Characters should use canonical action label.");
|
||||
Assert(centre.Contains("\"Characters\"", StringComparison.Ordinal) && centre.Contains("new { projectId = Model.ProjectID }", StringComparison.Ordinal), "Complete Characters should route to Characters/Index with project context.");
|
||||
|
||||
Assert(centre.Contains("\"Review Locations\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceLocations\"", StringComparison.Ordinal), "Incomplete Locations should route to Location review.");
|
||||
Assert(centre.Contains("\"View Locations\"", StringComparison.Ordinal) && centre.Contains("\"Locations\"", StringComparison.Ordinal), "Complete Locations should route to Locations/Index.");
|
||||
Assert(centre.Contains("Explore Locations", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceLocations\"", StringComparison.Ordinal), "Incomplete Locations should route to Location review.");
|
||||
Assert(centre.Contains("View Locations", StringComparison.Ordinal) && centre.Contains("\"Locations\"", StringComparison.Ordinal), "Complete Locations should route to Locations/Index.");
|
||||
|
||||
Assert(centre.Contains("\"Review Assets\"", StringComparison.Ordinal) && centre.Contains("\"StoryIntelligenceAssets\"", StringComparison.Ordinal), "Incomplete Assets should route to Asset review.");
|
||||
Assert(centre.Contains("\"View Assets\"", StringComparison.Ordinal) && centre.Contains("\"StoryAssets\"", StringComparison.Ordinal), "Complete Assets should route to StoryAssets/Index.");
|
||||
@ -1527,7 +1531,7 @@ static void StoryIntelligenceDetailedReviewsReadPersistedCandidatePages()
|
||||
Assert(migration.Contains("IF @PageSize > 100 SET @PageSize = 100", StringComparison.Ordinal), "Page size should be bounded server-side.");
|
||||
Assert(repository.Contains("CommandType.StoredProcedure", StringComparison.Ordinal), "Candidate repository should use stored procedures.");
|
||||
Assert(!repository.Contains("SELECT ", StringComparison.Ordinal), "Candidate repository should not use inline SELECT SQL.");
|
||||
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Location GET should use persisted candidate pages.");
|
||||
Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize, scope)", StringComparison.Ordinal), "Normal Location GET should use persisted candidate pages.");
|
||||
Assert(onboarding.Contains("reviewCandidates.GetRelationshipReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), "Normal Relationship GET should use persisted candidate pages.");
|
||||
Assert(service.Contains("PreparePeerCandidatesAsync", StringComparison.Ordinal), "Scene JSON candidate builders should be isolated to deliberate preparation.");
|
||||
Assert(service.Contains("LoadPageAsync", StringComparison.Ordinal), "Normal review reads should use page loading.");
|
||||
@ -1579,7 +1583,7 @@ static void StoryIntelligenceModuleCompletionLifecycleUsesPersistedCandidates()
|
||||
Assert(onboarding.Contains("var result = await assetImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Asset import should not set completion directly.");
|
||||
Assert(onboarding.Contains("var result = await relationshipImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Relationship import should not set completion directly.");
|
||||
Assert(onboarding.Contains("var result = await knowledgeImport.ImportAsync(batch, form, false)", StringComparison.Ordinal), "Knowledge import should not set completion directly.");
|
||||
Assert(onboarding.Contains("ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Location pending count instead of an empty detailed page.");
|
||||
Assert(onboarding.Contains("SummaryNeedsAttentionCount = summary.LocationUncertainCount", StringComparison.Ordinal), "Location attention count should report uncertainty independently of pending imports.");
|
||||
Assert(onboarding.Contains("ReadyPendingCount(summary.AssetCandidateGenerationStatus, summary.AssetPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Asset pending count instead of an empty detailed page.");
|
||||
Assert(onboarding.Contains("ReadyPendingCount(summary.RelationshipCandidateGenerationStatus, summary.RelationshipPendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Relationship pending count instead of an empty detailed page.");
|
||||
Assert(onboarding.Contains("ReadyPendingCount(summary.KnowledgeCandidateGenerationStatus, summary.KnowledgePendingCount)", StringComparison.Ordinal), "Review Centre should use persisted Knowledge pending count instead of an empty detailed page.");
|
||||
@ -3013,11 +3017,10 @@ static void LocationReviewUxSupportsLargeCandidateSets()
|
||||
Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts.");
|
||||
Assert(view.Contains("Page @Model.LocationReview.PageNumber", StringComparison.Ordinal), "Location review should show Page X of Y.");
|
||||
Assert(view.Contains("Showing @Model.LocationReview.ShowingStart", StringComparison.Ordinal), "Location review should show N-M of T pending.");
|
||||
Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal)
|
||||
&& view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal), "Previous and Next should submit the current page so decisions persist before navigation.");
|
||||
Assert(view.Contains("if (Model.LocationReview.HasNextPage)", StringComparison.Ordinal)
|
||||
&& view.Contains("else", StringComparison.Ordinal)
|
||||
&& view.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal), "Location review should show Next before the last page and Save only on the last page.");
|
||||
Assert(view.Contains("asp-route-page=\"@(Model.LocationReview.PageNumber - 1)\"", StringComparison.Ordinal)
|
||||
&& view.Contains("asp-route-page=\"@(Model.LocationReview.PageNumber + 1)\"", StringComparison.Ordinal), "Pagination should navigate without committing decisions.");
|
||||
Assert(view.Contains("if (Model.LocationReview.CanImport)", StringComparison.Ordinal)
|
||||
&& view.Contains("name=\"intent\" value=\"save\"", StringComparison.Ordinal), "Every pending page should offer explicit Save.");
|
||||
Assert(view.Contains("name=\"PageNumber\"", StringComparison.Ordinal), "Location review should post the current page number.");
|
||||
Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence.");
|
||||
Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all.");
|
||||
@ -3027,7 +3030,7 @@ static void LocationReviewUxSupportsLargeCandidateSets()
|
||||
Assert(controller.Contains("ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = \"save\")", StringComparison.Ordinal), "Location review POST should accept a save/navigation intent.");
|
||||
Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal)
|
||||
&& controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Location review should return to Review Centre only after module-wide pending reaches zero.");
|
||||
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal), "Location review should stay in-module while pending candidates remain.");
|
||||
Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage, scope = form.Scope })", StringComparison.Ordinal), "Location review should stay in-module while pending candidates remain.");
|
||||
Assert(controller.Contains("var targetPage = string.Equals(intent, \"previous\"", StringComparison.Ordinal), "Previous navigation should move to the prior persisted page after saving.");
|
||||
Assert(viewModels.Contains("IReadOnlyList<string> EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries.");
|
||||
Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation.");
|
||||
@ -3091,7 +3094,7 @@ static void StoryIntelligenceLocationReviewUsesSingleIdentityDecision()
|
||||
Assert(view.Contains("data-location-unified-target", StringComparison.Ordinal), "Same-location selector should use the unified target source.");
|
||||
Assert(view.Contains("IsSelfTarget(target, candidate)", StringComparison.Ordinal), "Selectors should exclude the current candidate.");
|
||||
Assert(view.Contains("TargetLabel(target)", StringComparison.Ordinal), "Selectors should share display naming for canonical and pending targets.");
|
||||
Assert(candidateService.Contains("BuildLocationTargetOptionsAsync(projectLocations, allPendingPage.Items)", StringComparison.Ordinal),
|
||||
Assert(candidateService.Contains("BuildLocationTargetOptionsAsync(projectLocations, all.Where(x => x.IsPending).ToList())", StringComparison.Ordinal),
|
||||
"Persisted review GET should hydrate unified canonical and pending targets.");
|
||||
Assert(candidateService.Contains("canonicalKeys.Contains(key)", StringComparison.Ordinal), "Pending targets duplicating canonical identity should be suppressed.");
|
||||
Assert(candidateService.Contains(".ThenBy(option => option.DisplayName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
|
||||
|
||||
@ -410,9 +410,9 @@ public sealed class OnboardingController(
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence/locations")]
|
||||
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1)
|
||||
public async Task<IActionResult> StoryIntelligenceLocations(Guid batchId, int page = 1, string scope = "significant")
|
||||
{
|
||||
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page);
|
||||
var model = await storyIntelligence.GetLocationReviewAsync(batchId, page, scope: scope);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
@ -558,10 +558,10 @@ public sealed class OnboardingController(
|
||||
TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message;
|
||||
if (!result.Success)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) });
|
||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber), scope = form.Scope });
|
||||
}
|
||||
|
||||
var pendingCount = progress.LocationReview.SummaryNeedsAttentionCount ?? progress.LocationReview.PendingCandidateCount;
|
||||
var pendingCount = progress.LocationReview.PendingCandidateCount;
|
||||
if (pendingCount <= 0)
|
||||
{
|
||||
return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID });
|
||||
@ -575,7 +575,7 @@ public sealed class OnboardingController(
|
||||
: currentPage;
|
||||
targetPage = Math.Clamp(targetPage, 1, pageCount);
|
||||
|
||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage });
|
||||
return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage, scope = form.Scope });
|
||||
}
|
||||
|
||||
[HttpPost("story-intelligence/assets")]
|
||||
|
||||
@ -15,6 +15,8 @@ public interface IStoryIntelligenceReviewCandidateRepository
|
||||
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListPageAsync(int bookId, string module, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceReviewCandidateGeneration?> MarkReviewedAsync(StoryIntelligenceReviewCandidateDecisionRequest request);
|
||||
Task ResetBookAsync(int bookId);
|
||||
Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListLocationsAsync(int bookId);
|
||||
Task BackfillLocationSignificanceAsync(int bookId, string assessmentsJson);
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFactory connectionFactory) : IStoryIntelligenceReviewCandidateRepository
|
||||
@ -120,6 +122,20 @@ public sealed class StoryIntelligenceReviewCandidateRepository(ISqlConnectionFac
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StoryIntelligenceReviewCandidateRow>> ListLocationsAsync(int bookId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return (await connection.QueryAsync<StoryIntelligenceReviewCandidateRow>(
|
||||
"dbo.StoryIntelligenceLocationReview_List", new { BookID = bookId }, commandType: CommandType.StoredProcedure)).ToList();
|
||||
}
|
||||
|
||||
public async Task BackfillLocationSignificanceAsync(int bookId, string assessmentsJson)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync("dbo.StoryIntelligenceLocationReview_BackfillSignificance",
|
||||
new { BookID = bookId, AssessmentsJson = assessmentsJson }, commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task ResetBookAsync(int bookId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -169,6 +169,8 @@ public sealed class StoryIntelligenceReviewCentreSummary
|
||||
public int? CharacterPendingCount { get; init; }
|
||||
public string? LocationCandidateGenerationStatus { get; init; }
|
||||
public int? LocationCandidateCount { get; init; }
|
||||
public int LocationSignificantCount { get; set; }
|
||||
public int LocationUncertainCount { get; set; }
|
||||
public int? LocationPendingCount { get; init; }
|
||||
public string? AssetCandidateGenerationStatus { get; init; }
|
||||
public int? AssetCandidateCount { get; init; }
|
||||
|
||||
151
PlotLine/Services/LocationSignificanceService.cs
Normal file
151
PlotLine/Services/LocationSignificanceService.cs
Normal file
@ -0,0 +1,151 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed record LocationSignificanceEvidence(int SceneID, int ChapterID, int BookID, string SceneLabel,
|
||||
bool IsSetting, int EventScore, int ContextScore, IReadOnlyList<string> Reasons,
|
||||
IReadOnlyList<string> Characters, IReadOnlyList<string> PlotLines);
|
||||
|
||||
public sealed class LocationSignificanceAssessment
|
||||
{
|
||||
public int Version { get; init; } = 1;
|
||||
public int Score { get; init; }
|
||||
public string Classification { get; init; } = "Incidental Location";
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public IReadOnlyList<string> Characters { get; init; } = [];
|
||||
public IReadOnlyList<string> PlotLines { get; init; } = [];
|
||||
public IReadOnlyList<LocationSignificanceEvidence> KeyScenes { get; init; } = [];
|
||||
public bool IsSignificant => Score >= 40 || Classification == "Primary Setting";
|
||||
}
|
||||
|
||||
public static class LocationSignificanceService
|
||||
{
|
||||
// Severe events are distinct from exposition verbs such as "reveals" or "discovers".
|
||||
private static readonly Regex Consequence = new(@"\b(murder(?:ed)?|kill(?:ed|ing)?|death|dies?|dead body|abduct(?:ed|ion)?|kidnap(?:ped|ping)?|betray(?:s|ed|al)?|rescu(?:e|ed)|climax|turning point|major decision|arrest(?:ed)?|destroy(?:s|ed)|burns? down)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex Development = new(@"\b(reveal(?:s|ed)?|revelation|discover(?:s|ed|y)?|confront(?:ation|s|ed)?|confess(?:ion|es|ed)?)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex Stakes = new(@"\b(secret|identity|truth|evidence|abuse|missing|cover.up|body|critical|decisive)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
private static readonly Regex Negation = new(@"\b(no|not|never|imagined|hypothetical|might|could|would|rumou?r|remember(?:ed|s)?)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
private static bool Consequential(string? text) => !string.IsNullOrWhiteSpace(text) && !Negation.IsMatch(text)
|
||||
&& (Consequence.IsMatch(text) || Development.IsMatch(text) && Stakes.IsMatch(text));
|
||||
private static bool High(string? text) => text?.Trim().ToLowerInvariant() is "high" or "major" or "critical" or "central";
|
||||
|
||||
public static LocationSignificanceEvidence Observe(Scene scene, SceneIntelligenceScene parsed,
|
||||
string sceneLabel, bool setting, bool present, bool mentionedOnly, string? localEvidence)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
// Mention-only sites need their own evidence; they cannot inherit the setting's plot significance.
|
||||
var eventScore = Consequential(localEvidence) ? 50 : 0;
|
||||
if (eventScore > 0) reasons.Add($"Consequential location evidence: {localEvidence}");
|
||||
var context = 0;
|
||||
var people = new List<string>();
|
||||
var plots = new List<string>();
|
||||
if (setting && present && !mentionedOnly)
|
||||
{
|
||||
context += 12;
|
||||
reasons.Add("Scene setting");
|
||||
var arcs = (parsed.NarrativeArcs ?? []).Where(x => !string.IsNullOrWhiteSpace(x.Title)).ToList();
|
||||
var importantArc = arcs.Any(x => High(x.Significance));
|
||||
plots.AddRange(arcs.Select(x => x.Title!));
|
||||
var purpose = parsed.ScenePurpose?.ObservedFunction;
|
||||
if (Consequential(purpose) || importantArc && !string.IsNullOrWhiteSpace(purpose)
|
||||
&& Development.IsMatch(purpose) && !Negation.IsMatch(purpose))
|
||||
{
|
||||
eventScore = Math.Max(eventScore, Consequential(purpose) ? 50 : 40);
|
||||
reasons.Add($"Consequential scene purpose: {purpose}");
|
||||
}
|
||||
if (arcs.Count > 0)
|
||||
{
|
||||
context += importantArc ? 6 : 4;
|
||||
// A major plot development is substantive evidence even without event vocabulary.
|
||||
if (importantArc) eventScore = Math.Max(eventScore, 25);
|
||||
reasons.Add(importantArc ? "Major plot / thread development at this setting" : "Plot / thread development at this setting");
|
||||
}
|
||||
var metrics = (parsed.Metrics ?? []).Where(x => Regex.IsMatch(x.Key, "tension|emotion|action|darkness|mystery", RegexOptions.IgnoreCase))
|
||||
.Select(x => x.Value.Score ?? 0).Concat((parsed.MetricValues ?? []).Where(x => Regex.IsMatch(x.Key ?? "", "tension|emotion|action|darkness|mystery", RegexOptions.IgnoreCase)).Select(x => x.Score ?? 0));
|
||||
if (metrics.DefaultIfEmpty().Max() >= 8)
|
||||
{
|
||||
context += 10;
|
||||
reasons.Add("High scene tension / emotional weight / action / mystery");
|
||||
}
|
||||
var knowledge = (parsed.KnowledgeChanges ?? []).Where(x => !string.IsNullOrWhiteSpace(x.KnowledgeItem)).ToList();
|
||||
if (knowledge.Count > 0 || (parsed.QuestionsAnswered ?? []).Any())
|
||||
{
|
||||
var consequentialKnowledge = knowledge.Any(x => Consequential(x.KnowledgeItem));
|
||||
context += consequentialKnowledge ? 12 : 3;
|
||||
reasons.Add(consequentialKnowledge ? "Consequential knowledge change at this setting" : "Knowledge change / answered question at this setting");
|
||||
}
|
||||
if ((parsed.Relationships ?? []).Any(x => Consequential(x.RelationshipSignal) || Consequential(x.Evidence)))
|
||||
{
|
||||
context += 10;
|
||||
reasons.Add("Consequential relationship event at this setting");
|
||||
}
|
||||
people.AddRange((parsed.Characters ?? []).Where(x => x.MentionedOnly != true && !string.IsNullOrWhiteSpace(x.Name)).Select(x => x.Name!));
|
||||
if (!string.IsNullOrWhiteSpace(parsed.PointOfView?.CharacterName))
|
||||
{
|
||||
people.Add(parsed.PointOfView.CharacterName);
|
||||
context += 4;
|
||||
}
|
||||
}
|
||||
// Correlated signals from one scene cannot swamp the event signal or flatten every score to 100.
|
||||
return new(scene.SceneID, scene.ChapterID, scene.BookID, sceneLabel, setting && present && !mentionedOnly,
|
||||
eventScore, Math.Min(36, context), reasons,
|
||||
people.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), plots.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||
}
|
||||
|
||||
public static LocationSignificanceAssessment Assess(string category, IEnumerable<LocationSignificanceEvidence> source)
|
||||
{
|
||||
var scenes = source.GroupBy(x => x.SceneID).Select(x => x.OrderByDescending(y => y.EventScore + y.ContextScore).First()).ToList();
|
||||
var peak = scenes.OrderByDescending(x => x.EventScore + x.ContextScore).FirstOrDefault();
|
||||
var chapters = scenes.Select(x => x.ChapterID).Distinct().Count();
|
||||
var recurrence = Math.Min(8, Math.Max(0, chapters - 1) * 2);
|
||||
var named = category is "Named location" ? 4 : 0;
|
||||
var score = Math.Min(100, (peak?.EventScore ?? 0) + (peak?.ContextScore ?? 0) + recurrence + named);
|
||||
var primary = scenes.Count(x => x.IsSetting) >= 6 && scenes.Where(x => x.IsSetting).Select(x => x.ChapterID).Distinct().Count() >= 3 && score >= 40;
|
||||
var classification = primary ? "Primary Setting" : score >= 40 ? "Significant Location" : scenes.Count >= 3 ? "Recurring Location" : "Incidental Location";
|
||||
var reasons = (peak?.Reasons ?? []).ToList();
|
||||
if (recurrence > 0) reasons.Add($"Across {chapters} chapters (recurrence contributes {recurrence}/8)");
|
||||
return new() { Score = score, Classification = classification, Reasons = reasons,
|
||||
Characters = scenes.SelectMany(x => x.Characters).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(),
|
||||
PlotLines = scenes.SelectMany(x => x.PlotLines).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList(),
|
||||
KeyScenes = scenes.OrderByDescending(x => x.EventScore + x.ContextScore).ThenBy(x => x.SceneID).Take(3).ToList() };
|
||||
}
|
||||
|
||||
public static IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> OrderHierarchy(
|
||||
IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> candidates, string scope)
|
||||
{
|
||||
var byKey = candidates.ToDictionary(x => x.Key, StringComparer.OrdinalIgnoreCase);
|
||||
var included = candidates.Where(x => scope == "all" || (scope == "uncertain" ? x.IsPending && x.NeedsReview : x.Significance?.IsSignificant == true)).Select(x => x.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in included.ToList())
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var current = byKey[key];
|
||||
while (current.ProposedParentCandidateKey is string parent && byKey.TryGetValue(parent, out current!) && visited.Add(parent)) included.Add(parent);
|
||||
}
|
||||
var children = candidates.ToLookup(x => x.ProposedParentCandidateKey ?? "", StringComparer.OrdinalIgnoreCase);
|
||||
int Rank(StoryIntelligenceLocationReviewCandidateViewModel item, HashSet<string> path)
|
||||
{
|
||||
if (!path.Add(item.Key)) return 0;
|
||||
var result = Math.Max(item.Significance?.Score ?? 0, children[item.Key].Select(x => Rank(x, path)).DefaultIfEmpty().Max());
|
||||
path.Remove(item.Key); return result;
|
||||
}
|
||||
var ranks = candidates.ToDictionary(x => x.Key, x => Rank(x, new(StringComparer.OrdinalIgnoreCase)), StringComparer.OrdinalIgnoreCase);
|
||||
var output = new List<StoryIntelligenceLocationReviewCandidateViewModel>(); var emitted = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
void Visit(StoryIntelligenceLocationReviewCandidateViewModel item, int depth)
|
||||
{
|
||||
if (!included.Contains(item.Key) || !emitted.Add(item.Key)) return;
|
||||
item.HierarchyDepth = depth; output.Add(item);
|
||||
foreach (var child in children[item.Key].OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase)) Visit(child, depth + 1);
|
||||
}
|
||||
// An already canonical parent may have no candidate row. Its siblings still form one group.
|
||||
var roots = candidates.Where(x => x.ProposedParentCandidateKey is null || !byKey.ContainsKey(x.ProposedParentCandidateKey));
|
||||
var rootGroups = roots.GroupBy(x => x.ProposedParentLocationID.HasValue ? $"canonical:{x.ProposedParentLocationID}"
|
||||
: x.ProposedParentCandidateKey is not null ? $"parent:{x.ProposedParentCandidateKey}" : $"root:{x.Key}", StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in rootGroups.OrderByDescending(g => g.Max(x => ranks[x.Key]))
|
||||
.ThenBy(g => g.First().ProposedParentLocationName ?? g.First().LocationName, StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var root in group.OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase))
|
||||
Visit(root, root.ProposedParentLocationID.HasValue ? 1 : 0);
|
||||
foreach (var remaining in candidates.OrderByDescending(x => ranks[x.Key]).ThenBy(x => x.LocationName)) Visit(remaining, 0);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,7 @@ public interface IOnboardingStoryIntelligenceService
|
||||
Task<StoryIntelligenceProgressViewModel?> GetProgressAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetReviewCentreAsync(Guid batchId);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25, string scope = "significant");
|
||||
Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetRelationshipReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
Task<StoryIntelligenceProgressViewModel?> GetKnowledgeReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25);
|
||||
@ -769,8 +769,8 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
public Task<StoryIntelligenceProgressViewModel?> GetCharacterReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
|
||||
=> GetSelectedReviewAsync(batchId, "Characters", async frame => frame.Model.CharacterReview = await reviewCandidates.GetCharacterReviewAsync(frame.Batch, pageNumber, pageSize));
|
||||
|
||||
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
|
||||
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize));
|
||||
public Task<StoryIntelligenceProgressViewModel?> GetLocationReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25, string scope = "significant")
|
||||
=> GetSelectedReviewAsync(batchId, "Locations", async frame => frame.Model.LocationReview = await reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize, scope));
|
||||
|
||||
public Task<StoryIntelligenceProgressViewModel?> GetAssetReviewAsync(Guid batchId, int pageNumber = 1, int pageSize = 25)
|
||||
=> GetSelectedReviewAsync(batchId, "Assets", async frame => frame.Model.AssetReview = await reviewCandidates.GetAssetReviewAsync(frame.Batch, pageNumber, pageSize));
|
||||
@ -877,7 +877,11 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
HasCommittedScenes = hasCommittedScenes,
|
||||
CanImport = !locationComplete,
|
||||
IsComplete = locationComplete,
|
||||
SummaryNeedsAttentionCount = locationComplete ? 0 : ReadyPendingCount(summary.LocationCandidateGenerationStatus, summary.LocationPendingCount)
|
||||
TotalCandidateCount = summary.LocationCandidateCount ?? 0,
|
||||
PendingCandidateCount = summary.LocationPendingCount ?? 0,
|
||||
SignificantCount = summary.LocationSignificantCount,
|
||||
UncertainCount = summary.LocationUncertainCount,
|
||||
SummaryNeedsAttentionCount = summary.LocationUncertainCount
|
||||
},
|
||||
AssetReview = new StoryIntelligenceAssetReviewViewModel
|
||||
{
|
||||
|
||||
@ -149,6 +149,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
LeafLocationName = candidate.LeafName,
|
||||
ImportName = candidate.LeafName,
|
||||
Category = candidate.Category,
|
||||
Significance = LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence),
|
||||
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
|
||||
Confidence = ConfidenceBand(candidate.Confidence),
|
||||
ParentLocationHint = candidate.ParentLocationHint,
|
||||
@ -463,7 +464,13 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
public async Task<IReadOnlyDictionary<string, LocationSignificanceAssessment>> BuildSignificanceAsync(OnboardingStoryIntelligenceBatch batch)
|
||||
{
|
||||
var data = await BuildCandidateDataAsync(batch, includeLinked: true);
|
||||
return data.Candidates.ToDictionary(x => x.Key, x => LocationSignificanceService.Assess(x.Category, x.SignificanceEvidence), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<LocationCandidateData> BuildCandidateDataAsync(OnboardingStoryIntelligenceBatch batch, bool includeLinked = false)
|
||||
{
|
||||
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
|
||||
var groups = new Dictionary<string, LocationCandidate>(StringComparer.OrdinalIgnoreCase);
|
||||
@ -514,16 +521,30 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
alreadyLinked++;
|
||||
}
|
||||
|
||||
importedScene.BookID = batch.BookID;
|
||||
AddSceneLocations(groups, existingIndex, importedScene, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
LinkParentCandidates(groups);
|
||||
// A house hosts events in its rooms. Roll up actual child settings, never unrelated mentions.
|
||||
var directEvidence = groups.Values.ToDictionary(x => x.Key, x => x.SignificanceEvidence.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var child in groups.Values)
|
||||
{
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { child.Key };
|
||||
var parentKey = child.ProposedParentCandidateKey;
|
||||
while (parentKey is not null && groups.TryGetValue(parentKey, out var parent) && visited.Add(parentKey))
|
||||
{
|
||||
parent.SignificanceEvidence.AddRange(directEvidence[child.Key].Where(x => x.IsSetting)
|
||||
.Select(x => x with { Reasons = new[] { $"Within child setting: {child.DisplayName}" }.Concat(x.Reasons).ToList() }));
|
||||
parentKey = parent.ProposedParentCandidateKey;
|
||||
}
|
||||
}
|
||||
var candidates = groups.Values
|
||||
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
|
||||
.Where(candidate => includeLinked || candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
|
||||
.Where(candidate => IsVisibleLocationCandidate(candidate))
|
||||
.OrderByDescending(candidate => candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count())
|
||||
.OrderByDescending(candidate => LocationSignificanceService.Assess(candidate.Category, candidate.SignificanceEvidence).Score)
|
||||
.ThenBy(candidate => candidate.DisplayName)
|
||||
.ToList();
|
||||
|
||||
@ -637,6 +658,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
candidate.Aliases.Add(cleanName);
|
||||
}
|
||||
|
||||
candidate.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), isSceneSetting, presentInScene, mentionedOnly, notes));
|
||||
candidate.Confidence = Max(candidate.Confidence, confidence);
|
||||
candidate.Description ??= Clean(notes);
|
||||
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
|
||||
@ -696,6 +718,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
groups[parentKey] = parentCandidate;
|
||||
}
|
||||
|
||||
parentCandidate.SignificanceEvidence.Add(LocationSignificanceService.Observe(importedScene, parsed, BuildSceneReference(importedScene), false, true, false, null));
|
||||
parentCandidate.Confidence = Max(parentCandidate.Confidence, 0.9m);
|
||||
parentCandidate.Description ??= "Parent location inferred from child location context.";
|
||||
parentCandidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, parentIdentity.DisplayName);
|
||||
@ -1866,6 +1889,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
||||
public string? ProposedParentCandidateKey { get; set; }
|
||||
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<LocationAppearanceImport> Appearances { get; } = [];
|
||||
public List<LocationSignificanceEvidence> SignificanceEvidence { get; } = [];
|
||||
public decimal? Confidence { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? FirstAppearanceNote { get; set; }
|
||||
|
||||
@ -12,7 +12,7 @@ public interface IStoryIntelligenceReviewCandidateService
|
||||
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant");
|
||||
Task<StoryIntelligenceAssetReviewViewModel> GetAssetReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceRelationshipReviewViewModel> GetRelationshipReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
Task<StoryIntelligenceKnowledgeReviewViewModel> GetKnowledgeReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||
@ -188,10 +188,22 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize, string scope = "significant")
|
||||
{
|
||||
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
|
||||
var allPendingPage = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, 1, MaxPageSize);
|
||||
scope = scope is "all" or "uncertain" ? scope : "significant";
|
||||
var generation = await candidates.GetCurrentGenerationAsync(batch.BookID, StoryIntelligenceReviewModules.Locations);
|
||||
var rows = await candidates.ListLocationsAsync(batch.BookID);
|
||||
var all = rows.Select(row => {
|
||||
var item = JsonSerializer.Deserialize<StoryIntelligenceLocationReviewCandidateViewModel>(row.CandidatePayloadJson, JsonOptions)!;
|
||||
item.DecisionStatus = row.DecisionStatus;
|
||||
return item;
|
||||
}).ToList();
|
||||
var ordered = LocationSignificanceService.OrderHierarchy(all, scope);
|
||||
pageSize = Math.Clamp(pageSize, 1, MaxPageSize);
|
||||
pageNumber = Math.Clamp(pageNumber, 1, Math.Max(1, (int)Math.Ceiling(ordered.Count / (double)pageSize)));
|
||||
var items = ordered.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToList();
|
||||
var pendingCount = all.Count(x => x.IsPending);
|
||||
var uncertainCount = all.Count(x => x.IsPending && x.NeedsReview);
|
||||
var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
|
||||
var parentOptions = projectLocations
|
||||
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
|
||||
@ -204,21 +216,25 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
||||
})
|
||||
.ToList();
|
||||
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
|
||||
var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, allPendingPage.Items);
|
||||
var targetOptions = await BuildLocationTargetOptionsAsync(projectLocations, all.Where(x => x.IsPending).ToList());
|
||||
return new StoryIntelligenceLocationReviewViewModel
|
||||
{
|
||||
HasCommittedScenes = true,
|
||||
CanImport = page.Items.Count > 0,
|
||||
IsComplete = page.GenerationReady && page.PendingCount == 0,
|
||||
SummaryNeedsAttentionCount = page.PendingCount,
|
||||
PageNumber = page.PageNumber,
|
||||
PageSize = page.PageSize,
|
||||
TotalCandidateCount = page.TotalCount,
|
||||
PendingCandidateCount = page.PendingCount,
|
||||
CanImport = items.Any(x => x.IsPending),
|
||||
IsComplete = generation?.Status == StoryIntelligenceReviewCandidateGenerationStatuses.Ready && pendingCount == 0,
|
||||
Scope = scope,
|
||||
SignificantCount = all.Count(x => x.Significance?.IsSignificant == true),
|
||||
UncertainCount = uncertainCount,
|
||||
FilteredCount = ordered.Count,
|
||||
SummaryNeedsAttentionCount = uncertainCount,
|
||||
PageNumber = pageNumber,
|
||||
PageSize = pageSize,
|
||||
TotalCandidateCount = all.Count,
|
||||
PendingCandidateCount = pendingCount,
|
||||
ParentLocationOptions = parentOptions,
|
||||
SameLocationOptions = sameLocationOptions,
|
||||
LocationTargetOptions = targetOptions,
|
||||
Candidates = page.Items
|
||||
Candidates = items
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
214
PlotLine/Sql/192_LocationNarrativeSignificance.sql
Normal file
214
PlotLine/Sql/192_LocationNarrativeSignificance.sql
Normal file
@ -0,0 +1,214 @@
|
||||
-- Deterministic assessments reuse persisted scene intelligence; no new AI or decision resets.
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceLocationReview_List @BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT c.* FROM dbo.StoryIntelligenceReviewCandidates c
|
||||
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
|
||||
WHERE g.BookID = @BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
|
||||
ORDER BY c.SortOrder, c.ReviewCandidateID;
|
||||
END;
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceLocationReview_BackfillSignificance @BookID int, @AssessmentsJson nvarchar(max)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
UPDATE c SET CandidatePayloadJson = JSON_MODIFY(c.CandidatePayloadJson, '$.significance', JSON_QUERY(a.Assessment))
|
||||
FROM dbo.StoryIntelligenceReviewCandidates c
|
||||
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
|
||||
INNER JOIN OPENJSON(@AssessmentsJson) WITH (StableKey nvarchar(450) '$.key', Assessment nvarchar(max) '$.assessment' AS JSON) a ON a.StableKey = c.StableKey
|
||||
WHERE g.BookID = @BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
|
||||
AND a.Assessment IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceReviewCentre_GetSummary
|
||||
@BookID int,
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
;WITH RunSummary AS
|
||||
(
|
||||
SELECT
|
||||
r.BookID,
|
||||
COUNT_BIG(*) AS RunCount,
|
||||
SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount,
|
||||
SUM(CASE WHEN r.Status IN (N'Pending', N'Running') THEN 1 ELSE 0 END) AS ActiveRunCount,
|
||||
SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount,
|
||||
SUM(COALESCE(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes,
|
||||
SUM(COALESCE(r.CompletedScenes, 0)) AS CompletedScenes,
|
||||
SUM(COALESCE(r.FailedScenes, 0)) AS FailedScenes
|
||||
FROM dbo.StoryIntelligenceRuns r
|
||||
WHERE r.BookID = @BookID
|
||||
GROUP BY r.BookID
|
||||
),
|
||||
CandidateSummary AS
|
||||
(
|
||||
SELECT
|
||||
generation.BookID,
|
||||
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.Status END) AS CharacterCandidateGenerationStatus,
|
||||
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.CandidateCount END) AS CharacterCandidateCount,
|
||||
MAX(CASE WHEN generation.Module = N'Characters' THEN generation.PendingCount END) AS CharacterPendingCount,
|
||||
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.Status END) AS LocationCandidateGenerationStatus,
|
||||
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.CandidateCount END) AS LocationCandidateCount,
|
||||
MAX(CASE WHEN generation.Module = N'Locations' THEN generation.PendingCount END) AS LocationPendingCount,
|
||||
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.Status END) AS AssetCandidateGenerationStatus,
|
||||
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.CandidateCount END) AS AssetCandidateCount,
|
||||
MAX(CASE WHEN generation.Module = N'Assets' THEN generation.PendingCount END) AS AssetPendingCount,
|
||||
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.Status END) AS RelationshipCandidateGenerationStatus,
|
||||
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.CandidateCount END) AS RelationshipCandidateCount,
|
||||
MAX(CASE WHEN generation.Module = N'Relationships' THEN generation.PendingCount END) AS RelationshipPendingCount,
|
||||
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.Status END) AS KnowledgeCandidateGenerationStatus,
|
||||
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.CandidateCount END) AS KnowledgeCandidateCount,
|
||||
MAX(CASE WHEN generation.Module = N'Knowledge' THEN generation.PendingCount END) AS KnowledgePendingCount
|
||||
FROM dbo.StoryIntelligenceReviewCandidateGenerations generation
|
||||
WHERE generation.BookID = @BookID
|
||||
AND generation.IsCurrent = 1
|
||||
GROUP BY generation.BookID
|
||||
),
|
||||
NarrativeSummary AS
|
||||
(
|
||||
SELECT
|
||||
nc.BookID,
|
||||
COUNT_BIG(*) AS NarrativeCandidateCount,
|
||||
SUM(CASE WHEN nc.Status = N'Pending' THEN 1 ELSE 0 END) AS NarrativePendingCount,
|
||||
SUM(CASE WHEN nc.CandidateType = N'Thread' THEN 1 ELSE 0 END) AS NarrativeThreadCount
|
||||
FROM dbo.StoryIntelligenceNarrativeCandidates nc
|
||||
WHERE nc.BookID = @BookID
|
||||
AND nc.WholeBookPlotSynthesisRunID IS NOT NULL
|
||||
GROUP BY nc.BookID
|
||||
),
|
||||
NarrativeEventSummary AS
|
||||
(
|
||||
SELECT
|
||||
nc.BookID,
|
||||
COUNT_BIG(*) AS NarrativeEventCount
|
||||
FROM dbo.StoryIntelligenceNarrativeCandidateEvents ev
|
||||
INNER JOIN dbo.StoryIntelligenceNarrativeCandidates nc
|
||||
ON nc.NarrativeCandidateID = ev.NarrativeCandidateID
|
||||
WHERE nc.BookID = @BookID
|
||||
AND nc.WholeBookPlotSynthesisRunID IS NOT NULL
|
||||
GROUP BY nc.BookID
|
||||
),
|
||||
MetricValueSummary AS
|
||||
(
|
||||
SELECT
|
||||
chapter.BookID,
|
||||
COUNT_BIG(*) AS MetricValueCount
|
||||
FROM dbo.SceneMetricValues metric
|
||||
INNER JOIN dbo.Scenes scene
|
||||
ON scene.SceneID = metric.SceneID
|
||||
INNER JOIN dbo.Chapters chapter
|
||||
ON chapter.ChapterID = scene.ChapterID
|
||||
WHERE chapter.BookID = @BookID
|
||||
GROUP BY chapter.BookID
|
||||
),
|
||||
CurrentPlotRun AS
|
||||
(
|
||||
SELECT TOP (1)
|
||||
run.BookID,
|
||||
run.WholeBookPlotSynthesisRunID,
|
||||
run.Status AS WholeBookPlotSynthesisStatus,
|
||||
run.ErrorMessage AS WholeBookPlotSynthesisErrorMessage
|
||||
FROM dbo.WholeBookPlotSynthesisRuns run
|
||||
WHERE run.BookID = @BookID
|
||||
AND run.IsCurrent = 1
|
||||
ORDER BY run.WholeBookPlotSynthesisRunID DESC
|
||||
)
|
||||
SELECT
|
||||
p.StoryIntelligenceBookPipelineID,
|
||||
p.ProjectID,
|
||||
project.ProjectName,
|
||||
p.BookID,
|
||||
book.BookTitle,
|
||||
book.Subtitle AS BookSubtitle,
|
||||
p.CurrentStage,
|
||||
p.LastCompletedStage,
|
||||
p.CurrentReviewStage,
|
||||
p.Status,
|
||||
p.CompletedUtc,
|
||||
p.CharacterReviewCompletedUtc,
|
||||
p.LocationReviewCompletedUtc,
|
||||
p.AssetReviewCompletedUtc,
|
||||
p.RelationshipReviewCompletedUtc,
|
||||
p.KnowledgeReviewCompletedUtc,
|
||||
p.NarrativeReviewCompletedUtc,
|
||||
cs.CharacterCandidateGenerationStatus,
|
||||
CAST(cs.CharacterCandidateCount AS int) AS CharacterCandidateCount,
|
||||
CAST(cs.CharacterPendingCount AS int) AS CharacterPendingCount,
|
||||
COALESCE(ls.SignificantCount, 0) AS LocationSignificantCount,
|
||||
COALESCE(ls.UncertainCount, 0) AS LocationUncertainCount,
|
||||
cs.LocationCandidateGenerationStatus,
|
||||
CAST(cs.LocationCandidateCount AS int) AS LocationCandidateCount,
|
||||
CAST(cs.LocationPendingCount AS int) AS LocationPendingCount,
|
||||
cs.AssetCandidateGenerationStatus,
|
||||
CAST(cs.AssetCandidateCount AS int) AS AssetCandidateCount,
|
||||
CAST(cs.AssetPendingCount AS int) AS AssetPendingCount,
|
||||
cs.RelationshipCandidateGenerationStatus,
|
||||
CAST(cs.RelationshipCandidateCount AS int) AS RelationshipCandidateCount,
|
||||
CAST(cs.RelationshipPendingCount AS int) AS RelationshipPendingCount,
|
||||
cs.KnowledgeCandidateGenerationStatus,
|
||||
CAST(cs.KnowledgeCandidateCount AS int) AS KnowledgeCandidateCount,
|
||||
CAST(cs.KnowledgePendingCount AS int) AS KnowledgePendingCount,
|
||||
p.LastRunID,
|
||||
CAST(COALESCE(rs.RunCount, 0) AS int) AS RunCount,
|
||||
CAST(COALESCE(rs.CompletedRunCount, 0) AS int) AS CompletedRunCount,
|
||||
CAST(COALESCE(rs.ActiveRunCount, 0) AS int) AS ActiveRunCount,
|
||||
CAST(COALESCE(rs.FailedRunCount, 0) AS int) AS FailedRunCount,
|
||||
CAST(COALESCE(rs.TotalDetectedScenes, 0) AS int) AS TotalDetectedScenes,
|
||||
CAST(COALESCE(rs.CompletedScenes, 0) AS int) AS CompletedScenes,
|
||||
CAST(COALESCE(rs.FailedScenes, 0) AS int) AS FailedScenes,
|
||||
plot.WholeBookPlotSynthesisRunID,
|
||||
plot.WholeBookPlotSynthesisStatus,
|
||||
plot.WholeBookPlotSynthesisErrorMessage,
|
||||
CAST(COALESCE(ns.NarrativeCandidateCount, 0) AS int) AS NarrativeCandidateCount,
|
||||
CAST(COALESCE(ns.NarrativePendingCount, 0) AS int) AS NarrativePendingCount,
|
||||
CAST(COALESCE(ns.NarrativeThreadCount, 0) AS int) AS NarrativeThreadCount,
|
||||
CAST(COALESCE(nes.NarrativeEventCount, 0) AS int) AS NarrativeEventCount,
|
||||
CAST(COALESCE(metrics.MetricValueCount, 0) AS int) AS MetricValueCount
|
||||
FROM dbo.StoryIntelligenceBookPipelines p
|
||||
INNER JOIN dbo.Projects project
|
||||
ON project.ProjectID = p.ProjectID
|
||||
INNER JOIN dbo.Books book
|
||||
ON book.BookID = p.BookID
|
||||
INNER JOIN dbo.ProjectUserAccess access
|
||||
ON access.ProjectID = p.ProjectID
|
||||
AND access.UserID = @UserID
|
||||
AND access.IsActive = 1
|
||||
LEFT JOIN RunSummary rs
|
||||
ON rs.BookID = p.BookID
|
||||
LEFT JOIN CandidateSummary cs
|
||||
ON cs.BookID = p.BookID
|
||||
LEFT JOIN NarrativeSummary ns
|
||||
ON ns.BookID = p.BookID
|
||||
LEFT JOIN NarrativeEventSummary nes
|
||||
ON nes.BookID = p.BookID
|
||||
LEFT JOIN MetricValueSummary metrics
|
||||
ON metrics.BookID = p.BookID
|
||||
LEFT JOIN CurrentPlotRun plot
|
||||
ON plot.BookID = p.BookID
|
||||
OUTER APPLY (
|
||||
SELECT SUM(CASE WHEN TRY_CONVERT(int, JSON_VALUE(c.CandidatePayloadJson, '$.significance.score')) >= 40 THEN 1 ELSE 0 END) AS SignificantCount,
|
||||
SUM(CASE WHEN c.DecisionStatus = N'Pending' AND
|
||||
(COALESCE(JSON_VALUE(c.CandidatePayloadJson, '$.confidence'), N'Unknown') <> N'High'
|
||||
OR JSON_VALUE(c.CandidatePayloadJson, '$.hasParentReviewIssue') = N'true'
|
||||
OR JSON_VALUE(c.CandidatePayloadJson, '$.category') = N'Ambiguous location') THEN 1 ELSE 0 END) AS UncertainCount
|
||||
FROM dbo.StoryIntelligenceReviewCandidates c
|
||||
INNER JOIN dbo.StoryIntelligenceReviewCandidateGenerations g ON g.ReviewCandidateGenerationID = c.ReviewCandidateGenerationID
|
||||
WHERE g.BookID = p.BookID AND g.Module = N'Locations' AND g.IsCurrent = 1 AND g.Status = N'Ready'
|
||||
) ls
|
||||
WHERE p.BookID = @BookID
|
||||
AND book.IsArchived = 0
|
||||
AND project.IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
@ -465,6 +465,10 @@ public static class StoryIntelligenceCharacterImportActions
|
||||
|
||||
public sealed class StoryIntelligenceLocationReviewViewModel
|
||||
{
|
||||
public string Scope { get; init; } = "significant";
|
||||
public int SignificantCount { get; init; }
|
||||
public int UncertainCount { get; init; }
|
||||
public int FilteredCount { get; init; }
|
||||
public bool CanImport { get; init; }
|
||||
public bool HasCommittedScenes { get; init; }
|
||||
public int AlreadyLinkedCount { get; init; }
|
||||
@ -474,9 +478,9 @@ public sealed class StoryIntelligenceLocationReviewViewModel
|
||||
public int PageSize { get; init; } = 25;
|
||||
public int TotalCandidateCount { get; init; }
|
||||
public int PendingCandidateCount { get; init; }
|
||||
public int PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize);
|
||||
public int ShowingStart => PendingCandidateCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
|
||||
public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(PendingCandidateCount, ShowingStart + Candidates.Count - 1);
|
||||
public int PageCount => FilteredCount <= 0 ? 1 : (int)Math.Ceiling(FilteredCount / (double)PageSize);
|
||||
public int ShowingStart => FilteredCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1;
|
||||
public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(FilteredCount, ShowingStart + Candidates.Count - 1);
|
||||
public bool HasPreviousPage => PageNumber > 1;
|
||||
public bool HasNextPage => PageNumber < PageCount;
|
||||
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
|
||||
@ -514,6 +518,11 @@ public sealed class StoryIntelligenceLocationTargetOptionViewModel
|
||||
|
||||
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
||||
{
|
||||
public PlotLine.Services.LocationSignificanceAssessment? Significance { get; init; }
|
||||
public int HierarchyDepth { get; set; }
|
||||
public string DecisionStatus { get; set; } = "Pending";
|
||||
public bool IsPending => string.Equals(DecisionStatus, "Pending", StringComparison.OrdinalIgnoreCase);
|
||||
public bool NeedsReview => HasParentReviewIssue || !string.Equals(Confidence, "High", StringComparison.OrdinalIgnoreCase) || Category == "Ambiguous location";
|
||||
public string Key { get; init; } = string.Empty;
|
||||
public string LocationName { get; init; } = string.Empty;
|
||||
public string LeafLocationName { get; init; } = string.Empty;
|
||||
@ -541,6 +550,7 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
||||
|
||||
public sealed class StoryIntelligenceLocationImportForm
|
||||
{
|
||||
public string Scope { get; set; } = "significant";
|
||||
public Guid BatchID { get; set; }
|
||||
public int PageNumber { get; set; } = 1;
|
||||
public List<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<div class="onboarding-copy story-review-heading">
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h1 id="story-location-title">Review Locations</h1>
|
||||
<p>Review place suggestions that need a create, same-location, or ignore decision.</p>
|
||||
<p>Explore important story settings first. Resolve uncertain identities and use the full list to inspect or organise every detected place.</p>
|
||||
</div>
|
||||
|
||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||
@ -21,12 +21,18 @@
|
||||
<div class="alert alert-success">@message</div>
|
||||
}
|
||||
|
||||
<nav class="onboarding-actions mb-3" aria-label="Location filters">
|
||||
<a class="btn @(Model.LocationReview.Scope == "significant" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="significant">Significant locations (@Model.LocationReview.SignificantCount)</a>
|
||||
<a class="btn @(Model.LocationReview.Scope == "all" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="all">All detected locations (@Model.LocationReview.TotalCandidateCount)</a>
|
||||
<a class="btn @(Model.LocationReview.Scope == "uncertain" ? "btn-primary" : "btn-outline-secondary")" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="uncertain">Need review (@Model.LocationReview.UncertainCount)</a>
|
||||
</nav>
|
||||
<p class="text-muted">Parent locations are included for context. Confidence describes identification; significance describes story importance.</p>
|
||||
<section class="story-review-chapter-list" aria-label="Location review">
|
||||
@if (Model.LocationReview.Candidates.Count == 0)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>No location decisions are waiting.</strong>
|
||||
<p class="mb-0">Locations are already up to date, or there were no location suggestions ready to import.</p>
|
||||
<strong>No locations match this filter.</strong>
|
||||
<p class="mb-0">Choose All detected locations to explore the complete list.</p>
|
||||
</div>
|
||||
<div class="onboarding-actions">
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
@ -40,12 +46,13 @@
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
||||
<span class="story-review-page-status">
|
||||
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
||||
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
|
||||
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
|
||||
<input type="hidden" name="BatchID" value="@Model.BatchID" />
|
||||
<input type="hidden" name="Scope" value="@Model.LocationReview.Scope" />
|
||||
<input type="hidden" name="PageNumber" value="@Model.LocationReview.PageNumber" />
|
||||
<div class="story-character-card-grid">
|
||||
@for (var i = 0; i < locationCandidates.Count; i++)
|
||||
@ -59,18 +66,32 @@
|
||||
? $"candidate:{candidate.ProposedParentCandidateKey}"
|
||||
: string.Empty;
|
||||
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint, candidate.ProposedParentLocationName });
|
||||
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()" data-location-search-text="@searchText">
|
||||
<details class="story-character-card" style="margin-inline-start: @(Math.Min(candidate.HierarchyDepth, 4) * 16)px" data-location-card data-location-status="@status" data-location-existing-match="@candidate.IsExistingMatch.ToString().ToLowerInvariant()" data-location-search-text="@searchText">
|
||||
<summary class="story-review-card-header">
|
||||
<span>
|
||||
@if (!string.IsNullOrWhiteSpace(proposedParentLabel)) { <small>Within @proposedParentLabel</small> }
|
||||
<strong>@candidate.LocationName</strong>
|
||||
<small>@(candidate.Significance?.Classification ?? "Not yet assessed") · @(candidate.IsPending && candidate.NeedsReview ? "Identity needs review" : "")</small>
|
||||
<small>@candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s")</small>
|
||||
</span>
|
||||
<em>@candidate.Confidence</em>
|
||||
<em>Confidence: @candidate.Confidence</em>
|
||||
</summary>
|
||||
|
||||
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
|
||||
|
||||
|
||||
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
|
||||
@if (candidate.Significance is { } significance)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
<strong>@significance.Classification · significance @significance.Score/100</strong>
|
||||
<ul>@foreach (var reason in significance.Reasons) { <li>@reason</li> }</ul>
|
||||
@if (significance.Characters.Any()) { <p>Characters: @string.Join(", ", significance.Characters)</p> }
|
||||
@if (significance.PlotLines.Any()) { <p>Plot lines: @string.Join(", ", significance.PlotLines)</p> }
|
||||
@foreach (var scene in significance.KeyScenes) {
|
||||
<a asp-controller="Scenes" asp-action="Details" asp-route-id="@scene.SceneID">@scene.SceneLabel</a><br />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<dl>
|
||||
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
|
||||
<div><dt>Proposed parent</dt><dd>@Display(proposedParentLabel)</dd></div>
|
||||
@ -98,6 +119,10 @@
|
||||
</details>
|
||||
}
|
||||
|
||||
@if (candidate.IsPending)
|
||||
{
|
||||
<input type="hidden" name="Locations.Index" value="@i" />
|
||||
<input type="hidden" name="Locations[@i].Key" value="@candidate.Key" />
|
||||
@if (candidate.IsExistingMatch)
|
||||
{
|
||||
<div class="story-review-note">
|
||||
@ -190,6 +215,8 @@
|
||||
<input type="hidden" name="Locations[@i].AliasTargetKey" data-location-alias-target />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else { <p class="text-muted">Decision saved: @candidate.DecisionStatus</p> }
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
@ -198,20 +225,21 @@
|
||||
<div class="onboarding-actions">
|
||||
<span class="text-muted">
|
||||
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
|
||||
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
|
||||
· Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.FilteredCount.ToString("N0") locations
|
||||
</span>
|
||||
@if (Model.LocationReview.HasPreviousPage)
|
||||
{
|
||||
<button class="btn btn-outline-secondary" type="submit" name="intent" value="previous" data-busy-label="Saving page...">Previous</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
|
||||
}
|
||||
@if (Model.LocationReview.HasNextPage)
|
||||
{
|
||||
<button class="btn btn-primary" type="submit" name="intent" value="next" data-busy-label="Saving page...">Next</button>
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-scope="@Model.LocationReview.Scope" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
|
||||
}
|
||||
else
|
||||
@if (Model.LocationReview.CanImport)
|
||||
{
|
||||
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving locations...">Save location decisions</button>
|
||||
}
|
||||
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@ -58,17 +58,16 @@
|
||||
"StoryBible",
|
||||
new { projectId = Model.ProjectID })
|
||||
|
||||
@ReviewModule(
|
||||
"Locations",
|
||||
Model.LocationNeedsAttentionCount,
|
||||
Model.PipelineDashboard.LocationStageComplete,
|
||||
!Model.CharacterGateSatisfied,
|
||||
"Places found in the manuscript.",
|
||||
"Review Locations",
|
||||
"StoryIntelligenceLocations",
|
||||
"View Locations",
|
||||
"Locations",
|
||||
new { projectId = Model.ProjectID })
|
||||
<article class="story-review-centre-tile">
|
||||
<h2>Locations</h2>
|
||||
<strong class="story-review-centre-tile__status story-review-centre-tile__status--ready">@Model.LocationReview.TotalCandidateCount locations identified</strong>
|
||||
<p>@Model.LocationReview.SignificantCount significant · @Model.LocationReview.UncertainCount need review</p>
|
||||
<p>Explore story settings. Review uncertain identities when your judgement is needed.</p>
|
||||
@if (Model.CharacterGateSatisfied) {
|
||||
<a class="btn btn-outline-primary btn-sm" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID">Explore Locations</a>
|
||||
@if (Model.PipelineDashboard.LocationStageComplete) { <a class="btn btn-outline-secondary btn-sm" asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.ProjectID">View Locations</a> }
|
||||
} else { <span class="btn btn-outline-secondary btn-sm disabled" aria-disabled="true">Locked</span> }
|
||||
</article>
|
||||
|
||||
@ReviewPlotSynthesis()
|
||||
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
|
||||
<ItemGroup><ProjectReference Include="../../PlotLine/PlotLine.csproj" /></ItemGroup>
|
||||
</Project>
|
||||
48
Tools/LocationSignificanceBackfill/Program.cs
Normal file
48
Tools/LocationSignificanceBackfill/Program.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.Models;
|
||||
|
||||
if (args.Length < 3 || !int.TryParse(args[0], out var bookId) || !int.TryParse(args[1], out var userId))
|
||||
throw new ArgumentException("Usage: <bookId> <userId> <expected database name> [--apply]. Set PLOT_DIRECTOR_SQL_CONNECTION securely. Default: dry run.");
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> {
|
||||
["ConnectionStrings:DefaultConnection"] = Environment.GetEnvironmentVariable("PLOT_DIRECTOR_SQL_CONNECTION") }).Build();
|
||||
var factory = new SqlConnectionFactory(config);
|
||||
using (var connection = factory.CreateConnection())
|
||||
{
|
||||
connection.Open();
|
||||
if (connection.Database != args[2]) throw new InvalidOperationException("Unexpected database; no changes made.");
|
||||
}
|
||||
var pipeline = new StoryIntelligencePipelineRepository(factory);
|
||||
var committed = await pipeline.ListCommittedRunsByBookAsync(bookId, userId);
|
||||
var runs = new StoryIntelligenceResultRepository(factory);
|
||||
var sourceRuns = await runs.ListRunsByBookForUserAsync(bookId, userId);
|
||||
if (sourceRuns.Any(x => x.Status is StoryIntelligenceRunStatuses.Pending or StoryIntelligenceRunStatuses.Running))
|
||||
throw new InvalidOperationException("Analysis is active; wait for completion before backfilling.");
|
||||
var items = committed.Count > 0
|
||||
? committed.Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID, RunID = x.StoryIntelligenceRunID }).ToList()
|
||||
: sourceRuns.Where(x => x.ChapterID.HasValue && x.Status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)
|
||||
.Select(x => new OnboardingStoryIntelligenceBatchItem { ChapterID = x.ChapterID!.Value, RunID = x.StoryIntelligenceRunID }).ToList();
|
||||
var projectId = committed.FirstOrDefault()?.ProjectID ?? sourceRuns.FirstOrDefault()?.ProjectID;
|
||||
if (items.Count == 0 || !projectId.HasValue) throw new InvalidOperationException("No accessible scene analysis for this book.");
|
||||
var batch = new OnboardingStoryIntelligenceBatch { BookID = bookId, UserID = userId, ProjectID = projectId.Value, Items = items };
|
||||
var importer = new StoryIntelligenceLocationImportService(new StoryIntelligenceResultRepository(factory), new LocationRepository(factory),
|
||||
new SceneRepository(factory), null!, NullLogger<StoryIntelligenceLocationImportService>.Instance);
|
||||
var assessments = await importer.BuildSignificanceAsync(batch);
|
||||
var repository = new StoryIntelligenceReviewCandidateRepository(factory);
|
||||
var rows = await repository.ListLocationsAsync(bookId);
|
||||
var missing = rows.Where(x => !assessments.ContainsKey(x.StableKey)).ToList();
|
||||
Console.WriteLine($"Book {bookId}: {rows.Count} persisted locations; {rows.Count - missing.Count} matched to scene evidence; {missing.Count} unmatched.");
|
||||
if (missing.Count > 0) throw new InvalidOperationException("Backfill stopped: candidate identities no longer match the source analysis. No decisions or canonical locations were changed.");
|
||||
foreach (var group in rows.GroupBy(x => assessments[x.StableKey].Classification)) Console.WriteLine($"{group.Key}: {group.Count()}");
|
||||
foreach (var row in rows.OrderByDescending(x => assessments[x.StableKey].Score).Take(12))
|
||||
Console.WriteLine($"{row.DisplayName}: {assessments[row.StableKey].Score} ({assessments[row.StableKey].Classification})");
|
||||
if (args.Contains("--apply"))
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
await repository.BackfillLocationSignificanceAsync(bookId, JsonSerializer.Serialize(rows.Select(x => new { key = x.StableKey, assessment = assessments[x.StableKey] }), options));
|
||||
Console.WriteLine("Saved assessments; identities, decisions and canonical data preserved.");
|
||||
}
|
||||
else Console.WriteLine("Dry run; no changes made.");
|
||||
10
Tools/LocationSignificanceBackfill/README.md
Normal file
10
Tools/LocationSignificanceBackfill/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Location significance backfill
|
||||
|
||||
Reassesses an existing book's persisted location review using saved scene analysis. No AI calls, canonical location changes, decision resets, or candidate regeneration.
|
||||
|
||||
1. Apply `PlotLine/Sql/192_LocationNarrativeSignificance.sql`.
|
||||
2. Build this project.
|
||||
3. Supply `PLOT_DIRECTOR_SQL_CONNECTION` securely and run the DLL with `<bookId> <userId> <expectedDatabaseName>` for a dry run.
|
||||
4. Review the match/classification counts, then repeat with `--apply`.
|
||||
|
||||
The user must have access to the book. All persisted stable keys must match before any assessment is written. Run after analysis finishes. Both commands use stored procedures and the normal contextual resolver. See [design notes](../../docs/location-significance.md) for scoring and limitations.
|
||||
18
docs/location-significance.md
Normal file
18
docs/location-significance.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Location significance
|
||||
|
||||
Audit: the active location import service reads stored scene intelligence, resolves generic references with POV/setting context, normalises aliases, and groups identities using parent-aware keys. Candidate appearances are separate evidence; existing canonical Locations and aliases are consulted before creating proposals. Confidence is the maximum identification confidence. Candidate order was distinct-scene frequency descending, then name. Persisted review candidates retain that order and pending counts drive Review Centre's misleading attention label.
|
||||
|
||||
Implementation: keep resolution, merge keys and import decisions unchanged. Compute an explainable, versioned significance assessment from the existing parsed scene results when preparing candidates. Only the actual scene setting inherits scene-wide metrics, plot, knowledge and relationship signals; other places need location-specific consequential evidence. A peak consequential scene outweighs recurrence, which is capped. Store the assessment in the existing candidate JSON, not new location identity fields. Backfill existing candidates deterministically from saved analysis without AI or resetting decisions. Review GETs read persisted assessments. Actual child-setting evidence rolls up to detected parents (for example a house hosting an event in a room), deduplicated by scene; unrelated mentions do not. Hierarchical ordering ranks sibling subtrees while keeping children with their parents; filtered views include ancestors for context. Review Centre counts identified/significant/uncertain separately from pending import choices.
|
||||
|
||||
Scoring (version 1): use peak scene evidence, not a sum of mentions. Explicit consequential local evidence or scene purpose contributes 50. Exposition verbs such as “reveals” and “discovers” require consequential subject matter or corroboration from a major plot development (40); they do not automatically establish a major event. A major plot development contributes at least 25 even without event vocabulary. Correlated context signals are capped at 36: an actual setting contributes 12, high relevant metrics 10, plot development 4–6, knowledge changes 3 or 12 for consequential knowledge, consequential relationship evidence 10, and POV association 4. Distinctive named identity contributes 4. Cross-chapter recurrence adds at most 8. Scores cap at 100; 40 marks significant. A significant setting used in six scenes across three chapters becomes Primary Setting. Otherwise three scenes marks Recurring; remaining detections are Incidental. Negated/hypothetical event vocabulary does not establish an event. Scene counts are deduplicated. This conservative heuristic reuses structured evidence; it is not a new semantic AI judgement. It is scoped to the analysed book and does not invent cross-book evidence.
|
||||
|
||||
Apply `PlotLine/Sql/192_LocationNarrativeSignificance.sql` before deploying the application. It adds read/backfill procedures and extends Review Centre's summary; it changes no identity tables. Existing imports can be assessed with `Tools/LocationSignificanceBackfill` using `PLOT_DIRECTOR_SQL_CONNECTION` supplied securely:
|
||||
|
||||
```
|
||||
dotnet run --project Tools/LocationSignificanceBackfill -- <bookId> <userId> <expectedDatabaseName>
|
||||
dotnet run --project Tools/LocationSignificanceBackfill -- <bookId> <userId> <expectedDatabaseName> --apply
|
||||
```
|
||||
|
||||
The first command is a dry run. The tool uses the same committed runs (or attached completed analysis fallback) as book resume, verifies database/access and every stable key, and stops before writing if any identity cannot be matched. The apply command modifies assessment JSON only, retaining generation IDs, decisions, merge targets and canonical links. It is repeatable after scoring changes. New candidate generations receive assessments during normal preparation. Missing legacy assessments display “Not yet assessed” and remain available under All detected locations until backfilled. Existing canonical matches still auto-resolve through the established path; significance never changes matching confidence or merge decisions.
|
||||
|
||||
Verification on the existing 308-location development import: all stable keys matched; 2 Primary Settings, 54 Significant Locations, 22 Recurring Locations and 230 Incidental Locations. Ten candidates have ambiguous identity/category/parent evidence. All 308 assessments were backfilled with the identity/decision checksum unchanged. The regression harness passes 353 tests, including contextual naming, consequential versus frequent locations, ordinary exposition, parent grouping, uncertainty, pagination and existing merge behaviour.
|
||||
Loading…
x
Reference in New Issue
Block a user