d9776452b2
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>
89 lines
3.3 KiB
TypeScript
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 };
|
|
}
|