Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions apps/ui-admin/src/ActorTurnReplayPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ActorTurnReplayPanel packet={fixturePacket()} />);

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("<soft>");
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("<soft>");
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(
<ActorTurnReplayPanel
packet={{
stationRunId: "run_empty",
scenarioId: "ed_chest_pain_priority_v1",
}}
/>,
);

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<string, unknown> {
return {
stationRunId: "station_run_review_packet_actor_turn_layers",
scenarioId: "peds_asthma_parent_anxiety_v1",
hiddenFacts: [HIDDEN],
spokenTextForTts: `<soft>${DISMISSIVE_LINE} [breath]</soft>`,
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,
};
}
187 changes: 187 additions & 0 deletions apps/ui-admin/src/ActorTurnReplayPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<ReviewPacketReplayQuery["reviewPacket"]>;
type ActorTurn = ReviewPacket["actorTurns"][number];
type EmotionalTimelineEntry = ReviewPacket["emotionalTimeline"][number];

export type ActorTurnReplayPanelProps = {
packet: Pick<ReviewPacket, "actorTurns" | "emotionalTimeline" | "prosodyNeutralized"> | Record<string, unknown>;
};

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 (
<section className="workbench-panel" aria-label="Actor turn plan versus execution replay">
<div className="workbench-title-row">
<div>
<Typography.Text className="eyebrow">GraphQL ReviewPacket actorTurns</Typography.Text>
<Typography.Title level={4}>Actor Turn Replay</Typography.Title>
</div>
<Tag color={prosodyNeutralized ? "gold" : "green"} aria-label="Prosody neutralization state">
{prosodyNeutralized ? "prosody neutralized" : "prosody rendered"}
</Tag>
</div>
<Typography.Paragraph>
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.
</Typography.Paragraph>

<div className="readiness-strip review-replay-strip">
<div>
<Typography.Text strong>{`${actorTurns.length} actor ${pluralize(actorTurns.length, "turn")}`}</Typography.Text>
<Typography.Paragraph type="secondary">{`${emotionalTimeline.length} emotional ${pluralize(emotionalTimeline.length, "step")}`}</Typography.Paragraph>
</div>
<div>
<Typography.Text strong>{`claimScope ${ACTOR_TURN_REPLAY_CLAIM_SCOPE}`}</Typography.Text>
<Typography.Paragraph type="secondary" aria-label="Actor turn replay claim boundary">
{ACTOR_TURN_REPLAY_CLAIM_BOUNDARY}
</Typography.Paragraph>
</div>
<div>
<Typography.Text strong>examEquivalenceGate false</Typography.Text>
<Typography.Paragraph type="secondary" aria-label="Actor turn replay not evidence for">
{`not evidence for ${ACTOR_TURN_REPLAY_NOT_EVIDENCE_FOR.join(", ")}`}
</Typography.Paragraph>
</div>
</div>

<section aria-label="Emotional timeline">
<Typography.Text strong>Emotional timeline</Typography.Text>
{emotionalTimeline.length === 0 ? (
<Typography.Paragraph className="empty-panel-note">No emotion transitions on this packet.</Typography.Paragraph>
) : (
<ol className="compact-list" aria-label="Emotional timeline entries">
{emotionalTimeline.map((entry, index) => (
<li key={`${entry.planId ?? "emotion"}:${entry.turnIndex ?? index}:${entry.from}:${entry.to}`}>
<Typography.Text>{`${entry.from} → ${entry.to}`}</Typography.Text>
<Typography.Text type="secondary">
{`${entry.trigger ?? "unspecified trigger"}; turn ${entry.turnIndex ?? "n/a"}; ${entry.atSecond ?? 0}s${entry.actorId ? `; ${entry.actorId}` : ""}`}
</Typography.Text>
</li>
))}
</ol>
)}
</section>

{actorTurns.length === 0 ? (
<Typography.Paragraph className="empty-panel-note">
No actor-turn plan/execution layers on this review packet.
</Typography.Paragraph>
) : (
<ol className="compact-list" aria-label="Actor turn plan versus execution timeline">
{actorTurns.map((turn) => (
<li key={turn.plan.planId}>
<div className="review-replay-grid">
<section aria-label={`Frozen plan ${turn.plan.planId}`}>
<Typography.Text strong>Frozen plan</Typography.Text>
<Typography.Paragraph>{turn.plan.spokenText}</Typography.Paragraph>
<Typography.Paragraph type="secondary">
{`plan ${turn.plan.planId}; emotion ${turn.plan.dialogueEmotionFrom} → ${turn.plan.dialogueEmotionTo}; event ${turn.plan.eventKind}`}
</Typography.Paragraph>
<Typography.Paragraph type="secondary">
{`performance ${turn.plan.performancePlanId}; face ${turn.plan.facePresetId}; claimScope ${turn.plan.claimScope}`}
</Typography.Paragraph>
<DroppedTagLog tags={turn.plan.droppedTags} />
</section>
<section aria-label={`Rendered execution ${turn.execution.planId}`}>
<Typography.Text strong>Rendered execution</Typography.Text>
<Typography.Paragraph>{executionOutcomeLabel(turn.execution)}</Typography.Paragraph>
<Typography.Paragraph type="secondary">
{`plan ${turn.execution.planId}; viseme cues ${turn.execution.visemeCueCount}; tts provider ${turn.execution.ttsProviderId}`}
</Typography.Paragraph>
<Tag color={turn.execution.interruptionKind === "none" ? "green" : "orange"} aria-label={`Interruption ${turn.plan.planId}`}>
{executionOutcomeLabel(turn.execution)}
</Tag>
</section>
</div>
</li>
))}
</ol>
)}

<Typography.Paragraph type="secondary" aria-label="Private payload posture">
{detectPrivatePayloadLeak(packet)
? "privatePayloadRedacted=true"
: "privatePayloadRedacted=true; no raw audio"}
</Typography.Paragraph>
</section>
);
}

function DroppedTagLog({ tags }: { tags: readonly string[] }): ReactElement {
return (
<div aria-label="Dropped provider tags">
<Typography.Text strong>Dropped provider tags</Typography.Text>
<div className="tag-row">
{tags.length === 0 ? <Tag color="green">none</Tag> : tags.map((tag) => (
<Tag key={tag} color="gold">{tag}</Tag>
))}
</div>
</div>
);
}

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<string, unknown> | undefined): ActorTurn[] {
const turns = packet?.["actorTurns"];
return Array.isArray(turns) ? turns as ActorTurn[] : [];
}

function readEmotionalTimeline(packet: Record<string, unknown> | 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<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}

function pluralize(count: number, noun: string): string {
return count === 1 ? noun : `${noun}s`;
}
5 changes: 3 additions & 2 deletions apps/ui-admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -405,6 +405,7 @@ function ReviewReplayWorkbench({ controlPlaneClient }: { controlPlaneClient: Adm
/>
) : null}

<ActorTurnReplayPanel packet={packet} />
<FacultyReviewDecisionPanel
packet={packet}
clinicalEventReviewSummary={clinicalEventReviewSummary}
Expand Down
Loading