diff --git a/apps/ui-admin/src/ActorTurnReplayPanel.test.tsx b/apps/ui-admin/src/ActorTurnReplayPanel.test.tsx new file mode 100644 index 00000000..045dc94d --- /dev/null +++ b/apps/ui-admin/src/ActorTurnReplayPanel.test.tsx @@ -0,0 +1,185 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ACTOR_TURN_REPLAY_CLAIM_BOUNDARY, + ACTOR_TURN_REPLAY_NOT_EVIDENCE_FOR, + ActorTurnReplayPanel, +} from "./ActorTurnReplayPanel.js"; + +const HIDDEN = "HIDDEN_DIAGNOSIS_MODERATE_PERSISTENT_ASTHMA"; +const DISMISSIVE_LINE = "The hallway light is on."; +const EMPATHETIC_LINE = "The window latch is closed."; + +describe("ActorTurnReplayPanel", () => { + afterEach(() => { + cleanup(); + }); + + it("renders GraphQL plan versus execution as an inspectable multimodal timeline", () => { + render(); + + const panel = screen.getByLabelText("Actor turn plan versus execution replay"); + expect(within(panel).getByRole("heading", { name: "Actor Turn Replay" })).toBeInTheDocument(); + expect(within(panel).getByLabelText("Prosody neutralization state")).toHaveTextContent("prosody neutralized"); + expect(within(panel).getByLabelText("Actor turn replay claim boundary")).toHaveTextContent( + ACTOR_TURN_REPLAY_CLAIM_BOUNDARY, + ); + const notEvidence = within(panel).getByLabelText("Actor turn replay not evidence for"); + for (const item of ACTOR_TURN_REPLAY_NOT_EVIDENCE_FOR) { + expect(notEvidence).toHaveTextContent(item); + } + expect(panel).toHaveTextContent("examEquivalenceGate false"); + expect(panel).toHaveTextContent("claimScope simulated_actor_behavior"); + + const emotions = within(panel).getByLabelText("Emotional timeline entries"); + expect(emotions).toHaveTextContent("neutral → concerned"); + expect(emotions).toHaveTextContent("concerned → reassured"); + expect(emotions).toHaveTextContent("learner_dismissive"); + expect(emotions).toHaveTextContent("learner_empathetic"); + + const timeline = within(panel).getByLabelText("Actor turn plan versus execution timeline"); + const dismissivePlan = within(timeline).getByLabelText("Frozen plan plan_dismissive_001"); + const dismissiveExecution = within(timeline).getByLabelText("Rendered execution plan_dismissive_001"); + expect(dismissivePlan).toHaveTextContent(DISMISSIVE_LINE); + expect(dismissivePlan).toHaveTextContent("emotion neutral → concerned"); + expect(dismissivePlan).toHaveTextContent("event learner_dismissive"); + expect(dismissivePlan).toHaveTextContent("performance perf_dismissive_child"); + expect(dismissivePlan).toHaveTextContent("face face_concerned_child"); + expect(within(dismissivePlan).getByLabelText("Dropped provider tags")).toHaveTextContent("[cry]"); + expect(dismissiveExecution).toHaveTextContent("truncated"); + expect(dismissiveExecution).toHaveTextContent("viseme cues 2"); + expect(dismissiveExecution).toHaveTextContent("tts provider mock-tts"); + expect(dismissivePlan).not.toHaveTextContent("truncated"); + expect(dismissivePlan.textContent).not.toContain(""); + expect(dismissivePlan.textContent).not.toContain("[breath]"); + + const empatheticPlan = within(timeline).getByLabelText("Frozen plan plan_empathetic_001"); + const empatheticExecution = within(timeline).getByLabelText("Rendered execution plan_empathetic_001"); + expect(empatheticPlan).toHaveTextContent(EMPATHETIC_LINE); + expect(empatheticPlan).toHaveTextContent("event learner_empathetic"); + expect(empatheticExecution).toHaveTextContent("uninterrupted"); + expect(empatheticExecution).not.toHaveTextContent("truncated"); + + const bargePlan = within(timeline).getByLabelText("Frozen plan plan_barge_001"); + const bargeExecution = within(timeline).getByLabelText("Rendered execution plan_barge_001"); + expect(bargePlan).toHaveTextContent("The curtain is drawn."); + expect(bargeExecution).toHaveTextContent("barge-in replaced"); + expect(bargePlan).not.toHaveTextContent("barge-in"); + + expect(panel.textContent).not.toContain(HIDDEN); + expect(panel.textContent).not.toContain("spokenTextForTts"); + expect(panel.textContent).not.toContain(""); + expect(panel.textContent).not.toContain("rawAudio"); + expect(within(panel).getByLabelText("Private payload posture")).toHaveTextContent("privatePayloadRedacted=true"); + }); + + it("shows an empty plan/execution state when GraphQL layers are absent", () => { + render( + , + ); + + const panel = screen.getByLabelText("Actor turn plan versus execution replay"); + expect(panel).toHaveTextContent("No actor-turn plan/execution layers on this review packet."); + expect(panel).toHaveTextContent("No emotion transitions on this packet."); + expect(within(panel).getByLabelText("Prosody neutralization state")).toHaveTextContent("prosody rendered"); + }); +}); + +function fixturePacket(): Record { + return { + stationRunId: "station_run_review_packet_actor_turn_layers", + scenarioId: "peds_asthma_parent_anxiety_v1", + hiddenFacts: [HIDDEN], + spokenTextForTts: `${DISMISSIVE_LINE} [breath]`, + rawAudio: "UklGRiQAAABXQVZF", + actorTurns: [ + { + plan: { + planId: "plan_dismissive_001", + spokenText: DISMISSIVE_LINE, + dialogueEmotionFrom: "neutral", + dialogueEmotionTo: "concerned", + performancePlanId: "perf_dismissive_child", + facePresetId: "face_concerned_child", + eventKind: "learner_dismissive", + droppedTags: ["[cry]"], + claimScope: "simulated_actor_behavior", + }, + execution: { + planId: "plan_dismissive_001", + truncated: true, + visemeCueCount: 2, + ttsProviderId: "mock-tts", + interruptionKind: "truncated", + }, + }, + { + plan: { + planId: "plan_empathetic_001", + spokenText: EMPATHETIC_LINE, + dialogueEmotionFrom: "concerned", + dialogueEmotionTo: "reassured", + performancePlanId: "perf_empathetic_child", + facePresetId: "face_reassured_child", + eventKind: "learner_empathetic", + droppedTags: [], + claimScope: "simulated_actor_behavior", + }, + execution: { + planId: "plan_empathetic_001", + truncated: false, + visemeCueCount: 5, + ttsProviderId: "mock-tts", + interruptionKind: "none", + }, + }, + { + plan: { + planId: "plan_barge_001", + spokenText: "The curtain is drawn.", + dialogueEmotionFrom: "reassured", + dialogueEmotionTo: "anxious", + performancePlanId: "perf_interrupted_child", + facePresetId: "face_anxious_child", + eventKind: "learner_interruption", + droppedTags: [], + claimScope: "simulated_actor_behavior", + }, + execution: { + planId: "plan_barge_001", + truncated: false, + visemeCueCount: 1, + ttsProviderId: "mock-tts", + interruptionKind: "replaced", + }, + }, + ], + emotionalTimeline: [ + { + turnIndex: 0, + actorId: "patient_maya_johnson_v1", + from: "neutral", + to: "concerned", + trigger: "learner_dismissive", + planId: "plan_dismissive_001", + atSecond: 12, + }, + { + turnIndex: 1, + actorId: "patient_maya_johnson_v1", + from: "concerned", + to: "reassured", + trigger: "learner_empathetic", + planId: "plan_empathetic_001", + atSecond: 24, + }, + ], + prosodyNeutralized: true, + }; +} diff --git a/apps/ui-admin/src/ActorTurnReplayPanel.tsx b/apps/ui-admin/src/ActorTurnReplayPanel.tsx new file mode 100644 index 00000000..9096f1f2 --- /dev/null +++ b/apps/ui-admin/src/ActorTurnReplayPanel.tsx @@ -0,0 +1,187 @@ +import type { ReviewPacketReplayQuery } from "@openclinxr/graphql/client"; +import { Tag, Typography } from "antd"; +import type { ReactElement } from "react"; + +export const ACTOR_TURN_REPLAY_CLAIM_BOUNDARY = + "faculty_actor_turn_plan_vs_execution_not_clinical_validity" as const; + +export const ACTOR_TURN_REPLAY_CLAIM_SCOPE = "simulated_actor_behavior" as const; + +export const ACTOR_TURN_REPLAY_NOT_EVIDENCE_FOR = [ + "clinical_validity", + "scoring_validity", + "quest_readiness", + "exam_equivalence", + "production_readiness", +] as const; + +type ReviewPacket = NonNullable; +type ActorTurn = ReviewPacket["actorTurns"][number]; +type EmotionalTimelineEntry = ReviewPacket["emotionalTimeline"][number]; + +export type ActorTurnReplayPanelProps = { + packet: Pick | Record; +}; + +const PRIVATE_PAYLOAD_MARKERS = [ + "hiddenFacts", + "privateFacts", + "hiddenFactRefs", + "serverOnlyNotes", + "spokenTextForTts", + "rawAudio", + "audioBytes", + "audioPayload", +]; + +export function ActorTurnReplayPanel({ packet }: ActorTurnReplayPanelProps): ReactElement { + const record = asRecord(packet); + const actorTurns = readActorTurns(record); + const emotionalTimeline = readEmotionalTimeline(record); + const prosodyNeutralized = record?.["prosodyNeutralized"] === true; + + return ( +
+
+
+ GraphQL ReviewPacket actorTurns + Actor Turn Replay +
+ + {prosodyNeutralized ? "prosody neutralized" : "prosody rendered"} + +
+ + Frozen authored/generated plan fields stay distinct from rendered execution. Captions use faculty-safe spoken text. Raw audio and provider-private payloads stay off this surface. + + +
+
+ {`${actorTurns.length} actor ${pluralize(actorTurns.length, "turn")}`} + {`${emotionalTimeline.length} emotional ${pluralize(emotionalTimeline.length, "step")}`} +
+
+ {`claimScope ${ACTOR_TURN_REPLAY_CLAIM_SCOPE}`} + + {ACTOR_TURN_REPLAY_CLAIM_BOUNDARY} + +
+
+ examEquivalenceGate false + + {`not evidence for ${ACTOR_TURN_REPLAY_NOT_EVIDENCE_FOR.join(", ")}`} + +
+
+ +
+ Emotional timeline + {emotionalTimeline.length === 0 ? ( + No emotion transitions on this packet. + ) : ( +
    + {emotionalTimeline.map((entry, index) => ( +
  1. + {`${entry.from} → ${entry.to}`} + + {`${entry.trigger ?? "unspecified trigger"}; turn ${entry.turnIndex ?? "n/a"}; ${entry.atSecond ?? 0}s${entry.actorId ? `; ${entry.actorId}` : ""}`} + +
  2. + ))} +
+ )} +
+ + {actorTurns.length === 0 ? ( + + No actor-turn plan/execution layers on this review packet. + + ) : ( +
    + {actorTurns.map((turn) => ( +
  1. +
    +
    + Frozen plan + {turn.plan.spokenText} + + {`plan ${turn.plan.planId}; emotion ${turn.plan.dialogueEmotionFrom} → ${turn.plan.dialogueEmotionTo}; event ${turn.plan.eventKind}`} + + + {`performance ${turn.plan.performancePlanId}; face ${turn.plan.facePresetId}; claimScope ${turn.plan.claimScope}`} + + +
    +
    + Rendered execution + {executionOutcomeLabel(turn.execution)} + + {`plan ${turn.execution.planId}; viseme cues ${turn.execution.visemeCueCount}; tts provider ${turn.execution.ttsProviderId}`} + + + {executionOutcomeLabel(turn.execution)} + +
    +
    +
  2. + ))} +
+ )} + + + {detectPrivatePayloadLeak(packet) + ? "privatePayloadRedacted=true" + : "privatePayloadRedacted=true; no raw audio"} + +
+ ); +} + +function DroppedTagLog({ tags }: { tags: readonly string[] }): ReactElement { + return ( +
+ Dropped provider tags +
+ {tags.length === 0 ? none : tags.map((tag) => ( + {tag} + ))} +
+
+ ); +} + +function executionOutcomeLabel(execution: ActorTurn["execution"]): string { + if (execution.interruptionKind === "replaced") { + return "barge-in replaced"; + } + if (execution.interruptionKind === "truncated" || execution.truncated) { + return "truncated"; + } + return "uninterrupted"; +} + +function readActorTurns(packet: Record | undefined): ActorTurn[] { + const turns = packet?.["actorTurns"]; + return Array.isArray(turns) ? turns as ActorTurn[] : []; +} + +function readEmotionalTimeline(packet: Record | undefined): EmotionalTimelineEntry[] { + const timeline = packet?.["emotionalTimeline"]; + return Array.isArray(timeline) ? timeline as EmotionalTimelineEntry[] : []; +} + +function detectPrivatePayloadLeak(packet: unknown): boolean { + const serialized = JSON.stringify(packet ?? {}); + return PRIVATE_PAYLOAD_MARKERS.some((marker) => serialized.includes(marker)); +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function pluralize(count: number, noun: string): string { + return count === 1 ? noun : `${noun}s`; +} diff --git a/apps/ui-admin/src/App.tsx b/apps/ui-admin/src/App.tsx index fd6a4515..3c1f82c1 100644 --- a/apps/ui-admin/src/App.tsx +++ b/apps/ui-admin/src/App.tsx @@ -37,6 +37,7 @@ import { type ScenarioSceneGenerationRequestQueue, type SubmitRuntimeVisualEvidenceAttachmentInput, } from "./api-client.js"; +import { ActorTurnReplayPanel } from "./ActorTurnReplayPanel.js"; import { CaseAuthoringWorkbench } from "./CaseAuthoringWorkbench.js"; import { EmissionReplayBindPanel } from "./EmissionReplayBindPanel.js"; import type { PlacementAuthorValue } from "./EnvironmentGenerationQueuePanel.js"; @@ -52,8 +53,7 @@ import { ScenarioBankMaturityPanel } from "./ScenarioBankMaturityPanel.js"; import { ScenarioReviewGatePanel } from "./ScenarioReviewGatePanel.js"; import { SeedExamReadinessBoundaryPanel } from "./SeedExamReadinessBoundaryPanel.js"; -const { Content, Sider } = Layout; -const { TextArea } = Input; +const { Content, Sider } = Layout, { TextArea } = Input; type AdminAppProps = { router?: "browser" | "memory"; @@ -405,6 +405,7 @@ function ReviewReplayWorkbench({ controlPlaneClient }: { controlPlaneClient: Adm /> ) : null} +