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>()));
|
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]
|
[Fact]
|
||||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
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 int Difficulty { get; set; } = 2;
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
public CcsInitialChart InitialChart { get; set; } = new();
|
public CcsInitialChart InitialChart { get; set; } = new();
|
||||||
|
public CcsInitialInterview InitialInterview { get; set; } = new();
|
||||||
public List<string> AllowedLocations { get; set; } = [];
|
public List<string> AllowedLocations { get; set; } = [];
|
||||||
public List<CcsExamDefinition> Exams { get; set; } = [];
|
public List<CcsExamDefinition> Exams { get; set; } = [];
|
||||||
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
|
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
|
||||||
@@ -74,6 +75,27 @@ public class CcsCaseRevision
|
|||||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
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 class CcsInitialChart
|
||||||
{
|
{
|
||||||
public string PatientSummary { get; set; } = "";
|
public string PatientSummary { get; set; } = "";
|
||||||
@@ -148,6 +170,8 @@ public class CcsAttempt
|
|||||||
public int Revision { get; set; }
|
public int Revision { get; set; }
|
||||||
public int SimulatedMinute { get; set; }
|
public int SimulatedMinute { get; set; }
|
||||||
[BsonIgnoreIfNull] public bool? OpeningCompleted { 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 string Location { get; set; } = "";
|
||||||
public Dictionary<string, string> CurrentVitals { get; set; } = [];
|
public Dictionary<string, string> CurrentVitals { get; set; } = [];
|
||||||
public List<CcsAttemptAction> Actions { get; set; } = [];
|
public List<CcsAttemptAction> Actions { get; set; } = [];
|
||||||
@@ -287,6 +311,7 @@ public class CcsCaseSummary
|
|||||||
public int RealTimeMinutes { get; set; }
|
public int RealTimeMinutes { get; set; }
|
||||||
public int Difficulty { get; set; }
|
public int Difficulty { get; set; }
|
||||||
public List<string> Tags { get; set; } = [];
|
public List<string> Tags { get; set; } = [];
|
||||||
|
public bool InteractiveInterviewAvailable { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CcsAttemptResponse
|
public class CcsAttemptResponse
|
||||||
@@ -320,11 +345,20 @@ public class CcsAttemptResponse
|
|||||||
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
public List<CcsPendingResult> PendingResults { get; set; } = [];
|
||||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||||
public List<string> AllowedLocations { get; set; } = [];
|
public List<string> AllowedLocations { get; set; } = [];
|
||||||
|
public CcsInterviewStateResponse? Interview { get; set; }
|
||||||
public CcsCaseScore? Score { get; set; }
|
public CcsCaseScore? Score { get; set; }
|
||||||
public CcsRatingChange? Rating { get; set; }
|
public CcsRatingChange? Rating { get; set; }
|
||||||
public List<QuestionReference> References { 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 class CcsHistoryItem
|
||||||
{
|
{
|
||||||
public string Id { get; set; } = "";
|
public string Id { get; set; } = "";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
|
|||||||
|
|
||||||
public static class CcsCaseValidator
|
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)
|
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");
|
errors.Add(prefix + "initial patient summary and reason are required");
|
||||||
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
|
||||||
errors.Add(prefix + "allowedLocations must include presentingLocation");
|
errors.Add(prefix + "allowedLocations must include presentingLocation");
|
||||||
|
ValidateInterview(item.InitialInterview, prefix, errors);
|
||||||
if (item.IsPublished)
|
if (item.IsPublished)
|
||||||
{
|
{
|
||||||
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
|
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
|
||||||
@@ -90,7 +91,7 @@ public static class CcsCaseValidator
|
|||||||
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
||||||
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
||||||
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
|
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";
|
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -119,4 +120,47 @@ public static class CcsCaseValidator
|
|||||||
return "history and examinations cannot be mixed with order changes in one batch";
|
return "history and examinations cannot be mixed with order changes in one batch";
|
||||||
return null;
|
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,
|
CaseVersion = definition.Version,
|
||||||
Mode = CcsAttemptModes.Timed,
|
Mode = CcsAttemptModes.Timed,
|
||||||
OpeningCompleted = false,
|
OpeningCompleted = false,
|
||||||
|
InterviewNodeId = definition.InitialInterview.StartNodeId,
|
||||||
Location = definition.PresentingLocation,
|
Location = definition.PresentingLocation,
|
||||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||||
VitalHistory =
|
VitalHistory =
|
||||||
@@ -227,6 +228,7 @@ public sealed class CcsStore
|
|||||||
case "acknowledge_opening":
|
case "acknowledge_opening":
|
||||||
attempt.OpeningCompleted = true;
|
attempt.OpeningCompleted = true;
|
||||||
break;
|
break;
|
||||||
|
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
|
||||||
case "end_case": EnterFinalOrders(attempt); break;
|
case "end_case": EnterFinalOrders(attempt); break;
|
||||||
case "exit_case": await CompleteAsync(attempt); break;
|
case "exit_case": await CompleteAsync(attempt); break;
|
||||||
}
|
}
|
||||||
@@ -412,6 +414,19 @@ public sealed class CcsStore
|
|||||||
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
|
||||||
? attempt.VitalHistory
|
? attempt.VitalHistory
|
||||||
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
|
: [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
|
return new CcsAttemptResponse
|
||||||
{
|
{
|
||||||
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
|
||||||
@@ -434,11 +449,29 @@ public sealed class CcsStore
|
|||||||
}).ToList(),
|
}).ToList(),
|
||||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||||
|
Interview = interview,
|
||||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||||
References = completed ? definition.References : []
|
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)
|
public async Task AbandonAsync(CcsAttempt attempt)
|
||||||
{
|
{
|
||||||
attempt.State = CcsAttemptStates.Abandoned;
|
attempt.State = CcsAttemptStates.Abandoned;
|
||||||
@@ -693,7 +726,8 @@ public sealed class CcsStore
|
|||||||
{
|
{
|
||||||
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
||||||
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
|
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)
|
internal static int Rank(CcsOrderDefinition order, string query)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,61 @@
|
|||||||
"simulatedStartUtc": "2026-07-15T08:00:00Z",
|
"simulatedStartUtc": "2026-07-15T08:00:00Z",
|
||||||
"initialOrderCodes": []
|
"initialOrderCodes": []
|
||||||
},
|
},
|
||||||
|
"initialInterview": {
|
||||||
|
"startNodeId": "greeting",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "greeting",
|
||||||
|
"patientText": "Doctor, I feel awful. I can barely keep my eyes open.",
|
||||||
|
"isTerminal": false,
|
||||||
|
"choices": [
|
||||||
|
{ "id": "ask_chief_concern", "text": "Tell me what brought you in today.", "nextNodeId": "chief_concern" },
|
||||||
|
{ "id": "ask_current_problem", "text": "What has been happening recently?", "nextNodeId": "chief_concern" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chief_concern",
|
||||||
|
"patientText": "I have been vomiting for two days, my stomach hurts, and I have become more and more tired.",
|
||||||
|
"isTerminal": false,
|
||||||
|
"choices": [
|
||||||
|
{ "id": "ask_insulin_use", "text": "Have you been taking your insulin?", "nextNodeId": "insulin_history" },
|
||||||
|
{ "id": "ask_diabetes_treatment", "text": "How have you managed your diabetes during this illness?", "nextNodeId": "insulin_history" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "insulin_history",
|
||||||
|
"patientText": "I stopped all of my insulin about a day and a half ago because I was not eating.",
|
||||||
|
"isTerminal": false,
|
||||||
|
"choices": [
|
||||||
|
{ "id": "ask_associated_symptoms", "text": "Have you noticed any other symptoms?", "nextNodeId": "associated_symptoms" },
|
||||||
|
{ "id": "ask_infection_symptoms", "text": "Any fever, cough, urinary symptoms, or diarrhea?", "nextNodeId": "associated_symptoms" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "associated_symptoms",
|
||||||
|
"patientText": "No cough, burning with urination, or diarrhea. I do not think I have had a fever.",
|
||||||
|
"isTerminal": false,
|
||||||
|
"choices": [
|
||||||
|
{ "id": "ask_medical_history", "text": "Tell me about your medical history and medications.", "nextNodeId": "background" },
|
||||||
|
{ "id": "ask_allergies", "text": "Do you have kidney problems, take other medicines, or have medication allergies?", "nextNodeId": "background" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "background",
|
||||||
|
"patientText": "I have type 1 diabetes and normally use long-acting and mealtime insulin. I have no kidney disease and no known drug allergies.",
|
||||||
|
"isTerminal": false,
|
||||||
|
"choices": [
|
||||||
|
{ "id": "finish_interview", "text": "Thank you. I am going to examine you and begin your evaluation.", "nextNodeId": "interview_complete" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "interview_complete",
|
||||||
|
"patientText": "Okay, doctor.",
|
||||||
|
"isTerminal": true,
|
||||||
|
"choices": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"allowedLocations": ["emergency_department", "icu", "ward"],
|
"allowedLocations": ["emergency_department", "icu", "ward"],
|
||||||
"exams": [
|
"exams": [
|
||||||
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
|
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate
|
|||||||
## Case structure
|
## Case structure
|
||||||
|
|
||||||
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
|
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
|
||||||
|
- `initialInterview` optionally defines the graphical office interview. It contains a `startNodeId` and an acyclic set of dialogue nodes. Each node has stable `id`, authored `patientText`, `isTerminal`, and clinician `choices` with globally unique IDs and valid `nextNodeId` references. Every branch must reach a terminal node.
|
||||||
- `allowedLocations` controls patient movement.
|
- `allowedLocations` controls patient movement.
|
||||||
- `exams` defines selectable examination components and their simulated duration.
|
- `exams` defines selectable examination components and their simulated duration.
|
||||||
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
|
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
|
||||||
@@ -23,4 +24,6 @@ Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progr
|
|||||||
|
|
||||||
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
|
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
|
||||||
|
|
||||||
|
Graphical interview choices are persisted as attempt actions and support reconnect/resume. They consume real deadline time but do not advance simulated time or contribute directly to scoring. Cases without `initialInterview` remain available in classic CCS and are omitted from CCS Cases 2.
|
||||||
|
|
||||||
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
|
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ var queue: Array = []
|
|||||||
var text_scale := 1.0
|
var text_scale := 1.0
|
||||||
var highlights := {}
|
var highlights := {}
|
||||||
var request_access_token := ""
|
var request_access_token := ""
|
||||||
|
var launch_origin := "ccs"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -77,6 +78,10 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void:
|
|||||||
"minutes": minutes
|
"minutes": minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
func send_dialogue_choice(choice_id: String) -> void:
|
||||||
|
send_command("dialogue_choice", choice_id)
|
||||||
|
|
||||||
func send_batch(commands: Array) -> void:
|
func send_batch(commands: Array) -> void:
|
||||||
if attempt.is_empty() or commands.is_empty():
|
if attempt.is_empty() or commands.is_empty():
|
||||||
return
|
return
|
||||||
@@ -272,6 +277,7 @@ func _escape_bbcode(value: String) -> String:
|
|||||||
|
|
||||||
func _on_auth_changed() -> void:
|
func _on_auth_changed() -> void:
|
||||||
if not AuthState.is_logged_in:
|
if not AuthState.is_logged_in:
|
||||||
|
launch_origin = "ccs"
|
||||||
highlights.clear()
|
highlights.clear()
|
||||||
text_scale = 1.0
|
text_scale = 1.0
|
||||||
attempt = {}
|
attempt = {}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
signal dialogue_choice_selected(index: int, text: String)
|
signal choice_selected(choice_id: String)
|
||||||
|
signal interview_continue_requested
|
||||||
|
|
||||||
const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png")
|
const CHOICE_NORMAL := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png")
|
||||||
const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png")
|
const CHOICE_HOVER := preload("res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png")
|
||||||
@@ -22,11 +23,8 @@ var dialogue_tween: Tween
|
|||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_update_clock()
|
_update_clock()
|
||||||
_schedule_patient_blink()
|
_schedule_patient_blink()
|
||||||
show_patient_dialogue("Hello, doctor.")
|
show_patient_dialogue("Loading patient interview...")
|
||||||
show_dialogue_choices([
|
hide_dialogue_choices()
|
||||||
"What brings you in today?",
|
|
||||||
"How are you feeling today?",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
@@ -91,12 +89,28 @@ func _resize_dialogue_bubble() -> void:
|
|||||||
dialogue_bubble.size.y = target_height
|
dialogue_bubble.size.y = target_height
|
||||||
|
|
||||||
|
|
||||||
func show_dialogue_choices(options: Array[String]) -> void:
|
func configure_interview(node: Dictionary) -> void:
|
||||||
|
show_patient_dialogue(str(node.get("patientText", "")))
|
||||||
|
if bool(node.get("isTerminal", false)):
|
||||||
|
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
|
||||||
|
else:
|
||||||
|
show_dialogue_choices(node.get("choices", []))
|
||||||
|
|
||||||
|
|
||||||
|
func set_choices_pending(pending: bool) -> void:
|
||||||
|
for child in choice_container.get_children():
|
||||||
|
if child is BaseButton:
|
||||||
|
child.disabled = pending
|
||||||
|
|
||||||
|
|
||||||
|
func show_dialogue_choices(options: Array) -> void:
|
||||||
hide_dialogue_choices()
|
hide_dialogue_choices()
|
||||||
var stack_height := options.size() * 97.0 + 8.0
|
var stack_height := options.size() * 97.0 + 8.0
|
||||||
choice_container.offset_top = -4.0 - stack_height
|
choice_container.offset_top = -4.0 - stack_height
|
||||||
for index in options.size():
|
for index in options.size():
|
||||||
var option_text := options[index]
|
var option: Dictionary = options[index]
|
||||||
|
var choice_id := str(option.get("id", ""))
|
||||||
|
var option_text := str(option.get("text", ""))
|
||||||
var button := TextureButton.new()
|
var button := TextureButton.new()
|
||||||
button.custom_minimum_size = Vector2(420.0, 105.0)
|
button.custom_minimum_size = Vector2(420.0, 105.0)
|
||||||
button.ignore_texture_size = true
|
button.ignore_texture_size = true
|
||||||
@@ -111,7 +125,7 @@ func show_dialogue_choices(options: Array[String]) -> void:
|
|||||||
button.mouse_exited.connect(_on_choice_hover.bind(button, false))
|
button.mouse_exited.connect(_on_choice_hover.bind(button, false))
|
||||||
button.button_down.connect(_on_choice_pressed_visual.bind(button, true))
|
button.button_down.connect(_on_choice_pressed_visual.bind(button, true))
|
||||||
button.button_up.connect(_on_choice_pressed_visual.bind(button, false))
|
button.button_up.connect(_on_choice_pressed_visual.bind(button, false))
|
||||||
button.pressed.connect(_on_dialogue_choice_pressed.bind(index, option_text))
|
button.pressed.connect(_on_dialogue_choice_pressed.bind(choice_id))
|
||||||
|
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
@@ -156,6 +170,8 @@ func _on_choice_pressed_visual(button: TextureButton, pressed: bool) -> void:
|
|||||||
button.position.y += 2.0 if pressed else -2.0
|
button.position.y += 2.0 if pressed else -2.0
|
||||||
|
|
||||||
|
|
||||||
func _on_dialogue_choice_pressed(index: int, option_text: String) -> void:
|
func _on_dialogue_choice_pressed(choice_id: String) -> void:
|
||||||
dialogue_choice_selected.emit(index, option_text)
|
if choice_id == "__continue":
|
||||||
hide_dialogue_choices()
|
interview_continue_requested.emit()
|
||||||
|
else:
|
||||||
|
choice_selected.emit(choice_id)
|
||||||
|
|||||||
@@ -1,34 +1,232 @@
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
|
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
|
||||||
const DESIGN_SIZE := Vector2(1500.0, 800.0)
|
const DESIGN_SIZE := Vector2(1500.0, 800.0)
|
||||||
|
|
||||||
|
var main
|
||||||
var stage_root: Control
|
var stage_root: Control
|
||||||
var active_scene: Control
|
var active_scene: Control
|
||||||
|
var selection_view: VBoxContainer
|
||||||
|
var case_list: VBoxContainer
|
||||||
|
var status_label: Label
|
||||||
|
var retry_button: Button
|
||||||
var current_view := ""
|
var current_view := ""
|
||||||
|
var cases_loaded := false
|
||||||
|
var active_loaded := false
|
||||||
|
var pending_action := ""
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
clip_contents = true
|
clip_contents = true
|
||||||
|
_build_selection_view()
|
||||||
stage_root = Control.new()
|
stage_root = Control.new()
|
||||||
stage_root.name = "GraphicalCcsStage"
|
stage_root.name = "GraphicalCcsStage"
|
||||||
stage_root.size = DESIGN_SIZE
|
stage_root.size = DESIGN_SIZE
|
||||||
|
stage_root.visible = false
|
||||||
add_child(stage_root)
|
add_child(stage_root)
|
||||||
|
_connect_service()
|
||||||
resized.connect(_fit_stage)
|
resized.connect(_fit_stage)
|
||||||
_fit_stage()
|
_fit_stage()
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_service() -> void:
|
||||||
|
if not CCSService.cases_changed.is_connected(_on_cases_changed):
|
||||||
|
CCSService.cases_changed.connect(_on_cases_changed)
|
||||||
|
if not CCSService.attempt_changed.is_connected(_on_attempt_changed):
|
||||||
|
CCSService.attempt_changed.connect(_on_attempt_changed)
|
||||||
|
if not CCSService.request_failed.is_connected(_on_request_failed):
|
||||||
|
CCSService.request_failed.connect(_on_request_failed)
|
||||||
|
|
||||||
|
|
||||||
func on_show(_payload: Variant = null) -> void:
|
func on_show(_payload: Variant = null) -> void:
|
||||||
_show_doctor_office()
|
CCSService.launch_origin = "ccs_cases_2"
|
||||||
|
cases_loaded = false
|
||||||
|
active_loaded = false
|
||||||
|
pending_action = "loading"
|
||||||
|
_show_selection("Loading interactive cases...")
|
||||||
|
CCSService.load_cases()
|
||||||
|
CCSService.load_active()
|
||||||
|
|
||||||
|
|
||||||
func on_hide() -> void:
|
func on_hide() -> void:
|
||||||
_clear_active_scene()
|
_clear_active_scene()
|
||||||
|
|
||||||
|
|
||||||
func _show_doctor_office() -> void:
|
func compatible_cases(cases: Array) -> Array:
|
||||||
|
return cases.filter(func(item: Variant) -> bool:
|
||||||
|
return typeof(item) == TYPE_DICTIONARY and bool(item.get("interactiveInterviewAvailable", false)))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_cases_changed() -> void:
|
||||||
|
cases_loaded = true
|
||||||
|
_render_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_attempt_changed() -> void:
|
||||||
|
active_loaded = true
|
||||||
|
pending_action = ""
|
||||||
|
_render_state()
|
||||||
|
|
||||||
|
|
||||||
|
func _render_state() -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
if not active_loaded:
|
||||||
|
return
|
||||||
|
var attempt: Dictionary = CCSService.attempt
|
||||||
|
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
|
||||||
|
_render_attempt(attempt)
|
||||||
|
elif cases_loaded and active_loaded:
|
||||||
|
_render_case_selection()
|
||||||
|
|
||||||
|
|
||||||
|
func _render_attempt(attempt: Dictionary) -> void:
|
||||||
|
if str(attempt.get("state", "")) != "active":
|
||||||
|
_handoff_to_classic_player()
|
||||||
|
return
|
||||||
|
var interview: Dictionary = attempt.get("interview", {})
|
||||||
|
if interview.is_empty():
|
||||||
|
_handoff_to_classic_player()
|
||||||
|
return
|
||||||
|
var node: Dictionary = interview.get("currentNode", {})
|
||||||
|
if node.is_empty():
|
||||||
|
_handoff_to_classic_player()
|
||||||
|
return
|
||||||
|
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
|
||||||
|
_handoff_to_classic_player()
|
||||||
|
return
|
||||||
|
_show_doctor_office(node)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_selection_view() -> void:
|
||||||
|
selection_view = VBoxContainer.new()
|
||||||
|
selection_view.name = "CaseSelection"
|
||||||
|
selection_view.add_theme_constant_override("separation", 16)
|
||||||
|
add_child(selection_view)
|
||||||
|
selection_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
var heading := Label.new()
|
||||||
|
heading.text = "Choose an Interactive CCS Case"
|
||||||
|
Brand.apply_font(heading)
|
||||||
|
heading.add_theme_font_size_override("font_size", 28)
|
||||||
|
selection_view.add_child(heading)
|
||||||
|
var description := Label.new()
|
||||||
|
description.text = "Begin a timed patient interview, then continue into clinical management."
|
||||||
|
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Brand.apply_font(description)
|
||||||
|
description.add_theme_font_size_override("font_size", 16)
|
||||||
|
selection_view.add_child(description)
|
||||||
|
status_label = Label.new()
|
||||||
|
status_label.name = "CaseSelectionStatus"
|
||||||
|
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
Brand.apply_font(status_label)
|
||||||
|
selection_view.add_child(status_label)
|
||||||
|
retry_button = Button.new()
|
||||||
|
retry_button.text = "Retry"
|
||||||
|
retry_button.visible = false
|
||||||
|
retry_button.custom_minimum_size = Vector2(120, 42)
|
||||||
|
retry_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||||
|
Style.style_primary_button(retry_button)
|
||||||
|
retry_button.pressed.connect(_retry_load)
|
||||||
|
selection_view.add_child(retry_button)
|
||||||
|
var scroll := ScrollContainer.new()
|
||||||
|
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
selection_view.add_child(scroll)
|
||||||
|
case_list = VBoxContainer.new()
|
||||||
|
case_list.name = "InteractiveCaseList"
|
||||||
|
case_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
case_list.add_theme_constant_override("separation", 10)
|
||||||
|
scroll.add_child(case_list)
|
||||||
|
|
||||||
|
|
||||||
|
func _show_selection(message: String = "") -> void:
|
||||||
|
_clear_active_scene()
|
||||||
|
current_view = "case_selection"
|
||||||
|
selection_view.visible = true
|
||||||
|
stage_root.visible = false
|
||||||
|
status_label.text = message
|
||||||
|
retry_button.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _render_case_selection() -> void:
|
||||||
|
_show_selection()
|
||||||
|
_clear_children(case_list)
|
||||||
|
var available := compatible_cases(CCSService.cases)
|
||||||
|
if available.is_empty():
|
||||||
|
status_label.text = "No published CCS cases currently include an interactive interview."
|
||||||
|
return
|
||||||
|
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
|
||||||
|
retry_button.visible = false
|
||||||
|
for raw_case in available:
|
||||||
|
var case: Dictionary = raw_case
|
||||||
|
var button := Button.new()
|
||||||
|
button.name = "Case%s" % str(case.get("externalId", "")).to_pascal_case()
|
||||||
|
button.text = "%s\n%s · Difficulty %d · %d minutes" % [
|
||||||
|
str(case.get("title", "Untitled case")), str(case.get("system", "")),
|
||||||
|
int(case.get("difficulty", 0)), int(case.get("realTimeMinutes", 0))]
|
||||||
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
button.custom_minimum_size = Vector2(0, 76)
|
||||||
|
Style.style_answer_button(button)
|
||||||
|
button.pressed.connect(_start_case.bind(str(case.get("externalId", ""))))
|
||||||
|
case_list.add_child(button)
|
||||||
|
|
||||||
|
|
||||||
|
func _start_case(case_external_id: String) -> void:
|
||||||
|
if not pending_action.is_empty() or case_external_id.is_empty():
|
||||||
|
return
|
||||||
|
pending_action = "creating"
|
||||||
|
status_label.text = "Starting case..."
|
||||||
|
_set_case_buttons_disabled(true)
|
||||||
|
CCSService.create_attempt(case_external_id, "timed")
|
||||||
|
|
||||||
|
|
||||||
|
func _show_doctor_office(node: Dictionary) -> void:
|
||||||
|
if active_scene == null:
|
||||||
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
|
||||||
|
active_scene.choice_selected.connect(_submit_dialogue_choice)
|
||||||
|
active_scene.interview_continue_requested.connect(_handoff_to_classic_player)
|
||||||
|
selection_view.visible = false
|
||||||
|
stage_root.visible = true
|
||||||
|
active_scene.configure_interview(node)
|
||||||
|
active_scene.set_choices_pending(not pending_action.is_empty())
|
||||||
|
|
||||||
|
|
||||||
|
func _submit_dialogue_choice(choice_id: String) -> void:
|
||||||
|
if not pending_action.is_empty() or choice_id.is_empty():
|
||||||
|
return
|
||||||
|
pending_action = "dialogue"
|
||||||
|
active_scene.set_choices_pending(true)
|
||||||
|
CCSService.send_dialogue_choice(choice_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _handoff_to_classic_player() -> void:
|
||||||
|
current_view = "handoff"
|
||||||
|
if main != null:
|
||||||
|
main.show_screen("ccs_player")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_request_failed(message: String) -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
var failed_action := pending_action
|
||||||
|
pending_action = ""
|
||||||
|
if failed_action == "creating" and message.contains("409"):
|
||||||
|
status_label.text = "An active case was found. Loading it now..."
|
||||||
|
CCSService.load_active()
|
||||||
|
return
|
||||||
|
if active_scene != null:
|
||||||
|
active_scene.set_choices_pending(false)
|
||||||
|
else:
|
||||||
|
status_label.text = message
|
||||||
|
retry_button.visible = failed_action == "loading"
|
||||||
|
_set_case_buttons_disabled(false)
|
||||||
|
|
||||||
|
|
||||||
|
func _retry_load() -> void:
|
||||||
|
on_show()
|
||||||
|
|
||||||
|
|
||||||
func _set_active_scene(scene: Control, view_name: String) -> void:
|
func _set_active_scene(scene: Control, view_name: String) -> void:
|
||||||
@@ -45,9 +243,21 @@ func _clear_active_scene() -> void:
|
|||||||
active_scene.get_parent().remove_child(active_scene)
|
active_scene.get_parent().remove_child(active_scene)
|
||||||
active_scene.queue_free()
|
active_scene.queue_free()
|
||||||
active_scene = null
|
active_scene = null
|
||||||
|
if current_view == "doctor_office":
|
||||||
current_view = ""
|
current_view = ""
|
||||||
|
|
||||||
|
|
||||||
|
func _set_case_buttons_disabled(disabled: bool) -> void:
|
||||||
|
for child in case_list.get_children():
|
||||||
|
if child is BaseButton:
|
||||||
|
child.disabled = disabled
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_children(parent: Node) -> void:
|
||||||
|
for child in parent.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _fit_stage() -> void:
|
func _fit_stage() -> void:
|
||||||
if stage_root == null:
|
if stage_root == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ func _connect_services() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func on_show(_payload: Variant = null) -> void:
|
func on_show(_payload: Variant = null) -> void:
|
||||||
|
CCSService.launch_origin = "ccs"
|
||||||
pending_action = ""
|
pending_action = ""
|
||||||
pending_attempt_id = ""
|
pending_attempt_id = ""
|
||||||
status_label.text = "Loading case history..."
|
status_label.text = "Loading case history..."
|
||||||
|
|||||||
@@ -820,7 +820,7 @@ func _confirm_abandon() -> void:
|
|||||||
func _on_abandoned() -> void:
|
func _on_abandoned() -> void:
|
||||||
if leaving:
|
if leaving:
|
||||||
leaving = false
|
leaving = false
|
||||||
main.show_screen("ccs_hub")
|
main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub")
|
||||||
|
|
||||||
|
|
||||||
func _render_chart() -> void:
|
func _render_chart() -> void:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func _ready() -> void:
|
|||||||
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
|
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
|
||||||
var row := HBoxContainer.new(); add_child(row)
|
var row := HBoxContainer.new(); add_child(row)
|
||||||
var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases)
|
var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases)
|
||||||
var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(back)
|
var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(_back_to_cases); row.add_child(back)
|
||||||
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
||||||
CCSService.request_failed.connect(_request_failed)
|
CCSService.request_failed.connect(_request_failed)
|
||||||
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
|
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
@@ -156,11 +156,18 @@ func _refresh_loaded_attempt() -> void:
|
|||||||
if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
|
if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
|
||||||
|
|
||||||
func _another_case() -> void:
|
func _another_case() -> void:
|
||||||
|
if CCSService.launch_origin == "ccs_cases_2":
|
||||||
|
main.show_screen("ccs_cases_2")
|
||||||
|
return
|
||||||
if starting_case: return
|
if starting_case: return
|
||||||
starting_case = true
|
starting_case = true
|
||||||
status.text = "Loading a random timed case..."
|
status.text = "Loading a random timed case..."
|
||||||
CCSService.create_attempt("", "timed")
|
CCSService.create_attempt("", "timed")
|
||||||
|
|
||||||
|
|
||||||
|
func _back_to_cases() -> void:
|
||||||
|
main.show_screen("ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs_hub")
|
||||||
|
|
||||||
func _request_failed(message: String) -> void:
|
func _request_failed(message: String) -> void:
|
||||||
if not visible or not starting_case: return
|
if not visible or not starting_case: return
|
||||||
starting_case = false
|
starting_case = false
|
||||||
|
|||||||
+4
-1
@@ -345,6 +345,7 @@ func _build_ui() -> void:
|
|||||||
if FeatureFlags.ccs_cases_2_enabled():
|
if FeatureFlags.ccs_cases_2_enabled():
|
||||||
var ccs_cases_2 := preload("res://scripts/ui/ccs_cases_2_screen.gd").new()
|
var ccs_cases_2 := preload("res://scripts/ui/ccs_cases_2_screen.gd").new()
|
||||||
ccs_cases_2.name = "CcsCases2"
|
ccs_cases_2.name = "CcsCases2"
|
||||||
|
ccs_cases_2.main = self
|
||||||
screens_container.add_child(ccs_cases_2)
|
screens_container.add_child(ccs_cases_2)
|
||||||
screens["ccs_cases_2"] = ccs_cases_2
|
screens["ccs_cases_2"] = ccs_cases_2
|
||||||
|
|
||||||
@@ -501,7 +502,9 @@ func _on_navigation_pressed(key: String) -> void:
|
|||||||
"question_bank": show_screen("selection", "question_bank")
|
"question_bank": show_screen("selection", "question_bank")
|
||||||
"dynamic_questions": show_screen("dynamic_questions")
|
"dynamic_questions": show_screen("dynamic_questions")
|
||||||
"exams": show_screen("simulation_history")
|
"exams": show_screen("simulation_history")
|
||||||
"ccs": show_screen("ccs_hub")
|
"ccs":
|
||||||
|
CCSService.launch_origin = "ccs"
|
||||||
|
show_screen("ccs_hub")
|
||||||
"ccs_cases_2": show_screen("ccs_cases_2")
|
"ccs_cases_2": show_screen("ccs_cases_2")
|
||||||
"flashcards": show_screen("flashcards_study")
|
"flashcards": show_screen("flashcards_study")
|
||||||
"performance": show_screen("profile")
|
"performance": show_screen("profile")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
elif screen_name == "ccs_cases_2":
|
elif screen_name == "ccs_cases_2":
|
||||||
key = "ccs_cases_2"
|
key = "ccs_cases_2"
|
||||||
elif screen_name.begins_with("ccs_"):
|
elif screen_name.begins_with("ccs_"):
|
||||||
key = "ccs"
|
key = "ccs_cases_2" if CCSService.launch_origin == "ccs_cases_2" else "ccs"
|
||||||
elif screen_name.begins_with("flashcards_"):
|
elif screen_name.begins_with("flashcards_"):
|
||||||
key = "flashcards"
|
key = "flashcards"
|
||||||
elif screen_name == "profile":
|
elif screen_name == "profile":
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ func _select_question_bank() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _select_ccs() -> void:
|
func _select_ccs() -> void:
|
||||||
|
CCSService.launch_origin = "ccs"
|
||||||
pending_ccs_launch = true
|
pending_ccs_launch = true
|
||||||
ccs_button.disabled = true
|
ccs_button.disabled = true
|
||||||
ccs_status_label.text = "Selecting a timed case..."
|
ccs_status_label.text = "Selecting a timed case..."
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ extends SceneTree
|
|||||||
var failures := 0
|
var failures := 0
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenHost extends Control:
|
||||||
|
var shown_screen := ""
|
||||||
|
|
||||||
|
func show_screen(screen_name: String, _payload: Variant = null) -> void:
|
||||||
|
shown_screen = screen_name
|
||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
call_deferred("_run")
|
call_deferred("_run")
|
||||||
|
|
||||||
@@ -20,25 +27,71 @@ func _run() -> void:
|
|||||||
await process_frame
|
await process_frame
|
||||||
ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
|
ProjectSettings.set_setting("step3/deployment_environment", prior_environment)
|
||||||
|
|
||||||
var host := Control.new()
|
var service = root.get_node("CCSService")
|
||||||
|
var prior_attempt: Dictionary = service.attempt.duplicate(true)
|
||||||
|
var prior_cases: Array = service.cases.duplicate(true)
|
||||||
|
var prior_origin: String = service.launch_origin
|
||||||
|
var host := ScreenHost.new()
|
||||||
host.size = Vector2(1200.0, 700.0)
|
host.size = Vector2(1200.0, 700.0)
|
||||||
root.add_child(host)
|
root.add_child(host)
|
||||||
var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new()
|
var screen = load("res://scripts/ui/ccs_cases_2_screen.gd").new()
|
||||||
|
screen.main = host
|
||||||
host.add_child(screen)
|
host.add_child(screen)
|
||||||
await process_frame
|
await process_frame
|
||||||
screen.on_show()
|
|
||||||
|
service.cases = [
|
||||||
|
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
|
||||||
|
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
|
||||||
|
]
|
||||||
|
service.attempt = {}
|
||||||
|
screen.cases_loaded = true
|
||||||
|
screen.active_loaded = true
|
||||||
|
screen._render_state()
|
||||||
await process_frame
|
await process_frame
|
||||||
_assert(screen.current_view == "doctor_office", "CCS Cases 2 should open directly in the doctor office")
|
_assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection")
|
||||||
_assert(screen.active_scene != null, "CCS Cases 2 should instantiate the doctor-office scene")
|
_assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection")
|
||||||
|
|
||||||
|
service.attempt = _sample_attempt(false)
|
||||||
|
screen._render_state()
|
||||||
|
await process_frame
|
||||||
|
_assert(screen.current_view == "doctor_office", "an active interactive attempt should open the doctor office")
|
||||||
|
_assert(screen.active_scene.dialogue_text.text == "Hello, doctor.", "the patient bubble should use the server-provided current node")
|
||||||
|
_assert(screen.active_scene.choice_container.get_child_count() == 1, "the scene should render server-provided dialogue choices")
|
||||||
_assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio")
|
_assert(is_equal_approx(screen.stage_root.scale.x, screen.stage_root.scale.y), "the graphical stage should preserve its aspect ratio")
|
||||||
|
|
||||||
|
service.attempt = _sample_attempt(true)
|
||||||
|
screen._on_attempt_changed()
|
||||||
|
await process_frame
|
||||||
|
_assert(screen.active_scene.dialogue_text.text == "Okay, doctor.", "attempt updates should advance the rendered patient response")
|
||||||
|
_assert(screen.active_scene.choice_container.get_child_count() == 1, "terminal dialogue should expose the management handoff")
|
||||||
|
screen.active_scene.interview_continue_requested.emit()
|
||||||
|
_assert(host.shown_screen == "ccs_player", "terminal dialogue should hand off the same attempt to the CCS player")
|
||||||
|
|
||||||
screen.on_hide()
|
screen.on_hide()
|
||||||
await process_frame
|
await process_frame
|
||||||
_assert(screen.active_scene == null and screen.current_view.is_empty(), "hiding CCS Cases 2 should release the prototype scene")
|
_assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene")
|
||||||
host.queue_free()
|
host.queue_free()
|
||||||
|
service.attempt = prior_attempt
|
||||||
|
service.cases = prior_cases
|
||||||
|
service.launch_origin = prior_origin
|
||||||
await process_frame
|
await process_frame
|
||||||
quit(1 if failures > 0 else 0)
|
quit(1 if failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _sample_attempt(completed: bool) -> Dictionary:
|
||||||
|
var node := {
|
||||||
|
"id": "done" if completed else "greeting",
|
||||||
|
"patientText": "Okay, doctor." if completed else "Hello, doctor.",
|
||||||
|
"isTerminal": completed,
|
||||||
|
"choices": [] if completed else [{"id": "ask_reason", "text": "What brings you in?", "nextNodeId": "done"}]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": "attempt-1", "state": "active", "revision": 1,
|
||||||
|
"openingCompleted": completed,
|
||||||
|
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func _assert(condition: bool, message: String) -> void:
|
func _assert(condition: bool, message: String) -> void:
|
||||||
if condition:
|
if condition:
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user