diff --git a/packages/openclinxr/conversation-policy/src/barge-in.test.ts b/packages/openclinxr/conversation-policy/src/barge-in.test.ts index ea237ffc7..4f137b807 100644 --- a/packages/openclinxr/conversation-policy/src/barge-in.test.ts +++ b/packages/openclinxr/conversation-policy/src/barge-in.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; import { resolveLearnerBargeIn } from "./barge-in.js"; -import { CONVERSATION_CLAIM_SCOPE, CONVERSATION_NOT_EVIDENCE_FOR, type ActorTurnInProgress } from "./types.js"; +import { + canonicalInterruptionId, + canonicalTurnClockMs, +} from "./canonical-interruption.js"; +import { + CONVERSATION_CLAIM_SCOPE, + CONVERSATION_NOT_EVIDENCE_FOR, + TURN_MODALITIES_CANCELLED_ON_BARGE_IN, + type ActorTurnInProgress, +} from "./types.js"; describe("resolveLearnerBargeIn", () => { const inProgress: ActorTurnInProgress = { @@ -22,6 +31,19 @@ describe("resolveLearnerBargeIn", () => { interruptedAtSecond: 105, truncatedResponse: true, yieldedToLearner: true, + interruptionId: canonicalInterruptionId({ clockMs: 105_000 }), + turnId: null, + planId: null, + clockMs: 105_000, + cancellationDirective: { + interruptionId: canonicalInterruptionId({ clockMs: 105_000 }), + turnId: "no_turn", + planId: null, + clockMs: 105_000, + reason: "learner_barge_in", + action: "audio.clear", + cancelModalities: TURN_MODALITIES_CANCELLED_ON_BARGE_IN, + }, claimScope: CONVERSATION_CLAIM_SCOPE.bargeIn, notEvidenceFor: CONVERSATION_NOT_EVIDENCE_FOR, }); @@ -36,6 +58,11 @@ describe("resolveLearnerBargeIn", () => { interruptedAtSecond: 40, truncatedResponse: false, yieldedToLearner: false, + interruptionId: canonicalInterruptionId({ clockMs: 40_000 }), + turnId: null, + planId: null, + clockMs: 40_000, + cancellationDirective: null, claimScope: CONVERSATION_CLAIM_SCOPE.bargeIn, notEvidenceFor: CONVERSATION_NOT_EVIDENCE_FOR, }); @@ -48,3 +75,108 @@ describe("resolveLearnerBargeIn", () => { expect(resolution.outcome).toBe("actor_turn_interrupted"); }); }); + +describe("canonical interruption identity and turn clock", () => { + const clockedTurn: ActorTurnInProgress = { + actorId: "patient_maya_johnson_v1", + conversationTurn: 2, + startedAtSecond: 100, + startedAtMs: 100_000, + stationRunId: "run_barge_001", + turnId: "turn_maya_002", + planId: "plan_maya_002", + }; + + it("mints identity from run, turn, and canonical clock", () => { + const resolution = resolveLearnerBargeIn(clockedTurn, { atSecond: 105, atMs: 105_250 }); + expect(canonicalTurnClockMs({ atSecond: 105, atMs: 105_250 })).toBe(105_250); + expect(resolution.interruptionId).toBe("run_barge_001:turn_maya_002:105250:learner_barge_in"); + expect(resolution.clockMs).toBe(105_250); + expect(resolution.turnId).toBe("turn_maya_002"); + expect(resolution.planId).toBe("plan_maya_002"); + expect(resolution.cancellationDirective?.cancelModalities).toEqual([ + "audio", + "viseme", + "gaze", + "posture", + "affect", + ]); + expect(resolution.cancellationDirective?.action).toBe("audio.clear"); + }); + + it("treats a duplicate interruption id as idempotent", () => { + const input = { + atSecond: 105, + atMs: 105_250, + interruptionId: "run_barge_001:turn_maya_002:105250:learner_barge_in", + turnId: "turn_maya_002", + }; + const first = resolveLearnerBargeIn(clockedTurn, input); + const second = resolveLearnerBargeIn(clockedTurn, input, { + acceptedInterruption: { + interruptionId: first.interruptionId, + turnId: "turn_maya_002", + clockMs: 105_250, + }, + }); + expect(first.outcome).toBe("actor_turn_interrupted"); + expect(second.outcome).toBe("duplicate_interruption"); + expect(second.interruptionId).toBe(first.interruptionId); + expect(second.cancellationDirective).toEqual(first.cancellationDirective); + expect(second.truncatedResponse).toBe(true); + }); + + it("refuses a stale completed turn and does not emit a cancellation directive", () => { + const resolution = resolveLearnerBargeIn(clockedTurn, { + atSecond: 120, + turnId: "turn_maya_002", + }, { + completedTurnIds: ["turn_maya_002"], + }); + expect(resolution.outcome).toBe("stale_turn_refused"); + expect(resolution.cancellationDirective).toBeNull(); + expect(resolution.yieldedToLearner).toBe(false); + expect(resolution.truncatedResponse).toBe(false); + }); + + it("does not cancel a newer actor turn when the interruption names an older turn", () => { + const newerTurn: ActorTurnInProgress = { + ...clockedTurn, + conversationTurn: 3, + turnId: "turn_maya_003", + planId: "plan_maya_003", + startedAtSecond: 110, + startedAtMs: 110_000, + }; + const resolution = resolveLearnerBargeIn(newerTurn, { + atSecond: 112, + turnId: "turn_maya_002", + stationRunId: "run_barge_001", + }, { + activeTurnId: "turn_maya_003", + completedTurnIds: ["turn_maya_002"], + }); + expect(resolution.outcome).toBe("newer_turn_protected"); + expect(resolution.cancellationDirective).toBeNull(); + expect(resolution.interruptedActorId).toBeNull(); + expect(resolution.turnId).toBe("turn_maya_002"); + }); + + it("treats a late interruption on a completed turn as an idempotent no-op", () => { + const resolution = resolveLearnerBargeIn(null, { + atSecond: 130, + turnId: "turn_maya_002", + stationRunId: "run_barge_001", + }, { + completedTurnIds: ["turn_maya_002"], + acceptedInterruption: { + interruptionId: "other_interruption", + turnId: "turn_maya_002", + clockMs: 105_250, + }, + }); + expect(resolution.outcome).toBe("late_interruption"); + expect(resolution.cancellationDirective).toBeNull(); + expect(resolution.yieldedToLearner).toBe(false); + }); +}); diff --git a/packages/openclinxr/conversation-policy/src/barge-in.ts b/packages/openclinxr/conversation-policy/src/barge-in.ts index 8110cc4ef..e91bd3e77 100644 --- a/packages/openclinxr/conversation-policy/src/barge-in.ts +++ b/packages/openclinxr/conversation-policy/src/barge-in.ts @@ -1,40 +1,20 @@ -import { - CONVERSATION_CLAIM_SCOPE, - CONVERSATION_NOT_EVIDENCE_FOR, - type ActorTurnInProgress, - type BargeInResolution, - type LearnerBargeInInput, +import { resolveCanonicalLearnerInterruption } from "./canonical-interruption.js"; +import type { + ActorTurnInProgress, + BargeInContext, + BargeInResolution, + LearnerBargeInInput, } from "./types.js"; /** * Resolve a learner barge-in against an in-progress actor turn. * Produces a DISTINCT traced outcome (tag: learner_barge_in) vs normal turns. + * Duplicate/late interruptions are idempotent; a stale target never cancels a newer turn. */ export function resolveLearnerBargeIn( inProgress: ActorTurnInProgress | null | undefined, bargeInInput: LearnerBargeInInput, + context: BargeInContext = {}, ): BargeInResolution { - if (!inProgress) { - return { - outcome: "no_active_turn_to_interrupt", - bargeInTraceTag: "learner_barge_in", - interruptedActorId: null, - interruptedAtSecond: bargeInInput.atSecond, - truncatedResponse: false, - yieldedToLearner: false, - claimScope: CONVERSATION_CLAIM_SCOPE.bargeIn, - notEvidenceFor: CONVERSATION_NOT_EVIDENCE_FOR, - }; - } - - return { - outcome: "actor_turn_interrupted", - bargeInTraceTag: "learner_barge_in", - interruptedActorId: inProgress.actorId, - interruptedAtSecond: bargeInInput.atSecond, - truncatedResponse: true, - yieldedToLearner: true, - claimScope: CONVERSATION_CLAIM_SCOPE.bargeIn, - notEvidenceFor: CONVERSATION_NOT_EVIDENCE_FOR, - }; + return resolveCanonicalLearnerInterruption(inProgress, bargeInInput, context); } diff --git a/packages/openclinxr/conversation-policy/src/canonical-interruption.ts b/packages/openclinxr/conversation-policy/src/canonical-interruption.ts new file mode 100644 index 000000000..2247b9070 --- /dev/null +++ b/packages/openclinxr/conversation-policy/src/canonical-interruption.ts @@ -0,0 +1,202 @@ +import { + CONVERSATION_CLAIM_SCOPE, + CONVERSATION_NOT_EVIDENCE_FOR, + TURN_MODALITIES_CANCELLED_ON_BARGE_IN, + type ActorTurnInProgress, + type BargeInContext, + type BargeInResolution, + type CanonicalInterruptionIdentity, + type LearnerBargeInInput, + type TurnCancellationDirective, +} from "./types.js"; + +const UNSCOPED_RUN = "unscoped"; +const NO_TURN = "no_turn"; +export const CANONICAL_TURN_CLOCK_MS_PER_SECOND = 1000; + +/** Station-run turn clock. Explicit atMs wins; otherwise atSecond * 1000. */ +export function canonicalTurnClockMs(input: Pick): number { + if (typeof input.atMs === "number" && Number.isFinite(input.atMs)) { + return Math.max(0, Math.trunc(input.atMs)); + } + return Math.max(0, Math.trunc(input.atSecond * CANONICAL_TURN_CLOCK_MS_PER_SECOND)); +} + +export function canonicalInterruptionId(parts: { + stationRunId?: string; + turnId?: string; + clockMs: number; +}): string { + const run = nonempty(parts.stationRunId) ?? UNSCOPED_RUN; + const turn = nonempty(parts.turnId) ?? NO_TURN; + return `${run}:${turn}:${parts.clockMs}:learner_barge_in`; +} + +export function canonicalInterruptionIdentity( + inProgress: ActorTurnInProgress | null | undefined, + bargeInInput: LearnerBargeInInput, +): CanonicalInterruptionIdentity { + const clockMs = canonicalTurnClockMs(bargeInInput); + const stationRunId = + nonempty(bargeInInput.stationRunId) ?? nonempty(inProgress?.stationRunId) ?? UNSCOPED_RUN; + const turnId = + nonempty(bargeInInput.turnId) ?? nonempty(inProgress?.turnId) ?? NO_TURN; + const interruptionId = + nonempty(bargeInInput.interruptionId) ?? + canonicalInterruptionId({ stationRunId, turnId, clockMs }); + return { interruptionId, turnId, stationRunId, clockMs }; +} + +export function buildTurnCancellationDirective( + identity: CanonicalInterruptionIdentity, + planId: string | null, +): TurnCancellationDirective { + return { + interruptionId: identity.interruptionId, + turnId: identity.turnId, + planId, + clockMs: identity.clockMs, + reason: "learner_barge_in", + action: "audio.clear", + cancelModalities: TURN_MODALITIES_CANCELLED_ON_BARGE_IN, + }; +} + +export function resolveCanonicalLearnerInterruption( + inProgress: ActorTurnInProgress | null | undefined, + bargeInInput: LearnerBargeInInput, + context: BargeInContext = {}, +): BargeInResolution { + const identity = canonicalInterruptionIdentity(inProgress, bargeInInput); + const accepted = context.acceptedInterruption ?? acceptedFromTurn(inProgress); + const activeTurnId = nonempty(context.activeTurnId) ?? nonempty(inProgress?.turnId); + const targetTurnId = nonempty(bargeInInput.turnId) ?? nonempty(inProgress?.turnId); + const completed = new Set(context.completedTurnIds ?? []); + + if (accepted && accepted.interruptionId === identity.interruptionId) { + return resolution({ + outcome: "duplicate_interruption", + identity, + inProgress, + bargeInInput, + truncatedResponse: true, + yieldedToLearner: true, + cancellationDirective: buildTurnCancellationDirective(identity, inProgress?.planId ?? null), + }); + } + + if (!inProgress) { + if (targetTurnId && completed.has(targetTurnId)) { + return resolution({ + outcome: "late_interruption", + identity: { ...identity, turnId: targetTurnId }, + inProgress, + bargeInInput, + truncatedResponse: false, + yieldedToLearner: false, + cancellationDirective: null, + }); + } + return resolution({ + outcome: "no_active_turn_to_interrupt", + identity, + inProgress, + bargeInInput, + truncatedResponse: false, + yieldedToLearner: false, + cancellationDirective: null, + }); + } + + if (targetTurnId && activeTurnId && targetTurnId !== activeTurnId) { + return resolution({ + outcome: "newer_turn_protected", + identity: { ...identity, turnId: targetTurnId }, + inProgress, + bargeInInput, + truncatedResponse: false, + yieldedToLearner: false, + cancellationDirective: null, + }); + } + + if (targetTurnId && completed.has(targetTurnId)) { + return resolution({ + outcome: "stale_turn_refused", + identity: { ...identity, turnId: targetTurnId }, + inProgress, + bargeInInput, + truncatedResponse: false, + yieldedToLearner: false, + cancellationDirective: null, + }); + } + + const acceptedTurnId = nonempty(inProgress.turnId) ?? identity.turnId; + const acceptedIdentity: CanonicalInterruptionIdentity = { + ...identity, + turnId: acceptedTurnId, + }; + return resolution({ + outcome: "actor_turn_interrupted", + identity: acceptedIdentity, + inProgress, + bargeInInput, + truncatedResponse: true, + yieldedToLearner: true, + cancellationDirective: buildTurnCancellationDirective( + acceptedIdentity, + inProgress.planId ?? null, + ), + }); +} + +function acceptedFromTurn( + inProgress: ActorTurnInProgress | null | undefined, +): BargeInContext["acceptedInterruption"] { + const interruptionId = nonempty(inProgress?.acceptedInterruptionId); + if (!interruptionId || !inProgress) { + return undefined; + } + return { + interruptionId, + turnId: nonempty(inProgress.turnId) ?? NO_TURN, + clockMs: inProgress.acceptedInterruptionAtMs ?? 0, + }; +} + +function resolution(args: { + outcome: BargeInResolution["outcome"]; + identity: CanonicalInterruptionIdentity; + inProgress: ActorTurnInProgress | null | undefined; + bargeInInput: LearnerBargeInInput; + truncatedResponse: boolean; + yieldedToLearner: boolean; + cancellationDirective: TurnCancellationDirective | null; +}): BargeInResolution { + const interrupted = + args.outcome === "actor_turn_interrupted" || args.outcome === "duplicate_interruption"; + return { + outcome: args.outcome, + bargeInTraceTag: "learner_barge_in", + interruptedActorId: interrupted ? (args.inProgress?.actorId ?? null) : null, + interruptedAtSecond: args.bargeInInput.atSecond, + truncatedResponse: args.truncatedResponse, + yieldedToLearner: args.yieldedToLearner, + interruptionId: args.identity.interruptionId, + turnId: args.identity.turnId === NO_TURN ? (args.inProgress?.turnId ?? null) : args.identity.turnId, + planId: args.inProgress?.planId ?? null, + clockMs: args.identity.clockMs, + cancellationDirective: args.cancellationDirective, + claimScope: CONVERSATION_CLAIM_SCOPE.bargeIn, + notEvidenceFor: CONVERSATION_NOT_EVIDENCE_FOR, + }; +} + +function nonempty(value: string | null | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/openclinxr/conversation-policy/src/index.ts b/packages/openclinxr/conversation-policy/src/index.ts index 247fb0496..2daa12b71 100644 --- a/packages/openclinxr/conversation-policy/src/index.ts +++ b/packages/openclinxr/conversation-policy/src/index.ts @@ -3,6 +3,19 @@ export { type ArbitrateTurnTakingInput, } from "./turn-taking.js"; export { resolveLearnerBargeIn } from "./barge-in.js"; +export { + learnerBargeInInputFromStt, + resolveLearnerBargeInFromStt, + type LearnerSttBargeInRecord, +} from "./learner-stt-barge-in.js"; +export { + buildTurnCancellationDirective, + canonicalInterruptionId, + canonicalInterruptionIdentity, + canonicalTurnClockMs, + resolveCanonicalLearnerInterruption, + CANONICAL_TURN_CLOCK_MS_PER_SECOND, +} from "./canonical-interruption.js"; export { buildHistoryTakingCoverageSpec, coverageTraceTagForDomain, @@ -13,9 +26,12 @@ export { export { CONVERSATION_CLAIM_SCOPE, CONVERSATION_NOT_EVIDENCE_FOR, + TURN_MODALITIES_CANCELLED_ON_BARGE_IN, type ActorTurnInProgress, + type BargeInContext, type BargeInOutcome, type BargeInResolution, + type CanonicalInterruptionIdentity, type ConversationActorRef, type ConversationNotEvidenceFor, type HistoryTakingCoverageSpec, @@ -24,6 +40,8 @@ export { type HistoryTakingCoverageUpdateResult, type HistoryTakingDomain, type LearnerBargeInInput, + type TurnCancelModality, + type TurnCancellationDirective, type TurnTakingDecision, type TurnTakingReason, } from "./types.js"; diff --git a/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.test.ts b/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.test.ts new file mode 100644 index 000000000..a30c0856d --- /dev/null +++ b/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + learnerBargeInInputFromStt, + resolveLearnerBargeInFromStt, +} from "./learner-stt-barge-in.js"; +import type { ActorTurnInProgress } from "./types.js"; + +describe("resolveLearnerBargeInFromStt", () => { + const inProgress: ActorTurnInProgress = { + actorId: "patient_maya_johnson_v1", + conversationTurn: 2, + startedAtSecond: 100, + startedAtMs: 100_000, + stationRunId: "run_barge_001", + turnId: "turn_maya_002", + planId: "plan_maya_002", + }; + + const stt = { + stationRunId: "run_barge_001", + transcript: "Wait — how long has this been going on?", + interruption: { + interruptionId: "run_barge_001:turn_maya_002:105250:learner_barge_in", + turnId: "turn_maya_002", + clockMs: 105_250, + }, + }; + + it("carries STT interruption identity into a policy cancellation directive", () => { + const input = learnerBargeInInputFromStt(stt); + expect(input).toEqual({ + atSecond: 105, + atMs: 105_250, + interruptionId: stt.interruption.interruptionId, + stationRunId: "run_barge_001", + turnId: "turn_maya_002", + learnerUtterance: stt.transcript, + }); + const resolution = resolveLearnerBargeInFromStt(stt, inProgress); + expect(resolution.outcome).toBe("actor_turn_interrupted"); + expect(resolution.interruptionId).toBe(stt.interruption.interruptionId); + expect(resolution.clockMs).toBe(105_250); + expect(resolution.cancellationDirective?.action).toBe("audio.clear"); + expect(resolution.cancellationDirective?.cancelModalities).toEqual([ + "audio", + "viseme", + "gaze", + "posture", + "affect", + ]); + }); + + it("does not interrupt when STT has no barge-in interruption", () => { + const resolution = resolveLearnerBargeInFromStt( + { stationRunId: "run_barge_001", interruption: null }, + inProgress, + ); + expect(resolution.outcome).toBe("no_active_turn_to_interrupt"); + expect(resolution.cancellationDirective).toBeNull(); + }); +}); diff --git a/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.ts b/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.ts new file mode 100644 index 000000000..a79f5d402 --- /dev/null +++ b/packages/openclinxr/conversation-policy/src/learner-stt-barge-in.ts @@ -0,0 +1,47 @@ +import { resolveLearnerBargeIn } from "./barge-in.js"; +import { CANONICAL_TURN_CLOCK_MS_PER_SECOND } from "./canonical-interruption.js"; +import type { + ActorTurnInProgress, + BargeInContext, + BargeInResolution, + LearnerBargeInInput, +} from "./types.js"; + +/** STT barge-in record as consumed by conversation-policy (structural, no voice-gateway import). */ +export type LearnerSttBargeInRecord = { + stationRunId: string; + transcript?: string; + interruption: { + interruptionId: string; + turnId: string | null; + clockMs: number; + } | null; +}; + +export function learnerBargeInInputFromStt( + stt: LearnerSttBargeInRecord, +): LearnerBargeInInput | null { + if (!stt.interruption) { + return null; + } + return { + atSecond: Math.trunc(stt.interruption.clockMs / CANONICAL_TURN_CLOCK_MS_PER_SECOND), + atMs: stt.interruption.clockMs, + interruptionId: stt.interruption.interruptionId, + stationRunId: stt.stationRunId, + ...(stt.interruption.turnId ? { turnId: stt.interruption.turnId } : {}), + ...(stt.transcript ? { learnerUtterance: stt.transcript } : {}), + }; +} + +export function resolveLearnerBargeInFromStt( + stt: LearnerSttBargeInRecord, + inProgress: ActorTurnInProgress | null | undefined, + context: BargeInContext = {}, +): BargeInResolution { + const input = learnerBargeInInputFromStt(stt); + if (!input) { + return resolveLearnerBargeIn(null, { atSecond: 0, stationRunId: stt.stationRunId }, context); + } + return resolveLearnerBargeIn(inProgress, input, context); +} diff --git a/packages/openclinxr/conversation-policy/src/types.ts b/packages/openclinxr/conversation-policy/src/types.ts index 7fc9fc91a..5feb6cda5 100644 --- a/packages/openclinxr/conversation-policy/src/types.ts +++ b/packages/openclinxr/conversation-policy/src/types.ts @@ -45,14 +45,70 @@ export type ActorTurnInProgress = { expectedResponseText?: string; learnerUtterance?: string; stationRunId?: string; + turnId?: string; + planId?: string; + startedAtMs?: number; + acceptedInterruptionId?: string; + acceptedInterruptionAtMs?: number; }; export type LearnerBargeInInput = { atSecond: number; learnerUtterance?: string; + /** Canonical turn-clock ms. When omitted, derived as atSecond * 1000. */ + atMs?: number; + /** Stable interruption identity. When omitted, derived from run/turn/clock. */ + interruptionId?: string; + /** Target actor turn. When omitted, the in-progress turn is the target. */ + turnId?: string; + stationRunId?: string; +}; + +export const TURN_MODALITIES_CANCELLED_ON_BARGE_IN = [ + "audio", + "viseme", + "gaze", + "posture", + "affect", +] as const; + +export type TurnCancelModality = (typeof TURN_MODALITIES_CANCELLED_ON_BARGE_IN)[number]; + +export type CanonicalInterruptionIdentity = { + interruptionId: string; + turnId: string; + stationRunId: string; + clockMs: number; +}; + +export type TurnCancellationDirective = { + interruptionId: string; + turnId: string; + planId: string | null; + clockMs: number; + reason: "learner_barge_in"; + action: "audio.clear"; + cancelModalities: typeof TURN_MODALITIES_CANCELLED_ON_BARGE_IN; +}; + +export type BargeInContext = { + completedTurnIds?: readonly string[]; + /** Currently executing turn that must not be cancelled unless it is the target. */ + activeTurnId?: string; + acceptedInterruption?: { + interruptionId: string; + turnId: string; + clockMs: number; + }; }; -export type BargeInOutcome = "actor_turn_interrupted" | "no_active_turn_to_interrupt"; +export type BargeInOutcome = + | "actor_turn_interrupted" + | "no_active_turn_to_interrupt" + | "duplicate_interruption" + | "late_interruption" + | "stale_turn_refused" + | "newer_turn_protected"; export type BargeInResolution = { outcome: BargeInOutcome; @@ -61,6 +117,11 @@ export type BargeInResolution = { interruptedAtSecond: number; truncatedResponse: boolean; yieldedToLearner: boolean; + interruptionId: string; + turnId: string | null; + planId: string | null; + clockMs: number; + cancellationDirective: TurnCancellationDirective | null; claimScope: typeof CONVERSATION_CLAIM_SCOPE.bargeIn; notEvidenceFor: ConversationNotEvidenceFor; }; diff --git a/packages/openclinxr/voice-gateway/package.json b/packages/openclinxr/voice-gateway/package.json index e40fd6211..eae0ec87a 100644 --- a/packages/openclinxr/voice-gateway/package.json +++ b/packages/openclinxr/voice-gateway/package.json @@ -17,7 +17,8 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@cellix/provider-contracts": "workspace:*" + "@cellix/provider-contracts": "workspace:*", + "@openclinxr/conversation-policy": "workspace:*" }, "devDependencies": { "typescript": "catalog:", diff --git a/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.test.ts b/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.test.ts new file mode 100644 index 000000000..8b8c0a0a6 --- /dev/null +++ b/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ACTOR_TURN_TIMELINE_ORIGIN_MS, + executeFrozenActorTurn, + type ActorTurnExecutionAdapters, + type FrozenActorTurnPlanForExecution, +} from "./actor-turn-execution.js"; +import { + ACTOR_TURN_CANCEL_MODALITIES, + applyTurnCancellationDirective, + replayKeyForCancellation, + type ActorTurnCancellationAdapters, + type ActorTurnCancellationDirective, +} from "./actor-turn-cancellation.js"; + +const SPOKEN = "The inhaler is in my backpack."; + +function planFixture(): FrozenActorTurnPlanForExecution { + return { + planId: "plan_maya_wob_001", + turnId: "turn_maya_wob_001", + actorId: "patient_maya_johnson_v1", + spokenText: SPOKEN, + spokenTextForTts: `${SPOKEN} [breath]`, + voiceId: "mock-maya-johnson", + dialogueEmotionTo: "anxious", + facePresetId: "face.anxious", + posePresetId: "pose_upright_child", + performancePlanId: "perf_anxious_child_mid", + gestureClipIds: ["gesture_clasp_v1"], + prosody: { + wrapTags: [""], + inlineTags: ["[breath]"], + speed: 0.95, + droppedTags: ["[cry]"], + }, + languageProvenance: { fallbackUsed: false, providerId: "mock-model" }, + notEvidenceFor: ["clinical_affect_inference", "empathy_score", "licensure"], + }; +} + +function deepFreezePlan(plan: FrozenActorTurnPlanForExecution): FrozenActorTurnPlanForExecution { + Object.freeze(plan.gestureClipIds); + Object.freeze(plan.prosody.wrapTags); + Object.freeze(plan.prosody.inlineTags); + Object.freeze(plan.prosody.droppedTags); + Object.freeze(plan.prosody); + Object.freeze(plan.languageProvenance); + Object.freeze(plan.notEvidenceFor); + return Object.freeze(plan); +} + +function runtimeAdapters(): ActorTurnExecutionAdapters { + return { + startVoice: vi.fn(() => true), + startProsody: vi.fn(() => true), + startViseme: vi.fn(() => true), + startFacialAffect: vi.fn(() => true), + startGazePosture: vi.fn(() => true), + startMotion: vi.fn(() => true), + }; +} + +function stopAdapters(): ActorTurnCancellationAdapters { + return { + stopVoice: vi.fn(() => true), + stopViseme: vi.fn(() => true), + stopFacialAffect: vi.fn(() => true), + stopGaze: vi.fn(() => true), + stopPosture: vi.fn(() => true), + stopMotion: vi.fn(() => true), + }; +} + +function directive( + overrides: Partial = {}, +): ActorTurnCancellationDirective { + return { + interruptionId: "run_barge_001:turn_maya_wob_001:420:learner_barge_in", + turnId: "turn_maya_wob_001", + planId: "plan_maya_wob_001", + clockMs: 420, + reason: "learner_barge_in", + action: "audio.clear", + cancelModalities: ACTOR_TURN_CANCEL_MODALITIES, + ...overrides, + }; +} + +describe("applyTurnCancellationDirective", () => { + it("stops audio, viseme, gaze, posture, and affect on one canonical clock and keeps partial provenance", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const stops = stopAdapters(); + const applied = await applyTurnCancellationDirective(envelope, directive(), { adapters: stops }); + + expect(applied.accepted).toBe(true); + expect(applied.reason).toBe("applied"); + expect(applied.clockMs).toBe(420); + expect(applied.cancelledModalities).toEqual(["audio", "viseme", "gaze", "posture", "affect"]); + expect(applied.actorTurnExecution.interruption.kind).toBe("truncated"); + expect(applied.actorTurnExecution).not.toBe(envelope.actorTurnExecution); + expect(applied.partialProvenance.deliveredAudioChunkCount).toBe(envelope.audioEvents.length); + expect(applied.partialProvenance.startedLanes).toEqual([ + "voice", + "prosody", + "viseme", + "facial_affect", + "gaze_posture", + "motion", + ]); + expect(applied.partialProvenance.truncatedAtMs).toBe(420); + expect(stops.stopVoice).toHaveBeenCalledWith(expect.objectContaining({ + planId: envelope.identity.planId, + turnId: envelope.identity.turnId, + timelineOriginMs: ACTOR_TURN_TIMELINE_ORIGIN_MS, + })); + expect(stops.stopViseme).toHaveBeenCalledTimes(1); + expect(stops.stopGaze).toHaveBeenCalledTimes(1); + expect(stops.stopPosture).toHaveBeenCalledTimes(1); + expect(stops.stopFacialAffect).toHaveBeenCalledTimes(1); + expect(envelope.actorTurnExecution.interruption.kind).toBe("none"); + expect(envelope.identity.spokenText).toBe(SPOKEN); + }); + + it("replay of the same directive on the same envelope is equivalent", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const first = await applyTurnCancellationDirective(envelope, directive(), { adapters: stopAdapters() }); + const second = await applyTurnCancellationDirective(envelope, directive(), { adapters: stopAdapters() }); + expect(second).toEqual(first); + expect(first.replayKey).toBe(replayKeyForCancellation({ + planId: envelope.identity.planId, + turnId: envelope.identity.turnId, + interruptionId: directive().interruptionId, + clockMs: 420, + deliveredAudioChunkCount: envelope.audioEvents.length, + })); + }); + + it("duplicate interruption id is idempotent and does not stop modalities again", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const stops = stopAdapters(); + const first = await applyTurnCancellationDirective(envelope, directive(), { adapters: stops }); + const again = await applyTurnCancellationDirective(envelope, directive(), { + adapters: stops, + appliedInterruptionId: first.interruptionId, + }); + expect(again.reason).toBe("duplicate"); + expect(again.accepted).toBe(true); + expect(again.actorTurnExecution).toEqual(first.actorTurnExecution); + expect(stops.stopVoice).toHaveBeenCalledTimes(1); + }); + + it("refuses a stale turn and does not cancel the envelope modalities", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const stops = stopAdapters(); + const refused = await applyTurnCancellationDirective( + envelope, + directive({ turnId: "turn_maya_wob_000" }), + { adapters: stops, activeTurnId: "turn_maya_wob_002" }, + ); + expect(refused.accepted).toBe(false); + expect(refused.reason).toBe("stale_turn"); + expect(refused.cancelledModalities).toEqual([]); + expect(refused.actorTurnExecution.interruption.kind).toBe("none"); + expect(stops.stopVoice).not.toHaveBeenCalled(); + expect(stops.stopViseme).not.toHaveBeenCalled(); + }); + + it("treats a late interruption on an already truncated execution as idempotent", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + bargeInAtChunkIndex: 2, + adapters: runtimeAdapters(), + }); + const stops = stopAdapters(); + expect(envelope.actorTurnExecution.interruption.kind).toBe("truncated"); + const late = await applyTurnCancellationDirective( + envelope, + directive({ interruptionId: "run_barge_001:turn_maya_wob_001:900:learner_barge_in", clockMs: 900 }), + { adapters: stops }, + ); + expect(late.accepted).toBe(true); + expect(late.reason).toBe("late"); + expect(late.actorTurnExecution).toBe(envelope.actorTurnExecution); + expect(stops.stopVoice).not.toHaveBeenCalled(); + }); + + it("does not cancel a newer actor turn when the directive names an older turn", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const stops = stopAdapters(); + const refused = await applyTurnCancellationDirective( + envelope, + directive({ turnId: "turn_maya_wob_000" }), + { adapters: stops }, + ); + expect(refused.accepted).toBe(false); + expect(refused.reason).toBe("newer_turn_protected"); + expect(refused.actorTurnExecution).toBe(envelope.actorTurnExecution); + expect(refused.actorTurnExecution.interruption.kind).toBe("none"); + expect(stops.stopVoice).not.toHaveBeenCalled(); + expect(stops.stopGaze).not.toHaveBeenCalled(); + expect(stops.stopPosture).not.toHaveBeenCalled(); + expect(stops.stopFacialAffect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.ts b/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.ts new file mode 100644 index 000000000..f5d1a0a2e --- /dev/null +++ b/packages/openclinxr/voice-gateway/src/actor-turn-cancellation.ts @@ -0,0 +1,273 @@ +/** + * Apply a learner barge-in cancellation directive to a started actor-turn + * execution. Duplicate/late applications are idempotent. A directive that + * names a stale turn never stops a newer turn's modalities. + * + * claimScope: simulated_actor_behavior. + * notEvidenceFor: clinical affect, Quest lip-sync, live paid TTS, production latency. + */ + +import type { + ActorTurnExecutionEnvelope, + ActorTurnExecutionStartContext, + ActorTurnModality, + BoundedActorTurnExecution, +} from "./actor-turn-execution.js"; + +export const ACTOR_TURN_CANCEL_MODALITIES = [ + "audio", + "viseme", + "gaze", + "posture", + "affect", +] as const; + +export type ActorTurnCancelModality = (typeof ACTOR_TURN_CANCEL_MODALITIES)[number]; + +export type ActorTurnCancellationDirective = { + interruptionId: string; + turnId: string; + planId?: string | null; + clockMs: number; + reason: "learner_barge_in"; + action: "audio.clear"; + cancelModalities: readonly ActorTurnCancelModality[]; +}; + +export type ActorTurnCancellationAdapters = { + stopVoice?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; + stopViseme?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; + stopFacialAffect?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; + stopGaze?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; + stopPosture?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; + stopMotion?: (ctx: ActorTurnExecutionStartContext) => boolean | Promise; +}; + +export type ApplyTurnCancellationOptions = { + adapters?: ActorTurnCancellationAdapters; + appliedInterruptionId?: string; + /** Currently executing turn. Must not be cancelled unless it is the directive target. */ + activeTurnId?: string; +}; + +export type CancellationApplicationReason = + | "applied" + | "duplicate" + | "late" + | "stale_turn" + | "newer_turn_protected"; + +export type PartialExecutionProvenance = { + deliveredAudioChunkCount: number; + startedLanes: readonly ActorTurnModality[]; + truncatedAtMs: number; +}; + +export type CancellationApplication = { + accepted: boolean; + reason: CancellationApplicationReason; + interruptionId: string; + turnId: string; + clockMs: number; + actorTurnExecution: BoundedActorTurnExecution; + cancelledModalities: readonly ActorTurnCancelModality[]; + partialProvenance: PartialExecutionProvenance; + replayKey: string; + claimScope: "simulated_actor_behavior"; + notEvidenceFor: readonly string[]; +}; + +const NOT_EVIDENCE_FOR = [ + "clinical_affect_inference", + "quest_lip_sync", + "live_paid_tts", + "production_latency", +] as const; + +export function replayKeyForCancellation(parts: { + planId: string; + turnId: string; + interruptionId: string; + clockMs: number; + deliveredAudioChunkCount: number; +}): string { + return `${parts.planId}:${parts.turnId}:${parts.interruptionId}:${parts.clockMs}:${parts.deliveredAudioChunkCount}`; +} + +export async function applyTurnCancellationDirective( + envelope: ActorTurnExecutionEnvelope, + directive: ActorTurnCancellationDirective, + options: ApplyTurnCancellationOptions = {}, +): Promise { + const provenance = partialProvenance(envelope, directive.clockMs); + const replayKey = replayKeyForCancellation({ + planId: envelope.identity.planId, + turnId: envelope.identity.turnId, + interruptionId: directive.interruptionId, + clockMs: directive.clockMs, + deliveredAudioChunkCount: provenance.deliveredAudioChunkCount, + }); + + if (options.appliedInterruptionId === directive.interruptionId) { + return freezeApplication({ + accepted: true, + reason: "duplicate", + interruptionId: directive.interruptionId, + turnId: directive.turnId, + clockMs: directive.clockMs, + actorTurnExecution: truncatedExecution(envelope), + cancelledModalities: [...ACTOR_TURN_CANCEL_MODALITIES], + partialProvenance: provenance, + replayKey, + }); + } + + if (directive.turnId !== envelope.identity.turnId) { + const liveTurnId = options.activeTurnId ?? envelope.identity.turnId; + const liveIsThisEnvelope = liveTurnId === envelope.identity.turnId; + return freezeApplication({ + accepted: false, + reason: liveIsThisEnvelope ? "newer_turn_protected" : "stale_turn", + interruptionId: directive.interruptionId, + turnId: directive.turnId, + clockMs: directive.clockMs, + actorTurnExecution: envelope.actorTurnExecution, + cancelledModalities: [], + partialProvenance: provenance, + replayKey, + }); + } + + if (options.activeTurnId && options.activeTurnId !== directive.turnId) { + return freezeApplication({ + accepted: false, + reason: "newer_turn_protected", + interruptionId: directive.interruptionId, + turnId: directive.turnId, + clockMs: directive.clockMs, + actorTurnExecution: envelope.actorTurnExecution, + cancelledModalities: [], + partialProvenance: provenance, + replayKey, + }); + } + + if (envelope.actorTurnExecution.interruption.kind !== "none") { + return freezeApplication({ + accepted: true, + reason: "late", + interruptionId: directive.interruptionId, + turnId: directive.turnId, + clockMs: directive.clockMs, + actorTurnExecution: envelope.actorTurnExecution, + cancelledModalities: [...ACTOR_TURN_CANCEL_MODALITIES], + partialProvenance: provenance, + replayKey, + }); + } + + const ctx: ActorTurnExecutionStartContext = { + planId: envelope.identity.planId, + turnId: envelope.identity.turnId, + actorId: envelope.identity.actorId, + spokenText: envelope.identity.spokenText, + voiceId: envelope.identity.voiceId, + facePresetId: envelope.identity.facePresetId, + posePresetId: envelope.identity.posePresetId, + performancePlanId: envelope.identity.performancePlanId, + timelineOriginMs: envelope.timelineOriginMs, + }; + + await stopModalities(directive.cancelModalities, options.adapters ?? {}, ctx); + + return freezeApplication({ + accepted: true, + reason: "applied", + interruptionId: directive.interruptionId, + turnId: directive.turnId, + clockMs: directive.clockMs, + actorTurnExecution: truncatedExecution(envelope), + cancelledModalities: [...ACTOR_TURN_CANCEL_MODALITIES], + partialProvenance: provenance, + replayKey, + }); +} + +function partialProvenance( + envelope: ActorTurnExecutionEnvelope, + truncatedAtMs: number, +): PartialExecutionProvenance { + return { + deliveredAudioChunkCount: envelope.audioEvents.length, + startedLanes: envelope.lanes.map((lane) => lane.modality), + truncatedAtMs, + }; +} + +function truncatedExecution(envelope: ActorTurnExecutionEnvelope): BoundedActorTurnExecution { + const execution: BoundedActorTurnExecution = { + planId: envelope.actorTurnExecution.planId, + turnId: envelope.actorTurnExecution.turnId, + interruption: { kind: "truncated" }, + renderedProsodyTags: [...envelope.actorTurnExecution.renderedProsodyTags], + droppedProsodyTags: [...envelope.actorTurnExecution.droppedProsodyTags], + fallback: { ...envelope.actorTurnExecution.fallback }, + }; + Object.freeze(execution.interruption); + Object.freeze(execution.renderedProsodyTags); + Object.freeze(execution.droppedProsodyTags); + Object.freeze(execution.fallback); + return Object.freeze(execution); +} + +function freezeApplication( + application: Omit, +): CancellationApplication { + const full: CancellationApplication = { + ...application, + claimScope: "simulated_actor_behavior", + notEvidenceFor: NOT_EVIDENCE_FOR, + }; + Object.freeze(full.cancelledModalities); + Object.freeze(full.partialProvenance); + Object.freeze(full.notEvidenceFor); + return Object.freeze(full); +} + +async function stopModalities( + modalities: readonly ActorTurnCancelModality[], + adapters: ActorTurnCancellationAdapters, + ctx: ActorTurnExecutionStartContext, +): Promise { + const unique = new Set(modalities); + if (unique.has("audio")) { + await invokeStop(adapters.stopVoice, ctx); + } + if (unique.has("viseme")) { + await invokeStop(adapters.stopViseme, ctx); + } + if (unique.has("affect")) { + await invokeStop(adapters.stopFacialAffect, ctx); + } + if (unique.has("gaze")) { + await invokeStop(adapters.stopGaze, ctx); + } + if (unique.has("posture")) { + await invokeStop(adapters.stopPosture, ctx); + await invokeStop(adapters.stopMotion, ctx); + } +} + +async function invokeStop( + adapter: ((ctx: ActorTurnExecutionStartContext) => boolean | Promise) | undefined, + ctx: ActorTurnExecutionStartContext, +): Promise { + if (!adapter) { + return; + } + try { + await adapter(ctx); + } catch { + // Fail-closed stop: a throwing adapter does not resume playback. + } +} diff --git a/packages/openclinxr/voice-gateway/src/actor-turn-execution.ts b/packages/openclinxr/voice-gateway/src/actor-turn-execution.ts index a5d76e149..7f17a940c 100644 --- a/packages/openclinxr/voice-gateway/src/actor-turn-execution.ts +++ b/packages/openclinxr/voice-gateway/src/actor-turn-execution.ts @@ -20,6 +20,19 @@ import { type ActorTurnPlanSpeech, } from "./adapters.js"; import type { AudioEvent } from "./types.js"; +import { + carryLearnerSttInterruptionOntoActorTurn, + type CarryLearnerSttInterruptionInput, + type CarryLearnerSttInterruptionResult, +} from "./carry-learner-stt-interruption.js"; + +export { + carryLearnerSttInterruptionOntoActorTurn, + LEARNER_BARGE_IN_EXECUTION_EVENT, + type CarryLearnerSttInterruptionInput, + type CarryLearnerSttInterruptionResult, + type LearnerBargeInExecutionEvent, +} from "./carry-learner-stt-interruption.js"; /** Bounded DVA-6 execution. `fallback.tts` is true only when voice is dropped. */ export type BoundedActorTurnExecution = { @@ -94,6 +107,8 @@ export type ExecuteFrozenActorTurnOptions = { bargeInAtChunkIndex?: number; available?: Partial>; adapters?: ActorTurnExecutionAdapters; + /** When set, STT barge-in is resolved through conversation-policy and applied to this envelope. */ + learnerBargeIn?: Omit; }; export type ActorTurnExecutionEnvelope = { @@ -118,6 +133,7 @@ export type ActorTurnExecutionEnvelope = { }; claimScope: "simulated_actor_behavior"; notEvidenceFor: readonly string[]; + learnerBargeIn?: CarryLearnerSttInterruptionResult; }; export async function executeFrozenActorTurn( @@ -213,7 +229,7 @@ export async function executeFrozenActorTurn( droppedModalities.push({ modality: "motion", reason: "adapter_failed" }); } - return { + const envelope: ActorTurnExecutionEnvelope = { seam: ACTOR_TURN_EXECUTION_SEAM, actorTurnExecution, timelineOriginMs: ACTOR_TURN_TIMELINE_ORIGIN_MS, @@ -236,6 +252,16 @@ export async function executeFrozenActorTurn( claimScope: "simulated_actor_behavior", notEvidenceFor: [...plan.notEvidenceFor], }; + + if (!options.learnerBargeIn) { + return envelope; + } + + const learnerBargeIn = await carryLearnerSttInterruptionOntoActorTurn({ + ...options.learnerBargeIn, + envelope, + }); + return { ...envelope, learnerBargeIn }; } function assertPlanFrozenForMultimodalExecution(plan: FrozenActorTurnPlanForExecution): void { diff --git a/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.test.ts b/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.test.ts new file mode 100644 index 000000000..2e1fc5329 --- /dev/null +++ b/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ActorTurnInProgress } from "@openclinxr/conversation-policy"; +import { + executeFrozenActorTurn, + type ActorTurnExecutionAdapters, + type FrozenActorTurnPlanForExecution, +} from "./actor-turn-execution.js"; +import { carryLearnerSttInterruptionOntoActorTurn } from "./carry-learner-stt-interruption.js"; +import type { ActorTurnCancellationAdapters } from "./actor-turn-cancellation.js"; +import { transcribeLearnerAudio } from "./learner-stt-adapter.js"; + +const SPOKEN = "The inhaler is in my backpack."; + +function planFixture(): FrozenActorTurnPlanForExecution { + return { + planId: "plan_maya_wob_001", + turnId: "turn_maya_wob_001", + actorId: "patient_maya_johnson_v1", + spokenText: SPOKEN, + spokenTextForTts: `${SPOKEN} [breath]`, + voiceId: "mock-maya-johnson", + dialogueEmotionTo: "anxious", + facePresetId: "face.anxious", + posePresetId: "pose_upright_child", + performancePlanId: "perf_anxious_child_mid", + gestureClipIds: ["gesture_clasp_v1"], + prosody: { + wrapTags: [""], + inlineTags: ["[breath]"], + speed: 0.95, + droppedTags: ["[cry]"], + }, + languageProvenance: { fallbackUsed: false, providerId: "mock-model" }, + notEvidenceFor: ["clinical_affect_inference", "empathy_score", "licensure"], + }; +} + +function deepFreezePlan(plan: FrozenActorTurnPlanForExecution): FrozenActorTurnPlanForExecution { + Object.freeze(plan.gestureClipIds); + Object.freeze(plan.prosody.wrapTags); + Object.freeze(plan.prosody.inlineTags); + Object.freeze(plan.prosody.droppedTags); + Object.freeze(plan.prosody); + Object.freeze(plan.languageProvenance); + Object.freeze(plan.notEvidenceFor); + return Object.freeze(plan); +} + +function runtimeAdapters(): ActorTurnExecutionAdapters { + return { + startVoice: vi.fn(() => true), + startProsody: vi.fn(() => true), + startViseme: vi.fn(() => true), + startFacialAffect: vi.fn(() => true), + startGazePosture: vi.fn(() => true), + startMotion: vi.fn(() => true), + }; +} + +function stopAdapters(): ActorTurnCancellationAdapters { + return { + stopVoice: vi.fn(() => true), + stopViseme: vi.fn(() => true), + stopFacialAffect: vi.fn(() => true), + stopGaze: vi.fn(() => true), + stopPosture: vi.fn(() => true), + stopMotion: vi.fn(() => true), + }; +} + +function inProgressTurn(): ActorTurnInProgress { + return { + actorId: "patient_maya_johnson_v1", + conversationTurn: 2, + startedAtSecond: 100, + startedAtMs: 100_000, + stationRunId: "run_barge_001", + turnId: "turn_maya_wob_001", + planId: "plan_maya_wob_001", + }; +} + +function bargeInStt(overrides: { turnId?: string; atMs?: number } = {}) { + return transcribeLearnerAudio({ + stationRunId: "run_barge_001", + streamId: "learner-mic-001", + pcmOrFixtureId: "fixture:chest-onset", + isFinal: false, + bargeIn: true, + atMs: overrides.atMs ?? 105_250, + turnId: overrides.turnId ?? "turn_maya_wob_001", + }); +} + +describe("carryLearnerSttInterruptionOntoActorTurn", () => { + it("carries STT barge-in through policy onto the execution envelope and appends a replayable event", async () => { + const stops = stopAdapters(); + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + learnerBargeIn: { + stt: bargeInStt(), + inProgress: inProgressTurn(), + adapters: stops, + }, + }); + + const carry = envelope.learnerBargeIn; + expect(carry).toBeDefined(); + expect(carry?.resolution.outcome).toBe("actor_turn_interrupted"); + expect(carry?.event.eventType).toBe("actor_turn.learner_barge_in"); + expect(carry?.event.interruptionId).toBe("run_barge_001:turn_maya_wob_001:105250:learner_barge_in"); + expect(carry?.event.turnId).toBe("turn_maya_wob_001"); + expect(carry?.event.planId).toBe("plan_maya_wob_001"); + expect(carry?.event.clockMs).toBe(105_250); + expect(carry?.event.cancelledModalities).toEqual(["audio", "viseme", "gaze", "posture", "affect"]); + expect(carry?.event.actorTurnExecution.interruption.kind).toBe("truncated"); + expect(carry?.event.partialProvenance.startedLanes).toContain("voice"); + expect(carry?.event.partialProvenance.truncatedAtMs).toBe(105_250); + expect(Object.isFrozen(carry?.event)).toBe(true); + expect(envelope.actorTurnExecution.interruption.kind).toBe("none"); + expect(stops.stopVoice).toHaveBeenCalledTimes(1); + expect(stops.stopGaze).toHaveBeenCalledTimes(1); + }); + + it("replays the same STT interruption onto the same envelope equivalently", async () => { + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const input = { + stt: bargeInStt(), + inProgress: inProgressTurn(), + envelope, + adapters: stopAdapters(), + }; + const first = await carryLearnerSttInterruptionOntoActorTurn(input); + const second = await carryLearnerSttInterruptionOntoActorTurn({ + ...input, + adapters: stopAdapters(), + }); + expect(second.event).toEqual(first.event); + expect(second.resolution.interruptionId).toBe(first.resolution.interruptionId); + }); + + it("treats a duplicate STT interruption as idempotent on the composed path", async () => { + const stops = stopAdapters(); + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const stt = bargeInStt(); + const first = await carryLearnerSttInterruptionOntoActorTurn({ + stt, + inProgress: inProgressTurn(), + envelope, + adapters: stops, + }); + const duplicate = await carryLearnerSttInterruptionOntoActorTurn({ + stt, + inProgress: inProgressTurn(), + envelope, + adapters: stops, + appliedInterruptionId: first.resolution.interruptionId, + context: { + acceptedInterruption: { + interruptionId: first.resolution.interruptionId, + turnId: "turn_maya_wob_001", + clockMs: 105_250, + }, + }, + }); + expect(duplicate.resolution.outcome).toBe("duplicate_interruption"); + expect(duplicate.application?.reason).toBe("duplicate"); + expect(duplicate.event.cancelledModalities).toEqual(first.event.cancelledModalities); + expect(stops.stopVoice).toHaveBeenCalledTimes(1); + }); + + it("refuses a stale completed turn on the composed path without cancelling modalities", async () => { + const stops = stopAdapters(); + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const result = await carryLearnerSttInterruptionOntoActorTurn({ + stt: bargeInStt(), + inProgress: inProgressTurn(), + envelope, + adapters: stops, + context: { completedTurnIds: ["turn_maya_wob_001"] }, + }); + expect(result.resolution.outcome).toBe("stale_turn_refused"); + expect(result.application).toBeNull(); + expect(result.event.cancelledModalities).toEqual([]); + expect(result.event.actorTurnExecution.interruption.kind).toBe("none"); + expect(stops.stopVoice).not.toHaveBeenCalled(); + }); + + it("does not cancel a newer actor turn when STT names an older turn", async () => { + const stops = stopAdapters(); + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const newer: ActorTurnInProgress = { + ...inProgressTurn(), + conversationTurn: 3, + turnId: "turn_maya_wob_001", + startedAtSecond: 110, + }; + const result = await carryLearnerSttInterruptionOntoActorTurn({ + stt: bargeInStt({ turnId: "turn_maya_wob_000" }), + inProgress: newer, + envelope, + adapters: stops, + activeTurnId: "turn_maya_wob_001", + context: { + activeTurnId: "turn_maya_wob_001", + completedTurnIds: ["turn_maya_wob_000"], + }, + }); + expect(result.resolution.outcome).toBe("newer_turn_protected"); + expect(result.application).toBeNull(); + expect(result.event.cancelledModalities).toEqual([]); + expect(stops.stopVoice).not.toHaveBeenCalled(); + expect(stops.stopFacialAffect).not.toHaveBeenCalled(); + }); + + it("treats a late STT interruption on a completed turn as an idempotent no-op", async () => { + const stops = stopAdapters(); + const envelope = await executeFrozenActorTurn(deepFreezePlan(planFixture()), { + adapters: runtimeAdapters(), + }); + const result = await carryLearnerSttInterruptionOntoActorTurn({ + stt: bargeInStt({ atMs: 130_000 }), + inProgress: null, + envelope, + adapters: stops, + context: { + completedTurnIds: ["turn_maya_wob_001"], + acceptedInterruption: { + interruptionId: "run_barge_001:turn_maya_wob_001:105250:learner_barge_in", + turnId: "turn_maya_wob_001", + clockMs: 105_250, + }, + }, + }); + expect(result.resolution.outcome).toBe("late_interruption"); + expect(result.application).toBeNull(); + expect(result.event.cancelledModalities).toEqual([]); + expect(stops.stopVoice).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.ts b/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.ts new file mode 100644 index 000000000..f3ebdb667 --- /dev/null +++ b/packages/openclinxr/voice-gateway/src/carry-learner-stt-interruption.ts @@ -0,0 +1,120 @@ +/** + * Package-level composition: STT barge-in → conversation-policy → cancellation + * applied to the exact ActorTurnExecution envelope, appending a replayable event. + */ + +import { + resolveLearnerBargeInFromStt, + type ActorTurnInProgress, + type BargeInContext, + type BargeInOutcome, + type BargeInResolution, + type LearnerSttBargeInRecord, + type TurnCancellationDirective, +} from "@openclinxr/conversation-policy"; +import type { ActorTurnExecutionEnvelope, BoundedActorTurnExecution } from "./actor-turn-execution.js"; +import { + applyTurnCancellationDirective, + type ActorTurnCancelModality, + type ActorTurnCancellationAdapters, + type ActorTurnCancellationDirective, + type CancellationApplication, + type PartialExecutionProvenance, +} from "./actor-turn-cancellation.js"; + +export const LEARNER_BARGE_IN_EXECUTION_EVENT = "actor_turn.learner_barge_in" as const; + +export type LearnerBargeInExecutionEvent = { + eventType: typeof LEARNER_BARGE_IN_EXECUTION_EVENT; + interruptionId: string; + turnId: string | null; + planId: string | null; + clockMs: number; + outcome: BargeInOutcome; + cancelledModalities: readonly ActorTurnCancelModality[]; + partialProvenance: PartialExecutionProvenance; + actorTurnExecution: BoundedActorTurnExecution; + replayKey: string; + claimScope: "simulated_actor_behavior"; + notEvidenceFor: readonly string[]; +}; + +export type CarryLearnerSttInterruptionInput = { + stt: LearnerSttBargeInRecord; + inProgress: ActorTurnInProgress | null | undefined; + envelope: ActorTurnExecutionEnvelope; + context?: BargeInContext; + adapters?: ActorTurnCancellationAdapters; + appliedInterruptionId?: string; + activeTurnId?: string; +}; + +export type CarryLearnerSttInterruptionResult = { + resolution: BargeInResolution; + application: CancellationApplication | null; + event: LearnerBargeInExecutionEvent; +}; + +export async function carryLearnerSttInterruptionOntoActorTurn( + input: CarryLearnerSttInterruptionInput, +): Promise { + const resolution = resolveLearnerBargeInFromStt( + input.stt, + input.inProgress, + input.context ?? {}, + ); + + let application: CancellationApplication | null = null; + if (resolution.cancellationDirective) { + application = await applyTurnCancellationDirective( + input.envelope, + toVoiceDirective(resolution.cancellationDirective), + { + ...(input.adapters ? { adapters: input.adapters } : {}), + ...(input.appliedInterruptionId ? { appliedInterruptionId: input.appliedInterruptionId } : {}), + ...(input.activeTurnId ? { activeTurnId: input.activeTurnId } : {}), + }, + ); + } + + const event = freezeEvent({ + eventType: LEARNER_BARGE_IN_EXECUTION_EVENT, + interruptionId: resolution.interruptionId, + turnId: resolution.turnId, + planId: resolution.planId ?? input.envelope.identity.planId, + clockMs: resolution.clockMs, + outcome: resolution.outcome, + cancelledModalities: application?.cancelledModalities ?? [], + partialProvenance: application?.partialProvenance ?? { + deliveredAudioChunkCount: input.envelope.audioEvents.length, + startedLanes: input.envelope.lanes.map((lane) => lane.modality), + truncatedAtMs: resolution.clockMs, + }, + actorTurnExecution: application?.actorTurnExecution ?? input.envelope.actorTurnExecution, + replayKey: application?.replayKey + ?? `${input.envelope.identity.planId}:${input.envelope.identity.turnId}:${resolution.interruptionId}:${resolution.clockMs}:${input.envelope.audioEvents.length}`, + claimScope: "simulated_actor_behavior", + notEvidenceFor: application?.notEvidenceFor ?? input.envelope.notEvidenceFor, + }); + + return { resolution, application, event }; +} + +function toVoiceDirective(directive: TurnCancellationDirective): ActorTurnCancellationDirective { + return { + interruptionId: directive.interruptionId, + turnId: directive.turnId, + planId: directive.planId, + clockMs: directive.clockMs, + reason: directive.reason, + action: directive.action, + cancelModalities: directive.cancelModalities, + }; +} + +function freezeEvent(event: LearnerBargeInExecutionEvent): LearnerBargeInExecutionEvent { + Object.freeze(event.cancelledModalities); + Object.freeze(event.partialProvenance); + Object.freeze(event.notEvidenceFor); + return Object.freeze(event); +} diff --git a/packages/openclinxr/voice-gateway/src/index.ts b/packages/openclinxr/voice-gateway/src/index.ts index bb30f949e..c7c11b650 100644 --- a/packages/openclinxr/voice-gateway/src/index.ts +++ b/packages/openclinxr/voice-gateway/src/index.ts @@ -2,3 +2,5 @@ export * from "./types.js"; export * from "./gateway.js"; export * from "./adapters.js"; export * from "./learner-stt-adapter.js"; +export * from "./actor-turn-execution.js"; +export * from "./actor-turn-cancellation.js"; diff --git a/packages/openclinxr/voice-gateway/src/learner-stt-adapter.ts b/packages/openclinxr/voice-gateway/src/learner-stt-adapter.ts index 1968adba0..d6ee3a0be 100644 --- a/packages/openclinxr/voice-gateway/src/learner-stt-adapter.ts +++ b/packages/openclinxr/voice-gateway/src/learner-stt-adapter.ts @@ -23,6 +23,16 @@ export type LearnerSttInput = { pcmOrFixtureId: string | Uint8Array; isFinal: boolean; bargeIn?: boolean; + /** Canonical turn-clock ms for a barge-in. Omitted barge-in uses 0. */ + atMs?: number; + turnId?: string; + interruptionId?: string; +}; + +export type LearnerSttInterruption = { + interruptionId: string; + turnId: string | null; + clockMs: number; }; export type LearnerSttProvenance = { @@ -40,6 +50,7 @@ export type LearnerSttRecord = { stationRunId: string; streamId: string; provenance: LearnerSttProvenance; + interruption: LearnerSttInterruption | null; }; /** Deterministic fixture catalog. Two utterances for skeptic-visible dual transcripts. */ @@ -59,8 +70,10 @@ export function transcribeLearnerAudio(input: LearnerSttInput): LearnerSttRecord const emptyOrUnintelligible = resolved.unintelligible || resolved.transcript.trim().length === 0; let eventKindHint: LearnerSttEventKindHint | null = null; + let interruption: LearnerSttInterruption | null = null; if (input.bargeIn === true) { eventKindHint = "learner_interruption"; + interruption = mintLearnerSttInterruption(input); } else if (input.isFinal && emptyOrUnintelligible) { eventKindHint = "learner_unclassified"; } @@ -78,9 +91,21 @@ export function transcribeLearnerAudio(input: LearnerSttInput): LearnerSttRecord fixtureId: resolved.fixtureId, unintelligible: emptyOrUnintelligible, }, + interruption, }; } +function mintLearnerSttInterruption(input: LearnerSttInput): LearnerSttInterruption { + const clockMs = typeof input.atMs === "number" && Number.isFinite(input.atMs) + ? Math.max(0, Math.trunc(input.atMs)) + : 0; + const turnId = input.turnId?.trim() ? input.turnId.trim() : null; + const interruptionId = input.interruptionId?.trim() + ? input.interruptionId.trim() + : `${input.stationRunId}:${turnId ?? input.streamId}:${clockMs}:learner_barge_in`; + return { interruptionId, turnId, clockMs }; +} + function resolveTranscript(pcmOrFixtureId: string | Uint8Array): { transcript: string; fixtureId: string; diff --git a/packages/openclinxr/voice-gateway/src/the-learner-stt-emits-classifier-ready-transcripts.test.ts b/packages/openclinxr/voice-gateway/src/the-learner-stt-emits-classifier-ready-transcripts.test.ts index df45a2de8..579c6be92 100644 --- a/packages/openclinxr/voice-gateway/src/the-learner-stt-emits-classifier-ready-transcripts.test.ts +++ b/packages/openclinxr/voice-gateway/src/the-learner-stt-emits-classifier-ready-transcripts.test.ts @@ -107,6 +107,28 @@ describe("learner STT adapter emits classifier-ready transcripts", () => { expect(withSilence.eventKindHint).toBe("learner_interruption"); expect(withText.transcript).toBe(LEARNER_STT_FIXTURES["fixture:chest-onset"]); expect(withText.eventKindHint).not.toBe("learner_unclassified"); + expect(withText.interruption).toEqual({ + interruptionId: "run_learner_stt_001:learner-mic-001:0:learner_barge_in", + turnId: null, + clockMs: 0, + }); + }); + + it("mints a canonical barge-in identity on the turn clock", () => { + const record = transcribeLearnerAudio({ + ...station, + pcmOrFixtureId: "fixture:chest-onset", + isFinal: false, + bargeIn: true, + atMs: 105_250, + turnId: "turn_maya_002", + }); + expect(record.eventKindHint).toBe("learner_interruption"); + expect(record.interruption).toEqual({ + interruptionId: "run_learner_stt_001:turn_maya_002:105250:learner_barge_in", + turnId: "turn_maya_002", + clockMs: 105_250, + }); }); it("decodes non-empty UTF-8 unary transcript bytes into a classifier-ready record", () => { diff --git a/packages/openclinxr/voice-gateway/tsconfig.json b/packages/openclinxr/voice-gateway/tsconfig.json index 3b229a153..588bd9ab0 100644 --- a/packages/openclinxr/voice-gateway/tsconfig.json +++ b/packages/openclinxr/voice-gateway/tsconfig.json @@ -20,6 +20,9 @@ "references": [ { "path": "../../cellix/provider-contracts" + }, + { + "path": "../conversation-policy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e527a584..41c84b12a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,9 @@ importers: '@cellix/provider-contracts': specifier: workspace:* version: link:../../cellix/provider-contracts + '@openclinxr/conversation-policy': + specifier: workspace:* + version: link:../conversation-policy devDependencies: '@cellix/config-typescript': specifier: workspace:*