Add graphical duodenal ulcer interview
This commit is contained in:
@@ -91,6 +91,18 @@ public sealed class CcsCaseTests
|
||||
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
|
||||
{
|
||||
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
|
||||
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
Assert.NotNull(request);
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
||||
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
||||
Assert.Equal(4, request.Cases.Single().InitialInterview.Topics.Count(value => value.Required));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedCaseRequiresReferencesAndKnownOrders()
|
||||
{
|
||||
@@ -210,6 +222,58 @@ public sealed class CcsCaseTests
|
||||
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = TopicInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
|
||||
|
||||
Assert.Equal(["chief_concern"], CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
|
||||
Assert.Equal(["required_history", "optional_history"],
|
||||
CcsStore.CurrentInterviewNode(attempt, definition)!.Choices.Select(value => value.Id));
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
|
||||
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
|
||||
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
|
||||
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
|
||||
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TopicInterviewRejectsPrerequisiteCycles()
|
||||
{
|
||||
var request = ValidImport();
|
||||
request.Cases[0].InitialInterview = TopicInterview();
|
||||
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
|
||||
|
||||
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
|
||||
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseContentComparisonIgnoresRevisionMetadata()
|
||||
{
|
||||
var first = ValidImport().Cases[0];
|
||||
var second = ValidImport().Cases[0];
|
||||
first.Id = "507f1f77bcf86cd799439011";
|
||||
first.Version = 4;
|
||||
first.IsCurrent = false;
|
||||
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
|
||||
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
|
||||
|
||||
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
second.Title = "Changed title";
|
||||
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
||||
{
|
||||
@@ -286,4 +350,23 @@ public sealed class CcsCaseTests
|
||||
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
|
||||
]
|
||||
};
|
||||
|
||||
private static CcsInitialInterview TopicInterview() => new()
|
||||
{
|
||||
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
|
||||
Topics =
|
||||
[
|
||||
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
|
||||
new()
|
||||
{
|
||||
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
|
||||
PrerequisiteTopicIds = ["chief_concern"]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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; } = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user