Connect graphical CCS interviews to cases
This commit is contained in:
@@ -135,6 +135,48 @@ public sealed class CcsCaseTests
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InteractiveInterviewRequiresAReachableAcyclicGraph()
|
||||
{
|
||||
var request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet<string>()));
|
||||
|
||||
request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing";
|
||||
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false;
|
||||
request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }];
|
||||
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
request = ValidImport();
|
||||
request.Cases[0].InitialInterview = ValidInterview();
|
||||
request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true });
|
||||
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
|
||||
Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.InitialInterview = ValidInterview();
|
||||
var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false };
|
||||
|
||||
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal("done", attempt.InterviewNodeId);
|
||||
Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds);
|
||||
Assert.Equal(7, attempt.SimulatedMinute);
|
||||
Assert.True(attempt.OpeningCompleted);
|
||||
Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason");
|
||||
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
||||
{
|
||||
@@ -201,4 +243,14 @@ public sealed class CcsCaseTests
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private static CcsInitialInterview ValidInterview() => new()
|
||||
{
|
||||
StartNodeId = "greeting",
|
||||
Nodes =
|
||||
[
|
||||
new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] },
|
||||
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class CcsCaseRevision
|
||||
public int Difficulty { get; set; } = 2;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public CcsInitialChart InitialChart { get; set; } = new();
|
||||
public CcsInitialInterview InitialInterview { get; set; } = new();
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public List<CcsExamDefinition> Exams { get; set; } = [];
|
||||
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
|
||||
@@ -74,6 +75,27 @@ public class CcsCaseRevision
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CcsInitialInterview
|
||||
{
|
||||
public string StartNodeId { get; set; } = "";
|
||||
public List<CcsDialogueNode> Nodes { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsDialogueNode
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PatientText { get; set; } = "";
|
||||
public bool IsTerminal { get; set; }
|
||||
public List<CcsDialogueChoice> Choices { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsDialogueChoice
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
public string NextNodeId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class CcsInitialChart
|
||||
{
|
||||
public string PatientSummary { get; set; } = "";
|
||||
@@ -148,6 +170,8 @@ public class CcsAttempt
|
||||
public int Revision { get; set; }
|
||||
public int SimulatedMinute { get; set; }
|
||||
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
|
||||
public string InterviewNodeId { get; set; } = "";
|
||||
public List<string> InterviewChoiceIds { get; set; } = [];
|
||||
public string Location { get; set; } = "";
|
||||
public Dictionary<string, string> CurrentVitals { get; set; } = [];
|
||||
public List<CcsAttemptAction> Actions { get; set; } = [];
|
||||
@@ -287,6 +311,7 @@ public class CcsCaseSummary
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int Difficulty { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public bool InteractiveInterviewAvailable { get; set; }
|
||||
}
|
||||
|
||||
public class CcsAttemptResponse
|
||||
@@ -320,11 +345,20 @@ public class CcsAttemptResponse
|
||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public CcsInterviewStateResponse? Interview { get; set; }
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
}
|
||||
|
||||
public class CcsInterviewStateResponse
|
||||
{
|
||||
public string CurrentNodeId { get; set; } = "";
|
||||
public CcsDialogueNode CurrentNode { get; set; } = new();
|
||||
public List<string> VisitedChoiceIds { get; set; } = [];
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public class CcsHistoryItem
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class CcsCaseValidator
|
||||
{
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
|
||||
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
|
||||
|
||||
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
|
||||
{
|
||||
@@ -38,6 +38,7 @@ public static class CcsCaseValidator
|
||||
errors.Add(prefix + "initial patient summary and reason are required");
|
||||
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
||||
errors.Add(prefix + "allowedLocations must include presentingLocation");
|
||||
ValidateInterview(item.InitialInterview, prefix, errors);
|
||||
if (item.IsPublished)
|
||||
{
|
||||
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
|
||||
@@ -90,7 +91,7 @@ public static class CcsCaseValidator
|
||||
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
||||
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
|
||||
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
|
||||
if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
|
||||
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
|
||||
return null;
|
||||
}
|
||||
@@ -119,4 +120,47 @@ public static class CcsCaseValidator
|
||||
return "history and examinations cannot be mixed with order changes in one batch";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
|
||||
{
|
||||
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; }
|
||||
var nodeIds = interview.Nodes.Select(value => value.Id).ToList();
|
||||
if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count)
|
||||
errors.Add(prefix + "interview node ids must be present and unique");
|
||||
var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id))
|
||||
.GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase);
|
||||
if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist");
|
||||
var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList();
|
||||
if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count)
|
||||
errors.Add(prefix + "interview choice ids must be present and globally unique");
|
||||
foreach (var node in interview.Nodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText");
|
||||
if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices");
|
||||
if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices");
|
||||
foreach (var choice in node.Choices)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text");
|
||||
if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId))
|
||||
errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node");
|
||||
}
|
||||
}
|
||||
if (!nodeMap.ContainsKey(interview.StartNodeId)) return;
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
bool Walk(string nodeId)
|
||||
{
|
||||
if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; }
|
||||
if (visited.Contains(nodeId)) return true;
|
||||
visiting.Add(nodeId);
|
||||
if (nodeMap.TryGetValue(nodeId, out var node))
|
||||
foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId);
|
||||
visiting.Remove(nodeId); visited.Add(nodeId); return true;
|
||||
}
|
||||
Walk(interview.StartNodeId);
|
||||
if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId");
|
||||
if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ public sealed class CcsStore
|
||||
CaseVersion = definition.Version,
|
||||
Mode = CcsAttemptModes.Timed,
|
||||
OpeningCompleted = false,
|
||||
InterviewNodeId = definition.InitialInterview.StartNodeId,
|
||||
Location = definition.PresentingLocation,
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
VitalHistory =
|
||||
@@ -227,6 +228,7 @@ public sealed class CcsStore
|
||||
case "acknowledge_opening":
|
||||
attempt.OpeningCompleted = true;
|
||||
break;
|
||||
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
|
||||
case "end_case": EnterFinalOrders(attempt); break;
|
||||
case "exit_case": await CompleteAsync(attempt); break;
|
||||
}
|
||||
@@ -412,6 +414,19 @@ public sealed class CcsStore
|
||||
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
||||
? attempt.VitalHistory
|
||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
||||
CcsInterviewStateResponse? interview = null;
|
||||
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
|
||||
if (currentInterviewNode is not null)
|
||||
{
|
||||
interview = new CcsInterviewStateResponse
|
||||
{
|
||||
CurrentNodeId = currentInterviewNode.Id,
|
||||
CurrentNode = currentInterviewNode,
|
||||
VisitedChoiceIds = attempt.InterviewChoiceIds,
|
||||
Completed = attempt.OpeningCompleted ?? false
|
||||
};
|
||||
}
|
||||
return new CcsAttemptResponse
|
||||
{
|
||||
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
||||
@@ -434,11 +449,29 @@ public sealed class CcsStore
|
||||
}).ToList(),
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Interview = interview,
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
References = completed ? definition.References : []
|
||||
};
|
||||
}
|
||||
|
||||
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.");
|
||||
var node = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The interactive interview is unavailable.");
|
||||
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
|
||||
var next = definition.InitialInterview.Nodes.FirstOrDefault(value =>
|
||||
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
|
||||
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
|
||||
attempt.InterviewChoiceIds.Add(choice.Id);
|
||||
attempt.InterviewNodeId = next.Id;
|
||||
if (next.IsTerminal) attempt.OpeningCompleted = true;
|
||||
}
|
||||
|
||||
public async Task AbandonAsync(CcsAttempt attempt)
|
||||
{
|
||||
attempt.State = CcsAttemptStates.Abandoned;
|
||||
@@ -693,7 +726,8 @@ 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
|
||||
Difficulty = value.Difficulty, Tags = value.Tags,
|
||||
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
|
||||
};
|
||||
internal static int Rank(CcsOrderDefinition order, string query)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user