/* * SPDX-License-Identifier: AGPL-3.0-or-later * Copyright (C) 2025 Sergej Görzen * This file is part of OmiLAXR.xAPI. */ using System; using System.Collections.Generic; using System.Linq; using OmiLAXR.Composers; using OmiLAXR.Extensions; using OmiLAXR.TrackingBehaviours; using OmiLAXR.Types; using OmiLAXR.Utils; using OmiLAXR.xAPI.Extensions; using TinCan; using xAPI.Registry; namespace OmiLAXR.xAPI.Composers { /// /// Represents an xAPI statement for tracking learning experiences. /// Implements the Experience API (xAPI) specification. /// // ReSharper disable InconsistentNaming public sealed class xApiStatement : IStatement // ReSharper restore InconsistentNaming { /// /// Builder class for creating xAPI statements with fluent API pattern. /// public sealed class Builder { internal readonly Author Author; // The statement author/authority internal readonly string Uri; // Base URI for identifiers internal readonly IComposer Composer; /// /// Creates a new Builder with the specified URI and author. /// internal Builder(string uri, Author author, IComposer composer) { Uri = uri; Author = author; Composer = composer; } /// /// Alias for Verb() that provides a more natural language API. /// public PreStatement Does(xAPI_Verb verb) => new PreStatement(this, verb); } public readonly struct PreStatement { internal readonly Builder Builder; internal readonly xAPI_Verb Verb; internal PreStatement(Builder builder, xAPI_Verb verb) { Builder = builder; Verb = verb; } public xApiStatement Activity(xAPI_Activity activity) => new xApiStatement(this, activity); } // Core statement components private readonly Guid _id = Guid.NewGuid(); private Guid? _refId = null; private xAPI_Verb _verb; // The action performed private xAPI_Activity _activity; // The object/activity acted upon private xAPI_Actor _actor; // The person/entity performing the action private List _groupMembers = new List(0); // For group actors private List _teamMembers = new List(0); // For team context private xAPI_Actor? _instructor; // Optional instructor private xAPI_Actor _authority; // Statement authority private xAPI_Actor? _team; // Optional team private string _language = "en"; // Default language code private string _platform = "OmiLAXRv2"; // Platform identifier private InteractionType _interactionType; // Interaction type private List _correctResponses = new List(0); // Correct responses private TimeSpan? _duration; // Extension collections for different parts of the statement private readonly xAPI_Extensions_Activity _activityExtensions; //private readonly xAPI_Extensions_Activity _activityGroup = new xAPI_Extensions_Activity(); //private readonly xAPI_Extensions_Activity _activityParent = new xAPI_Extensions_Activity(); //private readonly xAPI_Extensions_Activity _activityCategory = new xAPI_Extensions_Activity(); private readonly xAPI_Extensions_Context _contextExtensions; private readonly xAPI_Extensions_Result _resultExtensions; // Accessor methods for extensions public xAPI_Extensions_Activity GetActivityExtensions() => _activityExtensions; public xAPI_Extensions_Context GetContextExtensions() => _contextExtensions; public xAPI_Extensions_Result GetResultExtensions() => _resultExtensions; public xAPI_Activity GetActivity() => _activity; // Cached arrays to avoid per-call allocations from ToArray() private xAPI_Actor[] _groupMembersCached = Array.Empty(); private xAPI_Actor[] _teamMembersCached = Array.Empty(); private bool _groupDirty = true; private bool _teamDirty = true; public delegate xAPI_Extensions ExtensionActionDelegate(object value); /// /// Deprecated method for accessing activity extension values. /// [Obsolete("Use 'GetExtensionValue(string)' instead.")] public T? GetActivityExtensionValue(string key, T? defaultValue = null) where T : struct => GetExtensionValue(key, defaultValue); /// /// Retrieves an activity extension value by key with type conversion. /// public T? GetExtensionValue(string key, T? defaultValue = null) where T : struct { // LINQ-free to avoid GC allocations in hot paths foreach (var kvp in _activityExtensions) { if (kvp.Key.Key != key) continue; if (kvp.Value is T typedValue) return typedValue; break; // key found, but type mismatch } return defaultValue; } /// /// Retrieves an extension value by extension object, selecting the appropriate /// extension collection based on the extension type. /// public T? GetValue(xAPI_Extension ext, T? defaultValue = null) where T : struct { #if XAPI_REGISTRY_EXISTS switch (ext.extensionType) { case "result": return GetResultValue(ext, defaultValue); case "context": return GetContextValue(ext, defaultValue); case "activity": return GetExtensionValue(ext, defaultValue); default: throw new ArgumentException($"Extension type '{ext.extensionType}' is not supported."); } #else return null; #endif } #if XAPI_REGISTRY_EXISTS public T? GetValue(ExtensionActionDelegate action, T? defaultValue = null) where T : struct => GetValue(action(null)[0].Key, defaultValue); #else public T? GetValue(ExtensionActionDelegate action, T? defaultValue = null) where T : struct => null; #endif public Guid GetId() => _id; /// /// Retrieves an activity extension value by extension object. /// public T? GetExtensionValue(xAPI_Extension extension, T? defaultValue = null) where T : struct => GetExtensionValue(extension.Key, defaultValue); /// /// Retrieves a context extension value by key with type conversion. /// public T? GetContextValue(string key, T? defaultValue = null) where T : struct { // LINQ-free to avoid GC allocations in hot paths foreach (var kvp in _contextExtensions) { if (kvp.Key.Key != key) continue; if (kvp.Value is T typedValue) return typedValue; break; // key found, but type mismatch } return defaultValue; } /// /// Retrieves a context extension value by extension object. /// public T? GetContextValue(xAPI_Extension extension, T? defaultValue = null) where T : struct => GetContextValue(extension.Key, defaultValue); /// /// Retrieves a result extension value by key with type conversion. /// public T? GetResultValue(string key, T? defaultValue = null) where T : struct { // LINQ-free to avoid GC allocations in hot paths foreach (var kvp in _resultExtensions) { if (kvp.Key.Key != key) continue; if (kvp.Value is T typedValue) return typedValue; break; // key found, but type mismatch } return defaultValue; } /// /// Retrieves a result extension value by extension object. /// public T? GetResultValue(xAPI_Extension extension, T? defaultValue = null) where T : struct => GetResultValue(extension.Key, defaultValue); public bool IsFromComposer() where T : IComposer => GetComposer().GetType() == typeof(T); // Accessors for statement components public xAPI_Actor[] GetGroupMembers() { if (_groupDirty) { _groupMembersCached = _groupMembers.Count == 0 ? Array.Empty() : _groupMembers.ToArray(); _groupDirty = false; } return _groupMembersCached; } public bool IsInGroup => _groupMembers.Count > 0; public xAPI_Verb GetVerb() => _verb; public xAPI_Actor GetActor() => _actor; public xAPI_Actor? GetTeam() => _team; public xAPI_Actor[] GetTeamMembers() { if (_teamDirty) { _teamMembersCached = _teamMembers.Count == 0 ? Array.Empty() : _teamMembers.ToArray(); _teamDirty = false; } return _teamMembersCached; } public string GetLanguage() => _language; public Score GetScore() => _score; public bool? GetSuccess() => _success; public bool? GetCompletion() => _completion; public string GetResponse() => _response; public string GetPlatform() => _platform; public xAPI_Actor? GetInstructor() => _instructor; public xAPI_Actor GetAuthority() => _authority; public Guid? GetRegistration() => _registration; public DateTime? GetTimestamp() => _timestamp; public string GetTimestampString() => _timestamp.ToRfc3339(); public InteractionType GetInteractionType() => _interactionType; public List GetCorrectResponses() => _correctResponses; public TimeSpan? GetDuration() => _duration; public string GetDurationString() => _duration.HasValue ? _duration.Value.ToIso8601() : ""; public Guid? GetRefId() => _refId; /// /// Creation timestamp of this statement object. /// public readonly DateTime CreatedAt = DateTime.Now; // Pipeline tracking information private PipelineInfo _senderPipelineInfo; public IStatement Clone() => new xApiStatement() { _isDiscarded = _isDiscarded, _activity = _activity, _verb = _verb, _actor = _actor, _groupMembers = new List(_groupMembers), _teamMembers = new List(_teamMembers), _instructor = _instructor, _authority = _authority, _team = _team, _language = _language, _platform = _platform, _score = _score, _success = _success, _completion = _completion, _response = _response, _timestamp = _timestamp, _registration = _registration, _uri = _uri, _composer = _composer, _senderPipelineInfo = _senderPipelineInfo, _groupDirty = true, _teamDirty = true } .WithExtension(_activityExtensions) .WithResult(_resultExtensions) .WithContext(_contextExtensions); /// /// Converts the statement to a standardized xAPI JSON format. /// public string ToDataStandardString(string version = null) => ToJsonString(pretty: false, version: version); /// /// Converts the statement to a JSON string, optionally pretty-printed. /// public string ToJsonString(bool pretty = false, string version = null) { return this.ToTinCanStatement().ToJSON(version != null ? Versions[version] : TCAPIVersion.V103, pretty); } public string ToShortString() { return $"[xAPI {_actor.Name} {_verb.Key} {_activity.Key}]"; } /// /// Converts the statement to CSV format. /// public CsvFormat ToCsvFormat(bool flatten = false, string version = null) { // This function could be made more simple by just transforming to JSON and parsing by CsvFormat.FromJson. // But as we need as much performance and control as possible, the transformation is done manually. var origin = flatten ? "/" : GetOrigin(); var formatKey = new Func(s => string.IsNullOrEmpty(origin) || origin == "/" ? s.Replace("file:///", "").Replace('/', '_') : s); var authority = _authority.ToTinCanAgent(); var actor = _actor.ToTinCanAgent(); var verb = _verb.ToTinCanVerb(origin); var activity = _activity.ToTinCanActivity(origin, _activityExtensions, _interactionType, _correctResponses); var context = _contextExtensions.ToTinCanContext(version == null ? GetVersion() : Versions[version], origin, _language, _platform, _instructor, _team, GetTeamMembers(), _registration); var result = _resultExtensions.ToTinCanResult(origin, _score, _completion, _success, _response, _duration); if (flatten) { var flatCsv = new CsvFormat(rowsCapacity: 1); var rowValues = new Dictionary() { { "id", _id }, { "version", _composer.GetDataStandardVersion() }, { "timestamp", GetTimestampString() }, { "origin", GetOrigin() } }; foreach (var kvp in authority.ToJObject().Flatten()) { rowValues["authority_" + formatKey(kvp.Key)] = kvp.Value; } foreach (var kvp in actor.ToJObject().Flatten()) { rowValues["actor_" + formatKey(kvp.Key)] = kvp.Value; } // append verb foreach (var kvp in verb.ToJObject().Flatten()) { rowValues["verb_" + formatKey(kvp.Key)] = kvp.Value; } // append activity foreach (var kvp in activity.ToJObject().Flatten()) { rowValues["object_" + formatKey(kvp.Key)] = kvp.Value; } // append context foreach (var kvp in context.ToJObject().Flatten()) { rowValues["context_" + formatKey(kvp.Key)] = kvp.Value; } // append result foreach (var kvp in result.ToJObject().Flatten()) { rowValues["result_" + formatKey(kvp.Key)] = kvp.Value; } flatCsv.AddRow(rowValues); return flatCsv; } var csv = new CsvFormat(rowsCapacity: 1); csv.AddRow(new Dictionary() { { "id", _id }, { "version", _composer.GetDataStandardVersion() }, { "timestamp", GetTimestampString() }, { "origin", GetOrigin() }, { "authority", formatKey(authority.ToJSON()) }, { "actor", formatKey(actor.ToJSON()) }, { "verb", formatKey(verb.ToJSON()) }, { "object", formatKey(activity.ToJSON()) }, { "context", formatKey(context.ToJSON()) }, { "result", formatKey(result.ToJSON()) }, }); return csv; } /// /// Gets information about the pipeline that sent this statement. /// public PipelineInfo GetSenderPipelineInfo() => _senderPipelineInfo; // Result-related properties private Score _score; private bool? _success; private bool? _completion; private string _response; private DateTime _timestamp; private Guid? _registration; private string _uri; private List _attachments = new List(); // State tracking private bool _isDiscarded; private IComposer _composer; /// /// Gets the origin URI of the statement (alias for GetUri). /// public string GetOrigin() => GetUri(); /// /// Sets the origin URI of the statement (alias for GetUri). /// public void SetOrigin(string origin) => _uri = origin; /// /// Gets the URI of the statement. /// public string GetUri() => _uri; /// /// Sets the composer that created this statement. /// public void SetComposer(IComposer sender) => _composer = sender; /// /// Sets the owner (tracking behavior) of this statement. /// This configures actor information and other actor-related properties. /// public void SetOwner(ITrackingBehaviour trackingBehaviour) { var actor = trackingBehaviour.GetActor(); var instructor = trackingBehaviour.GetInstructor(); _actor = actor.ToXAPIActor(); // Handle group actors if (actor.IsGroupActor) { _groupMembers = ((ActorGroup)actor).GetMembers().ToXAPIActors().ToList(); _groupDirty = true; } else { if (_groupMembers.Count != 0) _groupMembers.Clear(); _groupDirty = true; } // Handle team information if (actor.HasTeam) { _team = actor.team.ToXAPIActor(); _teamMembers = actor.team.GetMembers().ToXAPIActors().ToList(); _teamDirty = true; } else { _team = null; if (_teamMembers.Count != 0) _teamMembers.Clear(); _teamDirty = true; } // Handle instructor information if (instructor) { _instructor = instructor.ToXAPIActor(); } _senderPipelineInfo = new PipelineInfo(actor.Pipeline); } /// /// Gets the composer that created this statement. /// public IComposer GetComposer() => _composer; /// /// Checks if the statement has been discarded. /// public bool IsDiscarded() => _isDiscarded; #region Matching Methods /// /// Checks if both verb and activity match the specified paths. /// public bool MatchPaths(string verbPath, string activityPath, char pathSeperator = '.') => _verb.MatchesPath(verbPath, pathSeperator) && _activity.MatchesPath(activityPath, pathSeperator); /// /// Checks if both verb and activity match the specified keys. /// public bool MatchKeys(string verbKey, string activityKey) => _verb.Key == verbKey && _activity.Key == activityKey; /// /// Checks if the verb matches the specified path. /// public bool HasVerb(string verbPath) => GetVerb().MatchesPath(verbPath); /// /// Checks if the verb matches the specified key. /// public bool HasVerbKey(string verbKey) => GetVerb().Key == verbKey; /// /// Checks if the activity matches the specified path. /// public bool HasActivity(string activityPath) => GetActivity().MatchesPath(activityPath); /// /// Checks if the activity matches the specified key. /// public bool HasActivityKey(string activityKey) => GetActivity().Key == activityKey; /// /// Checks if a context extension with the specified key exists. /// public bool HasContextKey(string contextKey) => _contextExtensions.ContainsKey(contextKey); /// /// Checks if a context extension matching the specified path exists. /// public bool HasContext(string contextPath) => _contextExtensions.ContainsPath(contextPath); /// /// Checks if an activity extension with the specified key exists. /// public bool HasExtensionKey(string extensionKey) => _activityExtensions.ContainsKey(extensionKey); /// /// Checks if an activity extension matching the specified path exists. /// public bool HasExtension(string extensionPath) => _activityExtensions.ContainsPath(extensionPath); /// /// Checks if a result extension with the specified key exists. /// public bool HasResultKey(string resultKey) => _resultExtensions.ContainsKey(resultKey); /// /// Checks if a result extension matching the specified path exists. /// public bool HasResult(string resultPath) => _resultExtensions.ContainsPath(resultPath); #endregion /// /// Sets the activity object and optional activity extensions. /// public xApiStatement ChangeActivity(xAPI_Activity activity, xAPI_Extensions_Activity activityExtensions = null) { _activity = activity; if (activityExtensions != null) _activityExtensions.AddRange(activityExtensions); return this; } public xApiStatement ChangeObject(xAPI_Activity activity, xAPI_Extensions_Activity activityExtensions = null) { _activity = activity; if (activityExtensions != null) _activityExtensions.AddRange(activityExtensions); return this; } /// /// Sets the verb of the statement. /// public xApiStatement ChangeVerb(xAPI_Verb verb) { _verb = verb; return this; } /// /// Sets the URI of the statement. /// public xApiStatement WithUri(string uri) { _uri = uri; return this; } public xApiStatement WithCorrectResponses(List correctResponses) { _correctResponses = correctResponses ?? new List(0); return this; } public xApiStatement WithCorrectResponses(params string[] correctResponses) { _correctResponses = correctResponses == null || correctResponses.Length == 0 ? new List(0) : new List(correctResponses); return this; } /// /// Type of interaction (e.g., choice, fill-in, likert, etc.). /// /// Type of interaction (e.g., choice, fill-in, likert, etc.). /// public xApiStatement WithInteractionType(InteractionType interactionType) { _interactionType = interactionType; return this; } /// /// Deprecated method. Use WithExtension instead. /// [Obsolete("Use WithExtension(xAPI_Extensions_Activity activityExtensions) instead.", true)] public xApiStatement WithActivityExtension(xAPI_Extensions_Activity activityExtensions) => WithExtension(activityExtensions); /// /// Adds activity extensions to the statement. /// public xApiStatement WithExtension(xAPI_Extensions_Activity activityExtensions) { _activityExtensions.AddRange(activityExtensions); return this; } /// /// Adds extensions to the appropriate collection based on extension type. /// public xApiStatement WithValue(xAPI_Extensions extensions) { #if XAPI_REGISTRY_EXISTS switch (extensions.ExtensionType) { case "activity": _activityExtensions.AddRange(extensions); break; case "context": _contextExtensions.AddRange(extensions); break; case "result": _resultExtensions.AddRange(extensions); break; } #endif return this; } /// /// Sets the timestamp of the statement. /// public xApiStatement WithTimestamp(DateTime timestamp) { _timestamp = timestamp; return this; } public xApiStatement WithRef(Guid refId) { _refId = refId; return this; } public xApiStatement WithRef(IStatement statement) { _refId = statement.GetId(); return this; } private static readonly Dictionary Versions = TCAPIVersion.GetSupported(); public TCAPIVersion GetVersion() => Versions[GetComposer().GetDataStandardVersion()]; /// /// Marks the statement as discarded, preventing it from being processed. /// public void Discard() { _isDiscarded = true; } /// /// Sets the actor of the statement directly. Use with caution. /// This overrides actor information that would normally come from the tracking behavior. /// public xApiStatement ByActor(xAPI_Actor actor) { _actor = actor; return this; } /// /// Sets the actor of the statement from an Actor object. Use with caution. /// This overrides actor information that would normally come from the tracking behavior. /// public xApiStatement ByActor(Actor actor) { _actor = actor.ToXAPIActor(); return this; } /// /// Sets the team associated with the statement. /// public xApiStatement WithTeam(xAPI_Actor team) { _team = team; return this; } /// /// Sets the instructor associated with the statement. /// public xApiStatement WithInstructor(xAPI_Actor instructor) { _instructor = instructor; return this; } /// /// Sets the language code for the statement. /// public xApiStatement WithLanguage(string langCode) { _language = langCode; return this; } /// /// Sets the platform identifier for the statement. /// public xApiStatement WithPlatform(string platform) { _platform = platform; return this; } /// /// Sets the registration UUID for the statement. /// public xApiStatement WithRegistration(Guid uuid) { _registration = uuid; return this; } /// /// Removes all group members from the statement. /// public xApiStatement DropGroup() { _groupMembers.Clear(); _groupDirty = true; return this; } /// /// Adds actors to the group members collection. /// public xApiStatement AddToGroup(params xAPI_Actor[] actors) { _groupMembers.AddRange(actors); _groupDirty = true; return this; } /// /// Removes specific actors from the group members collection. /// public xApiStatement RemoveFromGroup(params xAPI_Actor[] actors) { foreach (var actor in actors) { var index = _groupMembers.FindIndex(o => o.Email == actor.Email && o.Name == actor.Name); if (index >= 0) _groupMembers.RemoveAt(index); } _groupDirty = true; return this; } /// /// Removes all team members from the statement. /// public xApiStatement DropTeam() { _teamMembers.Clear(); _teamDirty = true; return this; } /// /// Adds actors to the team members collection. /// public xApiStatement AddToTeam(params xAPI_Actor[] actors) { _teamMembers.AddRange(actors); _teamDirty = true; return this; } /// /// Removes specific actors from the team members collection. /// public xApiStatement RemoveFromTeam(params xAPI_Actor[] actors) { foreach (var actor in actors) { var index = _teamMembers.FindIndex(o => o.Email == actor.Email && o.Name == actor.Name); if (index >= 0) _teamMembers.RemoveAt(index); } _teamDirty = true; return this; } /// /// Sets the authority of the statement to a new actor with the specified name and email. /// public xApiStatement ChangedBy(string name, string email) { _authority = new xAPI_Actor(name, email); return this; } /// /// Removes an activity extension by path. /// public xApiStatement DropExtension(string path, char pathSeparator = '.') { _activityExtensions.Remove(path, pathSeparator); return this; } /// /// Removes an activity extension by key. /// public xApiStatement DropExtensionByKey(string key) { _activityExtensions.RemoveByKey(key); return this; } /// /// Removes all activity extensions. /// public xApiStatement DropExtensions() { _activityExtensions.Clear(); return this; } /// /// Sets the success status of the statement. /// public xApiStatement WithSuccess(bool success) { this._success = success; return this; } /// /// Removes the success status from the statement. /// public xApiStatement DropSuccess() { _success = null; return this; } /// /// Sets the completion status of the statement. /// public xApiStatement WithCompletion(bool completion) { _completion = completion; return this; } public xApiStatement WithDuration(TimeSpan duration) { _duration = duration; return this; } public xApiStatement WithDuration(float durationInSeconds) { _duration = TimeSpan.FromSeconds(durationInSeconds); return this; } public xApiStatement WithDuration(Duration duration) { _duration = duration.ToTimeSpan(); return this; } public xApiStatement AddAttachment(Attachment attachment) { _attachments.Add(attachment); return this; } public xApiStatement ClearAttachments() { _attachments.Clear(); return this; } public IEnumerable GetAttachments() => _attachments; /// /// Removes the completion status from the statement. /// public xApiStatement DropCompletion() { _completion = null; return this; } /// /// Sets the response text of the statement. /// public xApiStatement WithResponse(string response) { this._response = response; return this; } /// /// Removes the response text from the statement. /// public xApiStatement DropResponse() { _response = null; return this; } /// /// Adds context extensions to the statement. /// public xApiStatement WithContext(xAPI_Extensions_Context extensions) { _contextExtensions.AddRange(extensions); return this; } /// /// Removes a context extension by path. /// public xApiStatement DropContext(string path, char pathSeparator = '.') { _contextExtensions.Remove(path, pathSeparator); return this; } /// /// Removes a context extension by key. /// public xApiStatement DropContextKey(string key) { _contextExtensions.RemoveByKey(key); return this; } /// /// Removes all context extensions. /// public xApiStatement DropContexts() { _contextExtensions.Clear(); return this; } /// /// Adds result extensions to the statement. /// public xApiStatement WithResult(xAPI_Extensions_Result extensions) { _resultExtensions.AddRange(extensions); return this; } /// /// Removes a result extension by key. /// public xApiStatement DropResultKey(string key) { _resultExtensions.RemoveByKey(key); return this; } /// /// Removes a result extension by path. /// public xApiStatement DropResult(string path, char pathSeparator = '.') { _resultExtensions.Remove(path, pathSeparator); return this; } /// /// Removes all result extensions. /// public xApiStatement DropResults() { _resultExtensions.Clear(); return this; } /// /// Sets the score information for the statement. /// /// Raw score value /// Minimum possible score /// Maximum possible score (must not be zero) /// Thrown when max is 0 public xApiStatement WithScore(double raw, double? min, double? max) { if (max == 0) throw new ArgumentException("The parameter 'max' is not allowed to be 0."); _score = new Score() { raw = raw, min = min, max = max, scaled = min / max }; return this; } /// /// Removes the score information from the statement. /// public xApiStatement DropScore() { _score = null; return this; } /// /// Creates a new xApiStatement with the specified builder and verb. /// Initializes default values and extension collections. /// private xApiStatement(PreStatement p, xAPI_Activity activity) { _uri = p.Builder.Uri; _composer = p.Builder.Composer; _timestamp = DateTime.Now; _authority = new xAPI_Actor(p.Builder.Author.Name, p.Builder.Author.Email); _verb = p.Verb; _activity = activity; _contextExtensions = new xAPI_Extensions_Context(); _resultExtensions = new xAPI_Extensions_Result(); _activityExtensions = new xAPI_Extensions_Activity(); } private xApiStatement() { // Ensure readonly extension collections exist even for Clone() base instance usage _contextExtensions = new xAPI_Extensions_Context(); _resultExtensions = new xAPI_Extensions_Result(); _activityExtensions = new xAPI_Extensions_Activity(); } /// /// Returns a string representation of the xApiStatement for debugging. /// public override string ToString() => $"[xApiStatement at: {GetTimestampString()}, verb: {_verb}, activity: {_activity}, ext: {_activityExtensions}, ctx: {_contextExtensions}, result: {_resultExtensions}, score: {_score}, success: {_success}, completion: {_completion}]"; } }