Files
podman/frontend/src/livekit/useInterventions.ts
T
sb-iam d9776452b2 feat(continual-learning): derive wasRealCollision from git overlap (RSI step 3)
Backend becomes authoritative for the verifier signal. recordOutcome now
overrides the client-supplied wasRealCollision with deriveWasRealCollision():
a flagged collision counts as REAL only if BOTH named engineers currently have
the collided file in their git changedFiles (getGitStates, 120s freshness TTL).
Conservative false when the collision is orphaned/missing or git state is
stale. Restores the (accepted x wasReal) 2x2 the spec assumes instead of the
hardcoded 107/107 true.

frontend/useInterventions.ts stops sending a hardcoded `true` (now a
backend-overridden placeholder). Spec: continual-learning/spec.md:98-108,
policy.md:35-42. backend+frontend typecheck + eslint pass. PLAN.md P0.5 rung 3
added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:47:53 -05:00

89 lines
3.3 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { RoomEvent, type Room } from 'livekit-client';
import type { DataMessage, HermesMessage, Intervention, InterventionStatus } from '@podman/shared';
import { DATA_TOPIC } from '@podman/shared';
import { createSyncPr, postOutcome } from '../lib/api';
const browserTtsFallbackEnabled = import.meta.env.VITE_ENABLE_BROWSER_TTS_FALLBACK === 'true';
/** Speak a cue in the browser only when the explicit fallback flag is enabled. */
export function speakInBrowser(text: string): void {
if (!browserTtsFallbackEnabled) return;
if (typeof window === 'undefined' || !('speechSynthesis' in window) || !text) return;
const u = new SpeechSynthesisUtterance(text);
u.rate = 1.05;
window.speechSynthesis.cancel(); // drop any queued cue so the latest wins
window.speechSynthesis.speak(u);
}
/** Unlock speechSynthesis from a user gesture when the browser fallback is enabled. */
export function primeSpeech(): void {
if (!browserTtsFallbackEnabled) return;
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return;
const u = new SpeechSynthesisUtterance(' ');
u.volume = 0;
window.speechSynthesis.speak(u);
}
export function useInterventions(room: Room | null) {
const [active, setActive] = useState<Intervention | null>(null);
const [hermes, setHermes] = useState<HermesMessage | null>(null);
const [voiceCue, setVoiceCue] = useState<string | null>(null);
const [actionUrl, setActionUrl] = useState<string | null>(null);
useEffect(() => {
if (!room) return;
const onData = (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
if (topic !== DATA_TOPIC) return;
const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;
if (msg.type === 'COLLISION') {
setActive(msg.intervention);
setActionUrl(null);
}
if (msg.type === 'HERMES_MESSAGE') setHermes(msg.message);
if (msg.type === 'VOICE_CUE') {
setVoiceCue(msg.text);
speakInBrowser(msg.text);
}
};
room.on(RoomEvent.DataReceived, onData);
return () => {
room.off(RoomEvent.DataReceived, onData);
};
}, [room]);
const respond = useCallback(
async (status: InterventionStatus, accepted: boolean) => {
if (!active) return;
if (accepted && active.suggestedAction.kind === 'open_sync_pr') {
const pr = await createSyncPr({
file: String(active.suggestedAction.params?.file ?? ''),
summary: String(active.suggestedAction.params?.summary ?? active.message),
});
setActionUrl(pr.url);
}
await postOutcome({
interventionId: active.id,
collisionId: active.collisionId,
podId: active.podId,
// Placeholder only — the backend derives the authoritative value from
// git overlap at outcome time (the client cannot know). (RSI Step 3)
wasRealCollision: false,
accepted,
recordedAt: new Date().toISOString(),
});
await room?.localParticipant.publishData(
new TextEncoder().encode(
JSON.stringify({ type: 'ACK', interventionId: active.id, status }),
),
{ reliable: true, topic: DATA_TOPIC },
);
setActive(null);
return status;
},
[active, room],
);
return { active, hermes, voiceCue, actionUrl, respond };
}