Add graphical duodenal ulcer interview

This commit is contained in:
hz
2026-08-05 23:14:51 -05:00
parent 7709d16018
commit 58dd370149
14 changed files with 768 additions and 29 deletions
+4
View File
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1
fi
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
dotnet QuestionsApi.dll promote-ccs-cases \
--source /app/content/ccs/duodenal-ulcer-office-001.json \
--apply
fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
+83
View File
@@ -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"]
}
]
};
}
+2
View File
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
COPY content/ccs/ content/ccs/
WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
COPY --from=build /src/content/ccs ./content/ccs
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+42
View File
@@ -64,6 +64,8 @@ public class CcsCaseRevision
public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = [];
@@ -79,6 +81,39 @@ public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
public string OpeningPatientText { get; set; } = "";
public List<CcsInterviewTopic> Topics { get; set; } = [];
public string FinishChoiceText { get; set; } = "Finish the interview";
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
public CcsInterviewDebrief Debrief { get; set; } = new();
}
public class CcsInterviewTopic
{
public string Id { get; set; } = "";
public string ClinicianText { get; set; } = "";
public string PatientText { get; set; } = "";
public bool Required { get; set; }
public List<string> PrerequisiteTopicIds { get; set; } = [];
}
public class CcsInterviewDebrief
{
public string LikelyDiagnosis { get; set; } = "";
public string Summary { get; set; } = "";
public List<string> TeachingPoints { get; set; } = [];
}
public class CcsGraphicalPresentation
{
public string SceneKey { get; set; } = "";
public string PatientAssetKey { get; set; } = "";
}
public static class CcsExperienceModes
{
public const string Management = "management";
public const string GraphicalInterview = "graphical_interview";
}
public class CcsDialogueNode
@@ -270,6 +305,7 @@ public class CcsImportResponse
{
public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; }
public int CasesSkipped { get; set; }
public List<string> Errors { get; set; } = [];
}
@@ -312,6 +348,9 @@ public class CcsCaseSummary
public int Difficulty { get; set; }
public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
public bool GraphicalPresentationAvailable { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
}
public class CcsAttemptResponse
@@ -332,6 +371,8 @@ public class CcsAttemptResponse
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
public int ElapsedSeconds { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
@@ -346,6 +387,7 @@ public class CcsAttemptResponse
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsInterviewDebrief? InterviewDebrief { get; set; }
public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = [];
+6
View File
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return;
}
if (args.FirstOrDefault() == "promote-ccs-cases")
{
Environment.ExitCode = await CcsPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "export-dynamic-review")
{
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
@@ -34,6 +34,12 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
errors.Add(prefix + "experienceMode is invalid");
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
@@ -73,6 +79,10 @@ public static class CcsCaseValidator
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
if (criterion.ActionType == "dialogue_choice"
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
}
}
}
@@ -123,6 +133,55 @@ public static class CcsCaseValidator
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (interview.Topics.Count > 0)
{
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
errors.Add(prefix + "topic interview requires openingPatientText");
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
var topicIds = interview.Topics.Select(value => value.Id).ToList();
if (topicIds.Any(string.IsNullOrWhiteSpace)
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
errors.Add(prefix + "interview topic ids must be present and unique");
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "finish_interview is a reserved interview topic id");
if (!interview.Topics.Any(value => value.Required))
errors.Add(prefix + "topic interview requires at least one required topic");
foreach (var topic in interview.Topics)
{
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
if (string.IsNullOrWhiteSpace(topic.PatientText))
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
foreach (var prerequisite in topic.PrerequisiteTopicIds)
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
}
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool ResolveTopic(string topicId)
{
if (resolving.Contains(topicId))
{
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
return false;
}
if (resolved.Contains(topicId)) return true;
resolving.Add(topicId);
if (topicMap.TryGetValue(topicId, out var topic))
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
ResolveTopic(prerequisite);
resolving.Remove(topicId);
resolved.Add(topicId);
return true;
}
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
return;
}
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class CcsPromotionCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
? args[sourceIndex + 1]
: Path.Combine(AppContext.BaseDirectory, "content", "ccs");
var files = File.Exists(source)
? new[] { source }
: Directory.Exists(source)
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
: [];
if (files.Length == 0)
{
await output.WriteLineAsync($"CCS content not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var request = new CcsImportRequest();
foreach (var file in files)
{
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
request.Orders.AddRange(parsed.Orders);
request.Cases.AddRange(parsed.Cases);
}
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
if (duplicateOrders.Count > 0)
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables().Build();
var mongo = new MongoDatabaseProvider(configuration);
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
if (errors.Count > 0)
{
foreach (var error in errors) await output.WriteLineAsync(error);
return 1;
}
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
foreach (var order in request.Orders)
{
order.Code = order.Code.Trim().ToLowerInvariant();
order.UpdatedUtc = DateTime.UtcNow;
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var unchanged = 0;
foreach (var incoming in request.Cases)
{
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
{
unchanged++;
continue;
}
if (current is not null)
await cases.UpdateOneAsync(value => value.Id == current.Id,
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
incoming.Version = (current?.Version ?? 0) + 1;
incoming.IsCurrent = true;
incoming.CreatedUtc = DateTime.UtcNow;
await cases.InsertOneAsync(incoming);
created++;
}
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
return 0;
}
}
+103 -11
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var skipped = 0;
foreach (var incoming in request.Cases)
{
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
}
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
{
var document = value.ToBsonDocument();
document.Remove("_id");
document.Remove(nameof(CcsCaseRevision.Version));
document.Remove(nameof(CcsCaseRevision.IsCurrent));
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
return document;
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
@@ -109,6 +125,7 @@ public sealed class CcsStore
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt
{
UserId = userId,
@@ -118,7 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = interview.StartNodeId,
InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
@@ -126,9 +143,11 @@ public sealed class CcsStore
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
@@ -416,9 +435,7 @@ public sealed class CcsStore
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var interviewDefinition = EffectiveInterview(definition);
var currentInterviewNode = interviewDefinition.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
@@ -440,9 +457,13 @@ public sealed class CcsStore
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Appearance = definition.InitialChart.Appearance,
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
@@ -452,6 +473,8 @@ public sealed class CcsStore
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
@@ -460,6 +483,11 @@ public sealed class CcsStore
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
{
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
if (definition.InitialInterview.Topics.Count > 0)
{
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
return;
}
var interview = EffectiveInterview(definition);
var node = interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
@@ -730,9 +758,73 @@ public sealed class CcsStore
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = EffectiveInterview(value).Nodes.Count > 0
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
};
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
{
var topicInterview = definition.InitialInterview;
if (topicInterview.Topics.Count > 0)
{
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
return new CcsDialogueNode
{
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
};
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
? topicInterview.OpeningPatientText
: topicInterview.Topics.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
if (string.IsNullOrWhiteSpace(patientText)) return null;
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
&& value.PrerequisiteTopicIds.All(visited.Contains))
.Select(value => new CcsDialogueChoice
{
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
}).ToList();
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
choices.Add(new CcsDialogueChoice
{
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
});
return new CcsDialogueNode
{
Id = attempt.InterviewNodeId, PatientText = patientText, Choices = choices
};
}
var interview = EffectiveInterview(definition);
return interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
}
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
{
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
{
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
throw new ArgumentException("Complete the core interview topics before finishing.");
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add("finish_interview");
attempt.InterviewNodeId = "complete";
attempt.OpeningCompleted = true;
return;
}
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("That interview topic has already been covered.");
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
throw new ArgumentException("That interview topic is not available yet.");
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(topic.Id);
attempt.InterviewNodeId = topic.Id;
}
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
{
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
+152
View File
@@ -0,0 +1,152 @@
{
"orders": [],
"cases": [
{
"externalId": "ccs-gastro-duodenal-ulcer-office-001",
"isPublished": true,
"title": "Office Visit — 44-year-old man",
"system": "Gastrointestinal System",
"presentingLocation": "office",
"realTimeMinutes": 10,
"difficulty": 1,
"tags": ["history taking", "dyspepsia", "duodenal ulcer", "Helicobacter pylori"],
"experienceMode": "graphical_interview",
"graphicalPresentation": {
"sceneKey": "doctor_office",
"patientAssetKey": "patient_44_white_male"
},
"initialChart": {
"patientSummary": "44-year-old man attending an office visit",
"reasonForEncounter": "The reason for the visit has not yet been elicited.",
"appearance": "He is seated comfortably, attentive, and in no acute distress.",
"history": "Several weeks of intermittent lightheadedness and fatigue accompany sleep-disrupting, gnawing epigastric discomfort that is worse on an empty stomach and temporarily relieved by food or antacids. He has no symptoms of gastrointestinal bleeding or other alarm features.",
"historySections": [
{ "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." },
{ "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Gnawing epigastric discomfort occurs when his stomach is empty and at night, and improves temporarily after food or antacids." },
{ "code": "alarm_features", "title": "Alarm features", "text": "No melena, hematemesis, persistent vomiting, dysphagia, unintentional weight loss, or syncope." }
],
"vitals": {
"temperature": "36.8 C (98.2 F)",
"bloodPressure": "122/76 mm Hg",
"pulse": "76/min",
"respirations": "14/min",
"oxygenSaturation": "99% on room air"
},
"simulatedStartUtc": "2026-08-05T09:00:00Z",
"initialOrderCodes": []
},
"initialInterview": {
"openingPatientText": "Good morning, doctor.",
"finishChoiceText": "Thank you. I have enough history for now.",
"closingPatientText": "Thank you, doctor. I hope we can figure out what has been going on.",
"topics": [
{
"id": "chief_concern",
"clinicianText": "Good morning. What brings you in today?",
"patientText": "For the past few weeks, I have felt worn out, and every so often I get lightheaded—especially when I stand up quickly.",
"required": false
},
{
"id": "dizziness_character",
"clinicianText": "Tell me more about the dizziness. Does the room spin, and have you fainted?",
"patientText": "The room does not spin. It is more like I might black out for a second when I get up, but I have never actually fainted. I have had no weakness, numbness, trouble speaking, chest pain, or racing heartbeat.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "fatigue_course",
"clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?",
"patientText": "My energy is down by the afternoon. I have been eating less because my stomach bothers me, and the discomfort sometimes wakes me at night, so I am not sleeping well.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "abdominal_symptoms",
"clinicianText": "You mentioned your stomach. Please describe the discomfort and when it occurs.",
"patientText": "It is a dull, gnawing ache high in the middle of my abdomen. It is usually worse late at night or when I have not eaten. A snack or an antacid settles it for a while, but then it comes back.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "bleeding_alarm_features",
"clinicianText": "Have you had black stools, blood in vomit or stool, trouble swallowing, persistent vomiting, weight loss, or severe sudden pain?",
"patientText": "No. My stools look normal, I have not vomited blood, swallowing is fine, and my weight has been steady. I have not had severe or sudden pain.",
"required": true,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "medications",
"clinicianText": "What medicines do you take, including aspirin, ibuprofen, naproxen, or other over-the-counter products?",
"patientText": "I do not take any prescriptions. I use acetaminophen once in a while for a headache, but I do not use aspirin, ibuprofen, or naproxen.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "medical_history",
"clinicianText": "Do you have prior stomach problems, other medical conditions, surgeries, or medication allergies?",
"patientText": "I have never been diagnosed with an ulcer and have not had surgery. I am generally healthy and have no known medication allergies.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "social_history",
"clinicianText": "Do you smoke, drink alcohol, or use recreational drugs?",
"patientText": "I do not smoke or use drugs. I might have one or two beers on a weekend, but not every week.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "additional_review",
"clinicianText": "Any fever, shortness of breath, headaches, bowel changes, or urinary symptoms?",
"patientText": "No fever, shortness of breath, unusual headaches, bowel changes, or urinary trouble.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
}
],
"debrief": {
"likelyDiagnosis": "Probable uncomplicated H. pylori–associated duodenal ulcer",
"summary": "The key pattern is gnawing epigastric discomfort when fasting or at night with temporary relief from food or antacids. The interview found no evidence of active gastrointestinal bleeding or other alarm features. H. pylori infection is a leading cause, but it would require confirmatory testing.",
"teachingPoints": [
"Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.",
"Ask directly about melena, hematemesis, syncope, weight loss, dysphagia, persistent vomiting, and sudden severe pain.",
"Review NSAID and aspirin exposure even when the history suggests H. pylori infection."
]
}
},
"allowedLocations": ["office"],
"exams": [],
"orderResponses": [],
"events": [],
"scoringCriteria": [
{ "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." },
{ "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." },
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "Poor intake and sleep disruption provide a coherent context for the nonspecific fatigue." },
{ "id": "characterize_abdominal_pain", "title": "Characterize abdominal symptoms", "actionType": "dialogue_choice", "targetCode": "abdominal_symptoms", "points": 20, "rationale": "Timing, location, quality, and relieving factors establish the ulcer-like dyspepsia pattern." },
{ "id": "screen_alarm_features", "title": "Screen for bleeding and alarm features", "actionType": "dialogue_choice", "targetCode": "bleeding_alarm_features", "points": 20, "rationale": "Bleeding and other alarm features would change the urgency and evaluation." },
{ "id": "review_medications", "title": "Review NSAID and aspirin exposure", "actionType": "dialogue_choice", "targetCode": "medications", "points": 10, "rationale": "Medication exposure is a major alternative cause of peptic ulcer disease." },
{ "id": "review_background", "title": "Review medical history and allergies", "actionType": "dialogue_choice", "targetCode": "medical_history", "points": 5, "rationale": "Background history informs risk and future treatment selection." },
{ "id": "review_social_history", "title": "Review social history", "actionType": "dialogue_choice", "targetCode": "social_history", "points": 5, "rationale": "Tobacco and heavy alcohol exposure can affect symptoms and risk." },
{ "id": "complete_review", "title": "Complete a focused review of systems", "actionType": "dialogue_choice", "targetCode": "additional_review", "points": 5, "rationale": "A focused review checks for competing causes and associated illness." }
],
"references": [
{
"title": "Peptic Ulcer Disease",
"publisher": "American College of Gastroenterology",
"url": "https://gi.org/topics/peptic-ulcer-disease/",
"supports": "Typical ulcer symptoms, bleeding warning symptoms, NSAID history, H. pylori evaluation, and endoscopic diagnosis",
"accessedOn": "2026-08-05",
"reviewer": "Question bank editorial review"
},
{
"title": "ACG Guideline on Treatment of Helicobacter pylori: New Recommendations",
"publisher": "American College of Gastroenterology",
"url": "https://gi.org/journals-publications/ebgi/schoenfeld_sep2024/",
"supports": "H. pylori treatment principles and confirmation of eradication",
"accessedOn": "2026-08-05",
"reviewer": "Question bank editorial review"
}
]
}
],
"validateOnly": false
}
+4
View File
@@ -4,6 +4,10 @@ CCS content is imported through `POST /api/ccs/admin/import` by a `SUPER` user.
Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validateOnly` to `true` first, then submit the same payload with `false`. Updating an existing `externalId` creates a new immutable revision; active and completed attempts remain attached to the revision they started with.
Topic-based graphical interviews use `initialInterview.openingPatientText` plus a `topics` array instead of `startNodeId` and `nodes`. Each topic supplies a stable id, clinician prompt, patient response, and optional `required` and `prerequisiteTopicIds` fields. Required topics gate `finish_interview`; visited topics are removed from the available choices. Set `experienceMode` to `graphical_interview` and provide a supported `graphicalPresentation` to complete in CCS Cases 2 with an authored debrief instead of handing off to classic patient management.
UAT deployment promotes `content/ccs/duodenal-ulcer-office-001.json` through the idempotent `promote-ccs-cases` command. An unchanged case is skipped; changed content creates the next immutable revision.
## 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.
+4 -3
View File
@@ -143,7 +143,7 @@ text = "Hello, doctor."
autowrap_mode = 2
vertical_alignment = 1
[node name="DialogueChoices" type="VBoxContainer" parent="."]
[node name="DialogueChoices" type="GridContainer" parent="."]
z_index = 29
layout_mode = 1
anchors_preset = 0
@@ -151,8 +151,9 @@ anchor_left = 0.5
anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -210.0
offset_left = -450.0
offset_top = -206.0
offset_right = 210.0
offset_right = 450.0
offset_bottom = -4.0
theme_override_constants/separation = -8
columns = 2
+117 -10
View File
@@ -2,6 +2,8 @@ extends Control
signal choice_selected(choice_id: String)
signal interview_continue_requested
signal debrief_return_requested
signal debrief_repeat_requested
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")
@@ -14,10 +16,13 @@ const CHOICE_CROP := Rect2(80.0, 200.0, 1610.0, 485.0)
@onready var patient: TextureRect = $Patient
@onready var dialogue_bubble: Control = $DialogueBubble
@onready var dialogue_text: Label = $DialogueBubble/Text
@onready var choice_container: VBoxContainer = $DialogueChoices
@onready var choice_container: GridContainer = $DialogueChoices
var blink_tween: Tween
var dialogue_tween: Tween
var clock_seconds := 9.0 * 60.0 * 60.0
var debrief_panel: PanelContainer
var experience_mode := "management"
func _ready() -> void:
@@ -28,20 +33,35 @@ func _ready() -> void:
func _process(_delta: float) -> void:
_update_clock()
pass
func _update_clock() -> void:
var time := Time.get_time_dict_from_system()
var unix_time := Time.get_unix_time_from_system()
var second := float(time.second) + fmod(unix_time, 1.0)
var minute := float(time.minute) + second / 60.0
var hour := float(time.hour % 12) + minute / 60.0
var hour_value := floori(clock_seconds / 3600.0)
var minute_value := floori(fmod(clock_seconds, 3600.0) / 60.0)
var second_value := fmod(clock_seconds, 60.0)
var second := float(second_value)
var minute := float(minute_value) + second / 60.0
var hour := float(hour_value % 12) + minute / 60.0
hour_hand.rotation = TAU * hour / 12.0
minute_hand.rotation = TAU * minute / 60.0
second_hand.rotation = TAU * second / 60.0
func configure_presentation(presentation: Dictionary, simulated_start: String, simulated_minute: int) -> void:
if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
push_warning("Unsupported CCS patient asset key; using the office default.")
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false)
if not date_time.is_empty():
clock_seconds = float(int(date_time.get("hour", 9)) * 3600 + int(date_time.get("minute", 0)) * 60)
clock_seconds += float(simulated_minute * 60)
_update_clock()
func configure_experience_mode(value: String) -> void:
experience_mode = value
func _schedule_patient_blink() -> void:
blink_tween = create_tween()
blink_tween.tween_interval(randf_range(2.8, 5.2))
@@ -90,9 +110,15 @@ func _resize_dialogue_bubble() -> void:
func configure_interview(node: Dictionary) -> void:
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = null
show_patient_dialogue(str(node.get("patientText", "")))
if bool(node.get("isTerminal", false)):
show_dialogue_choices([{"id": "__continue", "text": "Continue to Patient Management"}])
show_dialogue_choices([{
"id": "__continue",
"text": "Finish Interview" if experience_mode == "graphical_interview" else "Continue to Patient Management"
}])
else:
show_dialogue_choices(node.get("choices", []))
@@ -105,14 +131,15 @@ func set_choices_pending(pending: bool) -> void:
func show_dialogue_choices(options: Array) -> void:
hide_dialogue_choices()
var stack_height := options.size() * 97.0 + 8.0
var row_count := ceili(float(options.size()) / 2.0)
var stack_height := row_count * 97.0 + 8.0
choice_container.offset_top = -4.0 - stack_height
for index in options.size():
var option: Dictionary = options[index]
var choice_id := str(option.get("id", ""))
var option_text := str(option.get("text", ""))
var button := TextureButton.new()
button.custom_minimum_size = Vector2(420.0, 105.0)
button.custom_minimum_size = Vector2(440.0, 105.0)
button.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_SCALE
button.texture_normal = _crop_choice_texture(CHOICE_NORMAL)
@@ -175,3 +202,83 @@ func _on_dialogue_choice_pressed(choice_id: String) -> void:
interview_continue_requested.emit()
else:
choice_selected.emit(choice_id)
func show_debrief(debrief: Dictionary, score: Dictionary) -> void:
hide_patient_dialogue()
hide_dialogue_choices()
if debrief_panel != null and is_instance_valid(debrief_panel):
debrief_panel.queue_free()
debrief_panel = PanelContainer.new()
debrief_panel.name = "InterviewDebrief"
debrief_panel.z_index = 50
debrief_panel.position = Vector2(315.0, 100.0)
debrief_panel.size = Vector2(870.0, 600.0)
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.96, 0.95, 0.91, 0.97)
panel_style.border_color = Color(0.12, 0.22, 0.28, 0.8)
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(12)
panel_style.set_content_margin_all(28.0)
debrief_panel.add_theme_stylebox_override("panel", panel_style)
add_child(debrief_panel)
var content := VBoxContainer.new()
content.add_theme_constant_override("separation", 16)
debrief_panel.add_child(content)
var heading := Label.new()
heading.text = "Interview Debrief"
heading.add_theme_font_size_override("font_size", 30)
heading.add_theme_color_override("font_color", Color(0.055, 0.105, 0.145))
content.add_child(heading)
var diagnosis := Label.new()
diagnosis.text = str(debrief.get("likelyDiagnosis", "Interview complete"))
diagnosis.add_theme_font_size_override("font_size", 23)
diagnosis.add_theme_color_override("font_color", Color(0.12, 0.36, 0.27))
diagnosis.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(diagnosis)
var summary := Label.new()
summary.text = str(debrief.get("summary", ""))
summary.add_theme_font_size_override("font_size", 18)
summary.add_theme_color_override("font_color", Color(0.08, 0.12, 0.15))
summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(summary)
var score_label := Label.new()
score_label.text = "History coverage: %d%%" % int(round(float(score.get("percent", 0.0))))
score_label.add_theme_font_size_override("font_size", 20)
content.add_child(score_label)
var covered: Array[String] = []
var missed: Array[String] = []
for raw_criterion in score.get("criteria", []):
var criterion: Dictionary = raw_criterion
var title := str(criterion.get("title", "History topic"))
if str(criterion.get("status", "")) == "credited":
covered.append(title)
elif str(criterion.get("status", "")) == "missed":
missed.append(title)
var coverage := Label.new()
coverage.text = "Covered: %s\nMissed optional topics: %s" % [
", ".join(covered), "None" if missed.is_empty() else ", ".join(missed)]
coverage.add_theme_font_size_override("font_size", 16)
coverage.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(coverage)
var points := Label.new()
var teaching_points: Array = debrief.get("teachingPoints", [])
points.text = "Key points\n- " + "\n- ".join(teaching_points) if not teaching_points.is_empty() else ""
points.add_theme_font_size_override("font_size", 17)
points.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
points.size_flags_vertical = Control.SIZE_EXPAND_FILL
content.add_child(points)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 12)
content.add_child(actions)
var return_button := Button.new()
return_button.text = "Return to Cases"
return_button.custom_minimum_size = Vector2(190.0, 48.0)
return_button.pressed.connect(func(): debrief_return_requested.emit())
actions.add_child(return_button)
var repeat_button := Button.new()
repeat_button.text = "Repeat Interview"
repeat_button.custom_minimum_size = Vector2(190.0, 48.0)
repeat_button.pressed.connect(func(): debrief_repeat_requested.emit())
actions.add_child(repeat_button)
+52 -4
View File
@@ -57,7 +57,10 @@ func on_hide() -> 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)))
return typeof(item) == TYPE_DICTIONARY \
and bool(item.get("interactiveInterviewAvailable", false)) \
and bool(item.get("graphicalPresentationAvailable", false)) \
and str(item.get("graphicalPresentation", {}).get("sceneKey", "")) == "doctor_office")
func _on_cases_changed() -> void:
@@ -77,6 +80,10 @@ func _render_state() -> void:
if not active_loaded:
return
var attempt: Dictionary = CCSService.attempt
if not attempt.is_empty() and str(attempt.get("state", "")) == "completed" \
and str(attempt.get("experienceMode", "")) == "graphical_interview":
_render_debrief(attempt)
return
if not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]:
_render_attempt(attempt)
elif cases_loaded and active_loaded:
@@ -98,7 +105,7 @@ func _render_attempt(attempt: Dictionary) -> void:
if bool(attempt.get("openingCompleted", false)) and not bool(node.get("isTerminal", false)):
_handoff_to_classic_player()
return
_show_doctor_office(node)
_show_doctor_office(node, attempt)
func _build_selection_view() -> void:
@@ -156,7 +163,7 @@ func _render_case_selection() -> void:
_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."
status_label.text = "No published CCS cases currently have a supported graphical interview."
return
status_label.text = "%d interactive %s available." % [available.size(), "case is" if available.size() == 1 else "cases are"]
retry_button.visible = false
@@ -183,13 +190,20 @@ func _start_case(case_external_id: String) -> void:
CCSService.create_attempt(case_external_id, "timed")
func _show_doctor_office(node: Dictionary) -> void:
func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
if active_scene == null:
_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)
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_experience_mode(str(attempt.get("experienceMode", "management")))
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)))
active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty())
@@ -203,11 +217,45 @@ func _submit_dialogue_choice(choice_id: String) -> void:
func _handoff_to_classic_player() -> void:
if str(CCSService.attempt.get("experienceMode", "")) == "graphical_interview":
pending_action = "finishing_interview"
active_scene.set_choices_pending(true)
CCSService.send_command("exit_case")
return
current_view = "handoff"
if main != null:
main.show_screen("ccs_player")
func _render_debrief(attempt: Dictionary) -> void:
if active_scene == null:
_set_active_scene(DOCTOR_SCENE.instantiate(), "doctor_office")
active_scene.debrief_return_requested.connect(_return_to_cases)
active_scene.debrief_repeat_requested.connect(_repeat_interview)
selection_view.visible = false
stage_root.visible = true
active_scene.configure_presentation(
attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)))
active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
current_view = "interview_debrief"
func _return_to_cases() -> void:
CCSService.attempt = {}
pending_action = ""
_render_case_selection()
func _repeat_interview() -> void:
var case_external_id := str(CCSService.attempt.get("caseExternalId", ""))
if case_external_id.is_empty() or not pending_action.is_empty():
return
pending_action = "creating"
CCSService.create_attempt(case_external_id, "timed")
func _on_request_failed(message: String) -> void:
if not visible:
return
+48 -1
View File
@@ -40,7 +40,11 @@ func _run() -> void:
await process_frame
service.cases = [
{"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2, "realTimeMinutes": 20, "interactiveInterviewAvailable": true},
{
"externalId": "interactive", "title": "Interactive case", "system": "Endocrine", "difficulty": 2,
"realTimeMinutes": 20, "interactiveInterviewAvailable": true, "graphicalPresentationAvailable": true,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}
},
{"externalId": "legacy", "title": "Legacy case", "interactiveInterviewAvailable": false}
]
service.attempt = {}
@@ -58,6 +62,11 @@ func _run() -> void:
_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")
service.attempt = _sample_branching_attempt()
screen._render_state()
await process_frame
_assert(screen.active_scene.choice_container.columns == 2, "branching interview choices should use two columns")
_assert(screen.active_scene.choice_container.get_child_count() == 8, "all available branching topics should render")
service.attempt = _sample_attempt(true)
screen._on_attempt_changed()
@@ -67,6 +76,12 @@ func _run() -> void:
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")
service.attempt = _sample_graphical_debrief()
screen._on_attempt_changed()
await process_frame
_assert(screen.current_view == "interview_debrief", "a completed graphical interview should remain in CCS Cases 2")
_assert(screen.active_scene.find_child("InterviewDebrief", true, false) != null, "the graphical case should render its debrief")
screen.on_hide()
await process_frame
_assert(screen.active_scene == null, "hiding CCS Cases 2 should release the doctor scene")
@@ -87,11 +102,43 @@ func _sample_attempt(completed: bool) -> Dictionary:
}
return {
"id": "attempt-1", "state": "active", "revision": 1,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
"openingCompleted": completed,
"interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed}
}
func _sample_graphical_debrief() -> Dictionary:
return {
"id": "attempt-2", "caseExternalId": "interactive", "state": "completed", "revision": 7,
"experienceMode": "graphical_interview", "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
"interviewDebrief": {
"likelyDiagnosis": "Probable duodenal ulcer", "summary": "A focused history supports the assessment.",
"teachingPoints": ["Characterize dizziness.", "Screen for bleeding."]
},
"score": {"percent": 85.0}
}
func _sample_branching_attempt() -> Dictionary:
var choices: Array = []
for index in 8:
choices.append({"id": "topic_%d" % index, "text": "Ask about history topic %d" % (index + 1), "nextNodeId": "topic_%d" % index})
return {
"id": "attempt-1", "state": "active", "revision": 2,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"},
"openingCompleted": false,
"interview": {
"currentNodeId": "chief_concern",
"currentNode": {"id": "chief_concern", "patientText": "I feel tired and lightheaded.", "isTerminal": false, "choices": choices},
"visitedChoiceIds": ["chief_concern"], "completed": false
}
}
func _assert(condition: bool, message: String) -> void:
if condition:
return