Add graphical duodenal ulcer interview

This commit is contained in:
hz
2026-08-05 23:14:51 -05:00
parent 7709d16018
commit 58dd370149
14 changed files with 768 additions and 29 deletions
+2
View File
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
COPY content/ccs/ content/ccs/
WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
COPY --from=build /src/content/ccs ./content/ccs
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+42
View File
@@ -64,6 +64,8 @@ public class CcsCaseRevision
public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = [];
@@ -79,6 +81,39 @@ public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
public string OpeningPatientText { get; set; } = "";
public List<CcsInterviewTopic> Topics { get; set; } = [];
public string FinishChoiceText { get; set; } = "Finish the interview";
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
public CcsInterviewDebrief Debrief { get; set; } = new();
}
public class CcsInterviewTopic
{
public string Id { get; set; } = "";
public string ClinicianText { get; set; } = "";
public string PatientText { get; set; } = "";
public bool Required { get; set; }
public List<string> PrerequisiteTopicIds { get; set; } = [];
}
public class CcsInterviewDebrief
{
public string LikelyDiagnosis { get; set; } = "";
public string Summary { get; set; } = "";
public List<string> TeachingPoints { get; set; } = [];
}
public class CcsGraphicalPresentation
{
public string SceneKey { get; set; } = "";
public string PatientAssetKey { get; set; } = "";
}
public static class CcsExperienceModes
{
public const string Management = "management";
public const string GraphicalInterview = "graphical_interview";
}
public class CcsDialogueNode
@@ -270,6 +305,7 @@ public class CcsImportResponse
{
public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; }
public int CasesSkipped { get; set; }
public List<string> Errors { get; set; } = [];
}
@@ -312,6 +348,9 @@ public class CcsCaseSummary
public int Difficulty { get; set; }
public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
public bool GraphicalPresentationAvailable { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
}
public class CcsAttemptResponse
@@ -332,6 +371,8 @@ public class CcsAttemptResponse
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
public int ElapsedSeconds { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
@@ -346,6 +387,7 @@ public class CcsAttemptResponse
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsInterviewDebrief? InterviewDebrief { get; set; }
public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = [];
+6
View File
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return;
}
if (args.FirstOrDefault() == "promote-ccs-cases")
{
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "export-dynamic-review")
{
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
errors.Add(prefix + "experienceMode is invalid");
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
@@ -73,6 +79,10 @@ public static class CcsCaseValidator
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
if (criterion.ActionType == "dialogue_choice"
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
}
}
}
@@ -123,6 +133,55 @@ public static class CcsCaseValidator
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (interview.Topics.Count > 0)
{
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
errors.Add(prefix + "topic interview requires openingPatientText");
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
var topicIds = interview.Topics.Select(value => value.Id).ToList();
if (topicIds.Any(string.IsNullOrWhiteSpace)
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
errors.Add(prefix + "interview topic ids must be present and unique");
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "finish_interview is a reserved interview topic id");
if (!interview.Topics.Any(value => value.Required))
errors.Add(prefix + "topic interview requires at least one required topic");
foreach (var topic in interview.Topics)
{
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
if (string.IsNullOrWhiteSpace(topic.PatientText))
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
foreach (var prerequisite in topic.PrerequisiteTopicIds)
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
}
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool ResolveTopic(string topicId)
{
if (resolving.Contains(topicId))
{
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
return false;
}
if (resolved.Contains(topicId)) return true;
resolving.Add(topicId);
if (topicMap.TryGetValue(topicId, out var topic))
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
ResolveTopic(prerequisite);
resolving.Remove(topicId);
resolved.Add(topicId);
return true;
}
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
return;
}
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class CcsPromotionCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
? args[sourceIndex + 1]
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
var files = File.Exists(source)
? new[] { source }
: Directory.Exists(source)
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
: [];
if (files.Length == 0)
{
await output.WriteLineAsync($"CCS content not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var request = new CcsImportRequest();
foreach (var file in files)
{
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
request.Orders.AddRange(parsed.Orders);
request.Cases.AddRange(parsed.Cases);
}
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
if (duplicateOrders.Count > 0)
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables().Build();
var mongo = new MongoDatabaseProvider(configuration);
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
if (errors.Count > 0)
{
foreach (var error in errors) await output.WriteLineAsync(error);
return 1;
}
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
foreach (var order in request.Orders)
{
order.Code = order.Code.Trim().ToLowerInvariant();
order.UpdatedUtc = DateTime.UtcNow;
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var unchanged = 0;
foreach (var incoming in request.Cases)
{
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
{
unchanged++;
continue;
}
if (current is not null)
await cases.UpdateOneAsync(value => value.Id == current.Id,
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
incoming.Version = (current?.Version ?? 0) + 1;
incoming.IsCurrent = true;
incoming.CreatedUtc = DateTime.UtcNow;
await cases.InsertOneAsync(incoming);
created++;
}
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
return 0;
}
}
+103 -11
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var skipped = 0;
foreach (var incoming in request.Cases)
{
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
}
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
{
var document = value.ToBsonDocument();
document.Remove("_id");
document.Remove(nameof(CcsCaseRevision.Version));
document.Remove(nameof(CcsCaseRevision.IsCurrent));
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
return document;
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
@@ -109,6 +125,7 @@ public sealed class CcsStore
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt
{
UserId = userId,
@@ -118,7 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = interview.StartNodeId,
InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
@@ -126,9 +143,11 @@ public sealed class CcsStore
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
@@ -416,9 +435,7 @@ public sealed class CcsStore
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var interviewDefinition = EffectiveInterview(definition);
var currentInterviewNode = interviewDefinition.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
@@ -440,9 +457,13 @@ public sealed class CcsStore
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Appearance = definition.InitialChart.Appearance,
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
@@ -452,6 +473,8 @@ public sealed class CcsStore
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
@@ -460,6 +483,11 @@ public sealed class CcsStore
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
{
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
if (definition.InitialInterview.Topics.Count > 0)
{
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
return;
}
var interview = EffectiveInterview(definition);
var node = interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
@@ -730,9 +758,73 @@ public sealed class CcsStore
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = EffectiveInterview(value).Nodes.Count > 0
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
};
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
{
var topicInterview = definition.InitialInterview;
if (topicInterview.Topics.Count > 0)
{
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
return new CcsDialogueNode
{
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
};
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
? topicInterview.OpeningPatientText
: topicInterview.Topics.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
if (string.IsNullOrWhiteSpace(patientText)) return null;
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
&& value.PrerequisiteTopicIds.All(visited.Contains))
.Select(value => new CcsDialogueChoice
{
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
}).ToList();
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
choices.Add(new CcsDialogueChoice
{
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
});
return new CcsDialogueNode
{
Id = attempt.InterviewNodeId, PatientText = patientText, Choices = choices
};
}
var interview = EffectiveInterview(definition);
return interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
}
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
{
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
{
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
throw new ArgumentException("Complete the core interview topics before finishing.");
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add("finish_interview");
attempt.InterviewNodeId = "complete";
attempt.OpeningCompleted = true;
return;
}
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("That interview topic has already been covered.");
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
throw new ArgumentException("That interview topic is not available yet.");
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(topic.Id);
attempt.InterviewNodeId = topic.Id;
}
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
{
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)