diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 401efd1..fd9f928 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -18,7 +18,8 @@ var tests = new (string Name, Action Test)[] ("Location canonical keys merge trivial variants", LocationCanonicalKeysMergeTrivialVariants), ("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects), ("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets), - ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants) + ("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants), + ("Relationship signals map to broad lookup types", RelationshipSignalsMapToBroadTypes) }; foreach (var test in tests) @@ -225,6 +226,18 @@ static void AssetCanonicalKeysMergeTrivialVariants() Assert(AssetCanonicalKey("driving license") == AssetCanonicalKey("driving licence"), "License/licence spelling should merge."); } +static void RelationshipSignalsMapToBroadTypes() +{ + Assert(RelationshipTypeFromSignal("Friend") == "Friend", "Friend should map to Friend."); + Assert(RelationshipTypeFromSignal("driving instructor", "learner makes progress") == "Educational", "Instructor/learner should map to Educational."); + Assert(RelationshipTypeFromSignal("aunt", "family visit") == "Family", "Aunt/family should map to Family."); + Assert(RelationshipTypeFromSignal("boss", "employee at work") == "Professional", "Boss/employee should map to Professional."); + Assert(RelationshipTypeFromSignal("police", "suspect witness interview") == "Authority", "Police/suspect/witness should map to Authority."); + Assert(RelationshipTypeFromSignal("neighbour") == "Neighbour", "Neighbour should map to Neighbour."); + Assert(RelationshipTypeFromSignal("adversarial", "mistrust between them") == "Rival", "Adversarial/mistrust should map to Rival."); + Assert(RelationshipTypeFromSignal("uncertain emotional connection") == "Unknown", "Uncertain signals should map to Unknown."); +} + static bool IsAssetNameCandidate(string name) => InvokePrivateAssetFilter("IsAssetNameCandidate", name); @@ -246,6 +259,15 @@ static bool InvokePrivateAssetFilter(string methodName, string name) return method!.Invoke(null, [name]) is true; } +static string RelationshipTypeFromSignal(string signal, string? evidence = null) +{ + var method = typeof(StoryIntelligenceRelationshipImportService).GetMethod( + "RelationshipTypeFromSignal", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + Assert(method is not null, "RelationshipTypeFromSignal was not found."); + return (string)method!.Invoke(null, [signal, evidence])!; +} + static JsonSerializerOptions JsonOptions() => new() { diff --git a/PlotLine/PlotLine.csproj b/PlotLine/PlotLine.csproj index fade980..d16d8c7 100644 --- a/PlotLine/PlotLine.csproj +++ b/PlotLine/PlotLine.csproj @@ -17,6 +17,10 @@ + + PreserveNewest + PreserveNewest + PreserveNewest PreserveNewest diff --git a/PlotLine/Services/OnboardingStoryIntelligenceService.cs b/PlotLine/Services/OnboardingStoryIntelligenceService.cs index 9b50910..62c33e8 100644 --- a/PlotLine/Services/OnboardingStoryIntelligenceService.cs +++ b/PlotLine/Services/OnboardingStoryIntelligenceService.cs @@ -850,6 +850,7 @@ public sealed class OnboardingStoryIntelligenceBatch public List CharacterDecisions { get; } = []; public List LocationDecisions { get; } = []; public List AssetDecisions { get; } = []; + public List RelationshipSelections { get; } = []; public List RelationshipDecisions { get; } = []; public StoryIntelligenceCharacterImportBatchResult? LastCharacterImportResult { get; set; } public StoryIntelligenceLocationImportBatchResult? LastLocationImportResult { get; set; } @@ -906,6 +907,12 @@ public sealed class OnboardingStoryIntelligenceRelationshipDecision public bool CreatedOrLinked { get; init; } } +public sealed class OnboardingStoryIntelligenceRelationshipSelection +{ + public string Key { get; init; } = string.Empty; + public int RelationshipTypeID { get; init; } +} + public sealed class StoryIntelligenceCharacterImportBatchResult { public int CharactersCreated { get; init; } diff --git a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs index 27dc6e5..a78a5cb 100644 --- a/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs +++ b/PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs @@ -60,6 +60,7 @@ public sealed class StoryPromptRepository( ILogger logger) : IStoryPromptRepository { private const string PromptFolder = "Docs/AI"; + private const string RepositoryPromptFolder = "PlotLine/Docs/AI"; public async Task LoadPromptAsync(string fileName, CancellationToken cancellationToken) { @@ -77,10 +78,10 @@ public sealed class StoryPromptRepository( return cachedPrompt; } - var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName); - if (!File.Exists(promptPath)) + var promptPath = ResolvePromptPath(fileName); + if (promptPath is null) { - throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath); + throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /{PromptFolder}/ or /{RepositoryPromptFolder}/.", Path.Combine(environment.ContentRootPath, PromptFolder, fileName)); } var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken); @@ -93,6 +94,23 @@ public sealed class StoryPromptRepository( logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath); return prompt; } + + private string? ResolvePromptPath(string fileName) + { + var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName); + if (File.Exists(promptPath)) + { + return promptPath; + } + + var repositoryPromptPath = Path.Combine(environment.ContentRootPath, RepositoryPromptFolder, fileName); + if (File.Exists(repositoryPromptPath)) + { + return repositoryPromptPath; + } + + return null; + } } public sealed class StoryPromptBuilder : IStoryPromptBuilder diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 35a11d4..0577d6a 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; @@ -28,6 +29,9 @@ public sealed class StoryIntelligenceRelationshipImportService( public async Task BuildReviewAsync(OnboardingStoryIntelligenceBatch batch) { var data = await BuildCandidateDataAsync(batch); + var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var typeOptions = ToRelationshipTypeOptions(lookupData.RelationshipTypes); + var selectedTypeIds = batch.RelationshipSelections.ToDictionary(selection => selection.Key, selection => selection.RelationshipTypeID, StringComparer.OrdinalIgnoreCase); var decidedKeys = batch.RelationshipDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList(); return new StoryIntelligenceRelationshipReviewViewModel @@ -36,12 +40,14 @@ public sealed class StoryIntelligenceRelationshipImportService( CanImport = visibleCandidates.Count > 0, AlreadyLinkedCount = data.AlreadyLinkedCount, IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0, + RelationshipTypeOptions = typeOptions, Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceRelationshipReviewCandidateViewModel { Key = candidate.Key, CharacterAName = candidate.CharacterAName, CharacterBName = candidate.CharacterBName, RelationshipType = candidate.RelationshipType, + RelationshipTypeID = ResolveRelationshipTypeID(lookupData.RelationshipTypes, selectedTypeIds.GetValueOrDefault(candidate.Key), candidate.RelationshipType), AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(), Confidence = ConfidenceBand(candidate.Confidence), FirstAppearance = candidate.FirstAppearanceNote, @@ -65,6 +71,7 @@ public sealed class StoryIntelligenceRelationshipImportService( var choices = form.Relationships .Where(choice => !string.IsNullOrWhiteSpace(choice.Key)) .ToDictionary(choice => choice.Key, choice => choice, StringComparer.OrdinalIgnoreCase); + SaveRelationshipSelections(batch, choices.Values); if (choices.Count == 0) { if (data.Candidates.Count == 0) @@ -83,6 +90,7 @@ public sealed class StoryIntelligenceRelationshipImportService( } var lookupData = await characters.GetLookupsAsync(batch.ProjectID); + var relationshipTypesById = lookupData.RelationshipTypes.ToDictionary(type => type.RelationshipTypeID); var created = 0; var linkedExisting = 0; var merged = 0; @@ -116,12 +124,17 @@ public sealed class StoryIntelligenceRelationshipImportService( if (!relationshipId.HasValue) { + if (!choice.RelationshipTypeID.HasValue || !relationshipTypesById.ContainsKey(choice.RelationshipTypeID.Value)) + { + return new StoryIntelligenceImportCommitResult { Success = false, Message = "Choose a valid relationship type for each relationship you want to create." }; + } + relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship { ProjectID = batch.ProjectID, CharacterAID = candidate.CharacterAID, CharacterBID = candidate.CharacterBID, - RelationshipTypeID = MatchRelationshipType(lookupData.RelationshipTypes, choice.RelationshipType ?? candidate.RelationshipType), + RelationshipTypeID = choice.RelationshipTypeID.Value, IsPermanent = false, StartSceneID = candidate.Appearances.OrderBy(appearance => appearance.SceneNumber).FirstOrDefault()?.SceneID, EndSceneID = null, @@ -358,7 +371,7 @@ public sealed class StoryIntelligenceRelationshipImportService( characterA.CharacterName, characterB.CharacterID, characterB.CharacterName, - RelationshipTypeFromSignal(relationshipSignal), + RelationshipTypeFromSignal(relationshipSignal, evidence), existingMatch?.CharacterRelationshipID, existingMatch is null ? null : $"{existingMatch.CharacterAName} / {existingMatch.RelationshipTypeName} / {existingMatch.CharacterBName}"); groups[key] = candidate; @@ -366,7 +379,7 @@ public sealed class StoryIntelligenceRelationshipImportService( if ((confidence ?? 0m) >= (candidate.Confidence ?? 0m)) { - candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal); + candidate.RelationshipType = RelationshipTypeFromSignal(relationshipSignal, evidence); } candidate.Confidence = Max(candidate.Confidence, confidence); @@ -459,15 +472,12 @@ public sealed class StoryIntelligenceRelationshipImportService( && !string.IsNullOrWhiteSpace(observation.ObjectName); } - private static int MatchRelationshipType(IReadOnlyList types, string? signal) - { - var normalised = Normalise(signal); - var mapped = RelationshipTypeFromSignal(signal); - return types.FirstOrDefault(type => string.Equals(type.TypeName, mapped, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID - ?? types.FirstOrDefault(type => normalised.Contains(Normalise(type.TypeName), StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID - ?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID - ?? types.First().RelationshipTypeID; - } + private static int ResolveRelationshipTypeID(IReadOnlyList types, int? selectedRelationshipTypeId, string? suggestedType) + => selectedRelationshipTypeId.HasValue && types.Any(type => type.RelationshipTypeID == selectedRelationshipTypeId.Value) + ? selectedRelationshipTypeId.Value + : types.FirstOrDefault(type => string.Equals(type.TypeName, suggestedType, StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID + ?? types.FirstOrDefault(type => string.Equals(type.TypeName, "Unknown", StringComparison.OrdinalIgnoreCase))?.RelationshipTypeID + ?? types.First().RelationshipTypeID; private static int MatchRelationshipState(IReadOnlyList states, string? signal) { @@ -486,27 +496,64 @@ public sealed class StoryIntelligenceRelationshipImportService( ?? states.First().RelationshipStateID; } - private static string RelationshipTypeFromSignal(string? signal) + private static string RelationshipTypeFromSignal(string? signal, string? evidence = null) { - var value = Normalise(signal); - if (value.Contains("mother") || value.Contains("father") || value.Contains("parent")) return "Parent"; - if (value.Contains("daughter") || value.Contains("son") || value.Contains("child")) return "Child"; - if (value.Contains("sister") || value.Contains("brother") || value.Contains("sibling")) return "Sibling"; - if (value.Contains("spouse") || value.Contains("husband") || value.Contains("wife") || value.Contains("married")) return "Spouse"; - if (value.Contains("romantic") || value.Contains("lover") || value.Contains("love")) return "Lover"; - if (value.Contains("friend")) return "Friend"; - if (value.Contains("enemy") || value.Contains("adversarial") || value.Contains("hostile")) return "Enemy"; - if (value.Contains("rival")) return "Rival"; - if (value.Contains("suspicious") || value.Contains("suspect")) return "Suspicious Of"; - if (value.Contains("neighbour") || value.Contains("neighbor")) return "Neighbour"; - if (value.Contains("teacher")) return "Teacher"; - if (value.Contains("student")) return "Student"; - if (value.Contains("employer") || value.Contains("boss")) return "Employer"; - if (value.Contains("employee")) return "Employee"; - if (value.Contains("colleague") || value.Contains("professional") || value.Contains("police") || value.Contains("detective") || value.Contains("sergeant")) return "Colleague"; - if (value.Contains("ally") || value.Contains("support")) return "Ally"; - if (value.Contains("guardian") || value.Contains("protector")) return "Protector"; - if (value.Contains("estranged") || value.Contains("separat")) return "Estranged From"; + var value = NormaliseForMatching($"{signal} {evidence}"); + if (ContainsAny(value, "mother", "father", "sister", "brother", "parent", "daughter", "son", " child ", "aunt", "uncle", "cousin", "family", "sibling", "parent and child")) + { + return "Family"; + } + + if (ContainsAny(value, "romance", "romantic", "lover", "spouse", "husband", "wife", "girlfriend", "boyfriend", "love interest", "former partner", "partner like", "partner-like")) + { + return "Romantic"; + } + + if (ContainsAny(value, "teacher", "student", "instructor", "learner", "mentor", "pupil", "tutor", "driving instructor")) + { + return "Educational"; + } + + if (ContainsAny(value, "police", "detective", "suspect", "witness", "authority", "guard", "prisoner", "investigator", "person of interest")) + { + return "Authority"; + } + + if (ContainsAny(value, "criminal", "accomplice", "offender", "victim", "gang", "conspirator")) + { + return "Criminal"; + } + + if (ContainsAny(value, "doctor", "patient", "nurse", "therapist", "medical")) + { + return "Medical"; + } + + if (ContainsAny(value, "neighbour", "neighbor")) + { + return "Neighbour"; + } + + if (ContainsAny(value, "colleague", "coworker", "co worker", "boss", "employee", "manager", "client", "professional", "work relationship")) + { + return "Professional"; + } + + if (ContainsAny(value, "friend", "friendship", "best friend", "close friend", "childhood friend", "trust between friends", "affection between friends")) + { + return "Friend"; + } + + if (ContainsAny(value, "rival", "enemy", "adversarial", "conflict", "mistrust", "antagonist", "competitor")) + { + return "Rival"; + } + + if (ContainsAny(value, "ally", "protector", "supporter", "confidant", "dependence", "cooperation")) + { + return "Ally"; + } + return "Unknown"; } @@ -541,6 +588,32 @@ public sealed class StoryIntelligenceRelationshipImportService( private static string RelationshipLabel(RelationshipCandidate candidate) => $"{candidate.CharacterAName} / {candidate.RelationshipType} / {candidate.CharacterBName}"; + private static IReadOnlyList ToRelationshipTypeOptions(IEnumerable relationshipTypes) + => relationshipTypes + .OrderBy(type => type.CategorySortOrder) + .ThenBy(type => type.SortOrder) + .ThenBy(type => type.TypeName) + .Select(type => new SelectListItem(type.TypeName, type.RelationshipTypeID.ToString())) + .ToList(); + + private static void SaveRelationshipSelections(OnboardingStoryIntelligenceBatch batch, IEnumerable choices) + { + foreach (var choice in choices) + { + if (string.IsNullOrWhiteSpace(choice.Key) || choice.RelationshipTypeID is not { } relationshipTypeId) + { + continue; + } + + batch.RelationshipSelections.RemoveAll(selection => string.Equals(selection.Key, choice.Key, StringComparison.OrdinalIgnoreCase)); + batch.RelationshipSelections.Add(new OnboardingStoryIntelligenceRelationshipSelection + { + Key = choice.Key, + RelationshipTypeID = relationshipTypeId + }); + } + } + private static string? BuildFirstAppearance(Scene scene, SceneIntelligenceScene parsed) { var summary = Clean(parsed.Summary?.Short); @@ -592,10 +665,17 @@ public sealed class StoryIntelligenceRelationshipImportService( private static string Normalise(string? value) => Clean(value).ToLowerInvariant(); + private static string NormaliseForMatching(string? value) + => $" {NonWord.Replace(Normalise(value), " ")} "; + + private static bool ContainsAny(string value, params string[] needles) + => needles.Any(needle => value.Contains(NormaliseForMatching(needle), StringComparison.Ordinal)); + private static string Clean(string? value) => string.IsNullOrWhiteSpace(value) ? string.Empty : Whitespace.Replace(value, " ").Trim(); private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); + private static readonly Regex NonWord = new(@"[^\p{L}\p{N}]+", RegexOptions.Compiled); private static readonly string[] RelationshipSignalWords = [ diff --git a/PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql b/PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql new file mode 100644 index 0000000..9ba48d4 --- /dev/null +++ b/PlotLine/Sql/132_Phase20AT_RelationshipTypeTaxonomy.sql @@ -0,0 +1,73 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +DECLARE @OtherCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Other' + ORDER BY RelationshipCategoryID +); + +DECLARE @FamilyCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Family' + ORDER BY RelationshipCategoryID +); + +DECLARE @FriendshipCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Friendship' + ORDER BY RelationshipCategoryID +); + +DECLARE @RomanticCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Romantic / Sexual' + ORDER BY RelationshipCategoryID +); + +DECLARE @HostileCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Hostile / Conflict' + ORDER BY RelationshipCategoryID +); + +DECLARE @ProfessionalCategoryID int = ( + SELECT TOP (1) RelationshipCategoryID + FROM dbo.RelationshipCategories + WHERE CategoryName = N'Professional / Social' + ORDER BY RelationshipCategoryID +); + +MERGE dbo.RelationshipTypes AS target +USING (VALUES + (N'Unknown', 0, 1, @OtherCategoryID), + (N'Family', 1, 2, @FamilyCategoryID), + (N'Friend', 0, 3, @FriendshipCategoryID), + (N'Romantic', 0, 4, @RomanticCategoryID), + (N'Professional', 0, 5, @ProfessionalCategoryID), + (N'Educational', 0, 6, @ProfessionalCategoryID), + (N'Authority', 0, 7, @ProfessionalCategoryID), + (N'Criminal', 0, 8, @HostileCategoryID), + (N'Medical', 0, 9, @ProfessionalCategoryID), + (N'Neighbour', 0, 10, @ProfessionalCategoryID), + (N'Ally', 0, 11, @FriendshipCategoryID), + (N'Rival', 0, 12, @HostileCategoryID) +) AS source (TypeName, IsPermanentDefault, SortOrder, RelationshipCategoryID) +ON target.TypeName = source.TypeName +WHEN MATCHED THEN + UPDATE SET + target.IsPermanentDefault = source.IsPermanentDefault, + target.SortOrder = source.SortOrder, + target.IsActive = 1, + target.RelationshipCategoryID = COALESCE(source.RelationshipCategoryID, target.RelationshipCategoryID) +WHEN NOT MATCHED THEN + INSERT (TypeName, IsPermanentDefault, SortOrder, IsActive, RelationshipCategoryID) + VALUES (source.TypeName, source.IsPermanentDefault, source.SortOrder, 1, source.RelationshipCategoryID); +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index c3baaed..cbe79ec 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Models; namespace PlotLine.ViewModels; @@ -333,6 +334,7 @@ public sealed class StoryIntelligenceRelationshipReviewViewModel public bool HasCommittedScenes { get; init; } public int AlreadyLinkedCount { get; init; } public bool IsComplete { get; init; } + public IReadOnlyList RelationshipTypeOptions { get; init; } = []; public IReadOnlyList Candidates { get; init; } = []; } @@ -342,6 +344,7 @@ public sealed class StoryIntelligenceRelationshipReviewCandidateViewModel public string CharacterAName { get; init; } = string.Empty; public string CharacterBName { get; init; } = string.Empty; public string RelationshipType { get; init; } = "Unknown"; + public int RelationshipTypeID { get; init; } public int AppearsInScenes { get; init; } public string Confidence { get; init; } = "Unknown"; public string? FirstAppearance { get; init; } @@ -364,7 +367,7 @@ public sealed class StoryIntelligenceRelationshipImportChoiceForm { public string Key { get; set; } = string.Empty; public string Action { get; set; } = StoryIntelligenceRelationshipImportActions.CreateNew; - public string? RelationshipType { get; set; } + public int? RelationshipTypeID { get; set; } public string? AliasTargetKey { get; set; } } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml index ba97a2a..31bb125 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceRelationships.cshtml @@ -127,7 +127,21 @@
- + +
+ Story Intelligence observation +

@candidate.RelationshipType

+ @if (!string.IsNullOrWhiteSpace(candidate.ExampleContext)) + { + Evidence +

@candidate.ExampleContext

+ } +