PlotDirector/PlotLine/Services/LocationSignificanceService.cs

152 lines
11 KiB
C#

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;
}
}