152 lines
11 KiB
C#
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;
|
|
}
|
|
}
|