diff --git a/skills/meet-join/__tests__/config-schema.test.ts b/skills/meet-join/__tests__/config-schema.test.ts index ed2ac091774..6ff5cfbe82a 100644 --- a/skills/meet-join/__tests__/config-schema.test.ts +++ b/skills/meet-join/__tests__/config-schema.test.ts @@ -2,9 +2,18 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_MEET_OBJECTION_KEYWORDS, + DEFAULT_MEET_PROACTIVE_CHAT_KEYWORDS, MeetServiceSchema, } from "../config-schema.js"; +const DEFAULT_PROACTIVE_CHAT = { + enabled: true, + detectorKeywords: [...DEFAULT_MEET_PROACTIVE_CHAT_KEYWORDS], + tier2DebounceMs: 5_000, + escalationCooldownSec: 30, + tier2MaxTranscriptSec: 30, +}; + describe("MeetServiceSchema", () => { test("empty object parses to the documented defaults (feature off by default)", () => { const parsed = MeetServiceSchema.parse({}); @@ -18,6 +27,7 @@ describe("MeetServiceSchema", () => { objectionKeywords: [...DEFAULT_MEET_OBJECTION_KEYWORDS], dockerNetwork: "bridge", maxMeetingMinutes: 240, + proactiveChat: DEFAULT_PROACTIVE_CHAT, }); }); @@ -46,7 +56,7 @@ describe("MeetServiceSchema", () => { maxMeetingMinutes: 60, }; const parsed = MeetServiceSchema.parse(input); - expect(parsed).toEqual(input); + expect(parsed).toEqual({ ...input, proactiveChat: DEFAULT_PROACTIVE_CHAT }); }); test("rejects negative maxMeetingMinutes", () => { diff --git a/skills/meet-join/config-schema.ts b/skills/meet-join/config-schema.ts index 3fb7655715a..26f5a067bda 100644 --- a/skills/meet-join/config-schema.ts +++ b/skills/meet-join/config-schema.ts @@ -36,6 +36,21 @@ export const DEFAULT_MEET_OBJECTION_KEYWORDS: readonly string[] = [ "don't want this recorded", ]; +/** + * Default Tier 1 regex keyword patterns for the proactive-chat opportunity + * detector. Each entry is compiled as a case-insensitive {@link RegExp} at + * runtime. Patterns are intentionally broad — false positives only trigger + * a Tier 2 LLM confirmation, so we bias toward coverage. + */ +export const DEFAULT_MEET_PROACTIVE_CHAT_KEYWORDS: readonly string[] = [ + // Direct "can you / could you / would you / will you" requests + "\\b(can|could|would|will)\\s+you\\b", + // Collective requests addressed to anyone in the meeting + "\\bcan\\s+(anyone|someone)\\b", + "\\bdoes\\s+(anyone|someone)\\s+know\\b", + "\\banyone\\s+(have|know)\\b", +]; + /** * Normalize `joinName` — coerce empty or whitespace-only strings to `null` so * downstream code only has to check for `null` when deciding whether to fall @@ -110,6 +125,83 @@ export const MeetServiceSchema = z .describe( "Hard ceiling in minutes — the bot container is killed once this elapses, regardless of meeting state", ), + proactiveChat: z + .object({ + enabled: z + .boolean({ + error: "services.meet.proactiveChat.enabled must be a boolean", + }) + .default(true) + .describe( + "Whether the assistant proactively watches meeting transcript and chat for opportunities to respond via meeting chat.", + ), + detectorKeywords: z + .array( + z.string({ + error: + "services.meet.proactiveChat.detectorKeywords values must be strings", + }), + ) + .default([...DEFAULT_MEET_PROACTIVE_CHAT_KEYWORDS]) + .describe( + "Tier 1 regex patterns (case-insensitive) that trigger a Tier 2 LLM confirmation when matched against transcript or chat text.", + ), + tier2DebounceMs: z + .number({ + error: + "services.meet.proactiveChat.tier2DebounceMs must be a number", + }) + .int( + "services.meet.proactiveChat.tier2DebounceMs must be an integer", + ) + .nonnegative( + "services.meet.proactiveChat.tier2DebounceMs must be non-negative", + ) + .default(5_000) + .describe( + "Minimum milliseconds between consecutive Tier 2 LLM calls. Tier 1 hits arriving within this window are collapsed into a single LLM call.", + ), + escalationCooldownSec: z + .number({ + error: + "services.meet.proactiveChat.escalationCooldownSec must be a number", + }) + .int( + "services.meet.proactiveChat.escalationCooldownSec must be an integer", + ) + .nonnegative( + "services.meet.proactiveChat.escalationCooldownSec must be non-negative", + ) + .default(30) + .describe( + "Seconds between consecutive positive escalations. A Tier 2 positive verdict arriving within this window of the previous escalation is suppressed.", + ), + tier2MaxTranscriptSec: z + .number({ + error: + "services.meet.proactiveChat.tier2MaxTranscriptSec must be a number", + }) + .int( + "services.meet.proactiveChat.tier2MaxTranscriptSec must be an integer", + ) + .positive( + "services.meet.proactiveChat.tier2MaxTranscriptSec must be positive", + ) + .default(30) + .describe( + "Rolling transcript window (seconds) included in the Tier 2 LLM prompt.", + ), + }) + .default({ + enabled: true, + detectorKeywords: [...DEFAULT_MEET_PROACTIVE_CHAT_KEYWORDS], + tier2DebounceMs: 5_000, + escalationCooldownSec: 30, + tier2MaxTranscriptSec: 30, + }) + .describe( + "Proactive-chat opportunity detector tuning. The detector uses a Tier 1 regex fast filter plus a Tier 2 LLM confirmation before the assistant posts in meeting chat.", + ), }) .describe( "Google Meet bot configuration — controls the containerized Meet joining bot, consent messaging, and objection handling", diff --git a/skills/meet-join/daemon/__tests__/chat-opportunity-detector.test.ts b/skills/meet-join/daemon/__tests__/chat-opportunity-detector.test.ts new file mode 100644 index 00000000000..f16c36f714d --- /dev/null +++ b/skills/meet-join/daemon/__tests__/chat-opportunity-detector.test.ts @@ -0,0 +1,576 @@ +/** + * Unit tests for {@link MeetChatOpportunityDetector}. + * + * These tests inject a fake dispatcher (recording subscribers by meeting), + * a scripted Tier 2 LLM callable, and a controllable clock so every + * scenario is deterministic. Real provider abstractions are never + * constructed. + */ + +import { describe, expect, mock, test } from "bun:test"; + +import type { MeetBotEvent } from "@vellumai/meet-contracts"; + +import { + type ChatOpportunityDecision, + MeetChatOpportunityDetector, + type ProactiveChatConfig, +} from "../chat-opportunity-detector.js"; +import type { + MeetEventSubscriber, + MeetEventUnsubscribe, +} from "../event-publisher.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeFakeDispatcher(): { + subscribe: ( + meetingId: string, + cb: MeetEventSubscriber, + ) => MeetEventUnsubscribe; + dispatch: (meetingId: string, event: MeetBotEvent) => void; + subscriberCount: (meetingId: string) => number; +} { + const subs = new Map>(); + return { + subscribe(meetingId, cb) { + let set = subs.get(meetingId); + if (!set) { + set = new Set(); + subs.set(meetingId, set); + } + set.add(cb); + return () => { + const existing = subs.get(meetingId); + if (!existing) return; + existing.delete(cb); + if (existing.size === 0) subs.delete(meetingId); + }; + }, + dispatch(meetingId, event) { + const set = subs.get(meetingId); + if (!set) return; + for (const cb of Array.from(set)) cb(event); + }, + subscriberCount(meetingId) { + return subs.get(meetingId)?.size ?? 0; + }, + }; +} + +function makeClock(initial: number): { + now: () => number; + advance: (ms: number) => void; + set: (value: number) => void; +} { + let t = initial; + return { + now: () => t, + advance(ms) { + t += ms; + }, + set(value) { + t = value; + }, + }; +} + +function transcriptChunk( + meetingId: string, + timestamp: string, + text: string, + options: { + isFinal?: boolean; + speakerLabel?: string; + speakerId?: string; + } = {}, +): MeetBotEvent { + return { + type: "transcript.chunk", + meetingId, + timestamp, + isFinal: options.isFinal ?? true, + text, + speakerLabel: options.speakerLabel, + speakerId: options.speakerId, + }; +} + +function inboundChat( + meetingId: string, + timestamp: string, + text: string, + fromName = "Alice", + fromId = "a", +): MeetBotEvent { + return { + type: "chat.inbound", + meetingId, + timestamp, + fromId, + fromName, + text, + }; +} + +function defaultConfig( + overrides: Partial = {}, +): ProactiveChatConfig { + return { + enabled: true, + detectorKeywords: [ + "\\b(can|could|would|will)\\s+you\\b", + "\\bcan\\s+(anyone|someone)\\b", + "\\bdoes\\s+(anyone|someone)\\s+know\\b", + "\\banyone\\s+(have|know)\\b", + ], + tier2DebounceMs: 5_000, + escalationCooldownSec: 30, + tier2MaxTranscriptSec: 30, + ...overrides, + }; +} + +async function flushPromises(): Promise { + for (let i = 0; i < 3; i++) await Promise.resolve(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("MeetChatOpportunityDetector — Tier 1 fast filter", () => { + test("Tier 1 miss does not invoke Tier 2 and does not fire callback", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "should not be called", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig(), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "The weather is nice today.", + ), + ); + dispatcher.dispatch( + "m1", + inboundChat("m1", "2024-01-01T00:00:01.000Z", "hello team"), + ); + + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(0); + expect(onOpportunity).toHaveBeenCalledTimes(0); + expect(detector.getStats().tier1Hits).toBe(0); + + detector.dispose(); + expect(dispatcher.subscriberCount("m1")).toBe(0); + }); + + test("Tier 1 hit + Tier 2 false does not fire callback", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: false, + reason: "user was talking to another human", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig(), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "Hey Alice, can you send the deck?", + ), + ); + + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(1); + expect(onOpportunity).toHaveBeenCalledTimes(0); + + const stats = detector.getStats(); + expect(stats.tier1Hits).toBe(1); + expect(stats.tier2Calls).toBe(1); + expect(stats.tier2PositiveCount).toBe(0); + expect(stats.escalationsFired).toBe(0); + + detector.dispose(); + }); + + test("Tier 1 hit + Tier 2 true fires callback with decision reason", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "team is asking for a spec link the assistant can provide", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig(), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:00.000Z", + "Does anyone know where the design doc lives?", + ), + ); + + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(1); + expect(onOpportunity).toHaveBeenCalledTimes(1); + const [reason] = onOpportunity.mock.calls[0] as unknown as [string]; + expect(reason).toBe( + "team is asking for a spec link the assistant can provide", + ); + + const stats = detector.getStats(); + expect(stats.tier1Hits).toBe(1); + expect(stats.tier2Calls).toBe(1); + expect(stats.tier2PositiveCount).toBe(1); + expect(stats.escalationsFired).toBe(1); + expect(stats.escalationsSuppressed).toBe(0); + + detector.dispose(); + }); + + test("direct assistant name mention triggers Tier 1", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "assistant was directly addressed", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig(), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "I was chatting with Velissa earlier.", + ), + ); + + await flushPromises(); + + expect(detector.getStats().tier1Hits).toBe(1); + expect(llm).toHaveBeenCalledTimes(1); + detector.dispose(); + }); +}); + +describe("MeetChatOpportunityDetector — debounce + cooldown", () => { + test("two Tier 1 hits within debounce window produce only one Tier 2 call", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: false, + reason: "not applicable", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig({ tier2DebounceMs: 5_000 }), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "Can you send the deck?", + ), + ); + await flushPromises(); + + clock.advance(1_000); + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:01.000Z", + "Could you share the link?", + ), + ); + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(1); + const stats = detector.getStats(); + expect(stats.tier1Hits).toBe(2); + expect(stats.tier2Calls).toBe(1); + + // Advance past the debounce window and confirm a new hit actually calls. + clock.advance(5_000); + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:07.000Z", + "Can you paste the link?", + ), + ); + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(2); + detector.dispose(); + }); + + test("two Tier 2 positives within cooldown window fire callback only once", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "assistant should respond", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + // Use a short debounce so the second hit actually reaches Tier 2, + // letting us exercise the escalation cooldown rather than the + // debounce guard above it. + config: defaultConfig({ + tier2DebounceMs: 100, + escalationCooldownSec: 30, + }), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:00.000Z", + "Does anyone know the release date?", + ), + ); + await flushPromises(); + + clock.advance(500); + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:00.500Z", + "Can anyone confirm the release date?", + ), + ); + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(2); + expect(onOpportunity).toHaveBeenCalledTimes(1); + + const stats = detector.getStats(); + expect(stats.tier2PositiveCount).toBe(2); + expect(stats.escalationsFired).toBe(1); + expect(stats.escalationsSuppressed).toBe(1); + + // Advance past cooldown → next positive should fire again. + clock.advance(30_000); + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:30.500Z", + "Can anyone confirm timing?", + ), + ); + await flushPromises(); + + expect(onOpportunity).toHaveBeenCalledTimes(2); + detector.dispose(); + }); +}); + +describe("MeetChatOpportunityDetector — enabled=false", () => { + test("disabled detector performs no Tier 1, Tier 2, or callback work", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "should not be called", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig({ enabled: false }), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "Hey Velissa, can you send the deck?", + ), + ); + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:01.000Z", + "Does anyone know the link?", + ), + ); + + await flushPromises(); + + expect(llm).toHaveBeenCalledTimes(0); + expect(onOpportunity).toHaveBeenCalledTimes(0); + + const stats = detector.getStats(); + expect(stats.tier1Hits).toBe(0); + expect(stats.tier2Calls).toBe(0); + expect(stats.escalationsFired).toBe(0); + + detector.dispose(); + }); +}); + +describe("MeetChatOpportunityDetector — custom keywords", () => { + test("custom detectorKeywords accepted and used for Tier 1", async () => { + const dispatcher = makeFakeDispatcher(); + const clock = makeClock(1_000); + const llm = mock( + async (_prompt: string): Promise => ({ + shouldRespond: true, + reason: "custom trigger fired", + }), + ); + const onOpportunity = mock((_reason: string) => {}); + + const detector = new MeetChatOpportunityDetector({ + meetingId: "m1", + assistantDisplayName: "Velissa", + config: defaultConfig({ + // Only this custom pattern — none of the defaults are present. + detectorKeywords: ["\\bblue\\s+monkey\\b"], + }), + callDetectorLLM: llm, + onOpportunity, + subscribe: dispatcher.subscribe, + now: clock.now, + }); + detector.start(); + + // A phrase that would match the DEFAULT keywords must NOT fire here, + // because we replaced them entirely. + dispatcher.dispatch( + "m1", + transcriptChunk( + "m1", + "2024-01-01T00:00:00.000Z", + "Can you send the deck?", + ), + ); + await flushPromises(); + // Still matches the assistant-name pattern if name is "Velissa"? + // This phrase doesn't mention Velissa, so Tier 1 should not hit. + expect(detector.getStats().tier1Hits).toBe(0); + expect(llm).toHaveBeenCalledTimes(0); + + // The custom pattern should match. + dispatcher.dispatch( + "m1", + inboundChat( + "m1", + "2024-01-01T00:00:01.000Z", + "my favorite is the blue monkey at the zoo", + ), + ); + await flushPromises(); + + expect(detector.getStats().tier1Hits).toBe(1); + expect(llm).toHaveBeenCalledTimes(1); + expect(onOpportunity).toHaveBeenCalledTimes(1); + + detector.dispose(); + }); +}); diff --git a/skills/meet-join/daemon/chat-opportunity-detector.ts b/skills/meet-join/daemon/chat-opportunity-detector.ts new file mode 100644 index 00000000000..71c83ea356f --- /dev/null +++ b/skills/meet-join/daemon/chat-opportunity-detector.ts @@ -0,0 +1,516 @@ +/** + * MeetChatOpportunityDetector — watches meeting transcript and chat for + * moments when the AI assistant chiming in via meeting chat would be + * appropriate and helpful. Fires `onOpportunity(reason)` on positive + * verdicts so a downstream orchestrator (PR 7) can decide what to post. + * + * Two-tier design: + * + * 1. **Tier 1 (regex fast filter)** — synchronous on every final + * transcript chunk and every inbound chat message. Default patterns + * cover direct assistant-name mentions, `(hey|hi|…) , … ?` style + * address-then-question forms, and generic "can you / does anyone + * know" requests. A hit feeds Tier 2 with a short trigger reason. + * + * 2. **Tier 2 (LLM confirmation)** — fires on every Tier 1 hit, + * subject to a configurable debounce. The prompt includes the + * rolling transcript (last N seconds), the most recent 5 chat + * messages, the trigger chunk, and the Tier 1 reason, and asks for + * strict JSON `{ shouldRespond: boolean, reason: string }`. Positive + * verdicts are rate-limited further by an "escalation cooldown" so + * a chatty meeting can't fire the callback repeatedly. + * + * The detector is intentionally inert until wired: it does not itself + * post to meeting chat, consult any session manager, or share state with + * other meetings. PR 7 of the meet-phase-2-chat plan is responsible for + * plumbing `onOpportunity` into the session manager and actually + * constructing the chat reply. + * + * Dependency injection keeps the detector fully testable: the LLM call + * is reached via a `callDetectorLLM(prompt)` callable, and the router + * subscription can be overridden with an in-memory shim. + */ + +import type { + InboundChatEvent, + MeetBotEvent, + TranscriptChunkEvent, +} from "@vellumai/meet-contracts"; + +import { getLogger } from "../../../assistant/src/util/logger.js"; +import { + type MeetEventSubscriber, + type MeetEventUnsubscribe, + subscribeToMeetingEvents, +} from "./event-publisher.js"; + +const log = getLogger("meet-chat-opportunity-detector"); + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** Shape of the JSON the Tier 2 LLM returns. */ +export interface ChatOpportunityDecision { + shouldRespond: boolean; + reason: string; +} + +/** Tier 2 LLM callable. Tests inject scripted responses. */ +export type ChatOpportunityLLMAsk = ( + prompt: string, +) => Promise; + +/** Callback fired when an opportunity clears Tier 2 and cooldown. */ +export type ChatOpportunityCallback = (reason: string) => void; + +/** + * Configuration block mirrored from `services.meet.proactiveChat`. Carried + * independently so this file doesn't depend on the assistant-facing zod + * schema (which would pull the whole config surface into the skill bundle). + */ +export interface ProactiveChatConfig { + enabled: boolean; + detectorKeywords: readonly string[]; + tier2DebounceMs: number; + escalationCooldownSec: number; + tier2MaxTranscriptSec: number; +} + +/** Stats snapshot exposed to PR 7 for telemetry/debug surfaces. */ +export interface ChatOpportunityDetectorStats { + tier1Hits: number; + tier2Calls: number; + tier2PositiveCount: number; + escalationsFired: number; + escalationsSuppressed: number; +} + +export interface MeetChatOpportunityDetectorDeps { + meetingId: string; + /** + * Display name the bot is using in the meeting. Used to build the + * default name-mention and addressed-question Tier 1 regexes. Pass the + * value the bot actually joined with, not the assistant's internal id. + */ + assistantDisplayName: string; + config: ProactiveChatConfig; + callDetectorLLM: ChatOpportunityLLMAsk; + onOpportunity: ChatOpportunityCallback; + /** Override the dispatcher subscribe (tests). */ + subscribe?: ( + meetingId: string, + cb: MeetEventSubscriber, + ) => MeetEventUnsubscribe; + /** Override `Date.now` for deterministic tests. */ + now?: () => number; +} + +// --------------------------------------------------------------------------- +// Rolling buffers +// --------------------------------------------------------------------------- + +interface TranscriptEntry { + tMs: number; + timestamp: string; + speaker: string; + text: string; +} + +interface ChatEntry { + timestamp: string; + fromName: string; + text: string; +} + +/** Max chat messages preserved for Tier 2 prompt context. */ +const CHAT_BUFFER_SIZE = 5; + +// --------------------------------------------------------------------------- +// Regex helpers +// --------------------------------------------------------------------------- + +/** Escape a raw string so it can be embedded as a literal in a RegExp. */ +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Compile a list of pattern strings into case-insensitive {@link RegExp} + * instances. Invalid patterns are dropped with a warning log — a single + * bad entry must not disable the detector. + */ +function compilePatterns( + patterns: readonly string[], + meetingId: string, +): RegExp[] { + const compiled: RegExp[] = []; + for (const pattern of patterns) { + if (!pattern) continue; + try { + compiled.push(new RegExp(pattern, "i")); + } catch (err) { + log.warn( + { err, pattern, meetingId }, + "MeetChatOpportunityDetector: invalid detector regex — skipping", + ); + } + } + return compiled; +} + +// --------------------------------------------------------------------------- +// MeetChatOpportunityDetector +// --------------------------------------------------------------------------- + +export class MeetChatOpportunityDetector { + private readonly meetingId: string; + private readonly assistantDisplayName: string; + private readonly config: ProactiveChatConfig; + private readonly callDetectorLLM: ChatOpportunityLLMAsk; + private readonly onOpportunity: ChatOpportunityCallback; + private readonly subscribe: ( + meetingId: string, + cb: MeetEventSubscriber, + ) => MeetEventUnsubscribe; + private readonly now: () => number; + + private unsubscribe: MeetEventUnsubscribe | null = null; + + /** Compiled Tier 1 regexes. Empty when `config.enabled === false`. */ + private readonly patterns: RegExp[]; + + private readonly transcriptBuffer: TranscriptEntry[] = []; + private readonly chatBuffer: ChatEntry[] = []; + + /** Wall-clock ms of the last Tier 2 call (regardless of outcome). */ + private lastTier2CallAt: number | null = null; + /** Wall-clock ms of the last positive escalation (`shouldRespond: true`). */ + private lastEscalationAt: number | null = null; + /** In-flight flag so overlapping Tier 1 hits don't race Tier 2 calls. */ + private tier2InFlight = false; + + private readonly stats: ChatOpportunityDetectorStats = { + tier1Hits: 0, + tier2Calls: 0, + tier2PositiveCount: 0, + escalationsFired: 0, + escalationsSuppressed: 0, + }; + + constructor(deps: MeetChatOpportunityDetectorDeps) { + this.meetingId = deps.meetingId; + this.assistantDisplayName = deps.assistantDisplayName; + this.config = deps.config; + this.callDetectorLLM = deps.callDetectorLLM; + this.onOpportunity = deps.onOpportunity; + this.subscribe = deps.subscribe ?? subscribeToMeetingEvents; + this.now = deps.now ?? Date.now; + + this.patterns = this.config.enabled + ? this.buildPatterns(deps.assistantDisplayName, this.config.detectorKeywords) + : []; + } + + /** + * Begin observing the meeting. Idempotent. When `config.enabled === false` + * the detector still subscribes but the event handler short-circuits + * before any Tier 1 evaluation — this keeps the lifecycle symmetric with + * `dispose()` and makes the "disabled" telemetry trivially observable + * (zero tier1Hits). + */ + start(): void { + if (this.unsubscribe) return; + this.unsubscribe = this.subscribe(this.meetingId, (event) => + this.onEvent(event), + ); + } + + /** + * Tear down the subscription. Idempotent. Matches the lifecycle + * vocabulary ("dispose") called out in the phase plan. + */ + dispose(): void { + if (this.unsubscribe) { + try { + this.unsubscribe(); + } catch (err) { + log.warn( + { err, meetingId: this.meetingId }, + "MeetChatOpportunityDetector: unsubscribe threw during dispose", + ); + } + this.unsubscribe = null; + } + } + + /** Snapshot of current detector stats. Callers should not mutate. */ + getStats(): ChatOpportunityDetectorStats { + return { ...this.stats }; + } + + // ── Event handling ──────────────────────────────────────────────────────── + + private onEvent(event: MeetBotEvent): void { + if (!this.config.enabled) return; + try { + if (event.type === "transcript.chunk") { + this.onTranscriptChunk(event); + return; + } + if (event.type === "chat.inbound") { + this.onInboundChat(event); + return; + } + } catch (err) { + log.warn( + { err, meetingId: this.meetingId, eventType: event.type }, + "MeetChatOpportunityDetector: event handler threw", + ); + } + } + + private onTranscriptChunk(event: TranscriptChunkEvent): void { + if (!event.isFinal) return; + const raw = event.text ?? ""; + if (raw.trim().length === 0) return; + + const speaker = + event.speakerLabel ?? event.speakerId ?? "Unknown speaker"; + this.transcriptBuffer.push({ + tMs: this.now(), + timestamp: event.timestamp, + speaker, + text: raw, + }); + this.trimTranscriptBuffer(); + + const reason = this.tier1Match(raw); + if (reason !== null) { + this.stats.tier1Hits += 1; + void this.maybeRunTier2(reason, raw); + } + } + + private onInboundChat(event: InboundChatEvent): void { + const raw = event.text ?? ""; + if (raw.trim().length === 0) return; + + this.chatBuffer.push({ + timestamp: event.timestamp, + fromName: event.fromName, + text: raw, + }); + while (this.chatBuffer.length > CHAT_BUFFER_SIZE) this.chatBuffer.shift(); + + const reason = this.tier1Match(raw); + if (reason !== null) { + this.stats.tier1Hits += 1; + void this.maybeRunTier2(reason, raw); + } + } + + // ── Tier 1 ──────────────────────────────────────────────────────────────── + + /** + * Build the Tier 1 pattern list. The assistant-name mention and addressed- + * question patterns are always prepended (they depend on the live display + * name), then the config-supplied generic patterns follow. + */ + private buildPatterns( + displayName: string, + extras: readonly string[], + ): RegExp[] { + const nameLiteral = escapeRegex(displayName.trim()); + const patterns: RegExp[] = []; + if (nameLiteral.length > 0) { + // Word-boundary name mention, case-insensitive. + try { + patterns.push(new RegExp(`\\b${nameLiteral}\\b`, "i")); + } catch (err) { + log.warn( + { err, displayName, meetingId: this.meetingId }, + "MeetChatOpportunityDetector: failed to build name-mention regex", + ); + } + // Address + question: `(hey|hi|ok|so),? [,.]? … ?`. + try { + patterns.push( + new RegExp( + `(hey|hi|ok|so),?\\s+${nameLiteral}[,.]?\\s+.*\\?$`, + "i", + ), + ); + } catch (err) { + log.warn( + { err, displayName, meetingId: this.meetingId }, + "MeetChatOpportunityDetector: failed to build addressed-question regex", + ); + } + } + patterns.push(...compilePatterns(extras, this.meetingId)); + return patterns; + } + + /** + * Return a short trigger reason if `text` matches any Tier 1 pattern, or + * `null` when no pattern matched. The reason is the matching pattern's + * `source` prefixed with `"tier1:"` so downstream logs can attribute. + */ + private tier1Match(text: string): string | null { + for (const re of this.patterns) { + if (re.test(text)) return `tier1:${re.source}`; + } + return null; + } + + // ── Tier 2 ──────────────────────────────────────────────────────────────── + + /** + * Run one Tier 2 LLM check if the debounce window has elapsed and no + * other call is in flight. Overlapping Tier 1 hits within the debounce + * window are silently dropped (stats still record them as `tier1Hits` + * but not as `tier2Calls`). + * + * On a `shouldRespond: true` verdict, the escalation cooldown is checked + * before firing `onOpportunity`. A verdict arriving within + * `escalationCooldownSec` of the previous fire is counted as + * `escalationsSuppressed` and dropped. + */ + private async maybeRunTier2( + triggerReason: string, + triggerText: string, + ): Promise { + if (this.tier2InFlight) return; + + const nowMs = this.now(); + if ( + this.lastTier2CallAt !== null && + nowMs - this.lastTier2CallAt < this.config.tier2DebounceMs + ) { + log.debug( + { + event: "chat_opportunity.tier2.debounced", + meetingId: this.meetingId, + msSinceLast: nowMs - this.lastTier2CallAt, + }, + "MeetChatOpportunityDetector: Tier 2 debounced", + ); + return; + } + + // Stamp the debounce clock BEFORE the async call so a second trigger + // arriving mid-flight is still debounced. Capture the previous value + // so we can restore it on failure — a failed LLM call must not burn + // the debounce window. + const prevTier2CallAt = this.lastTier2CallAt; + this.lastTier2CallAt = nowMs; + this.tier2InFlight = true; + this.stats.tier2Calls += 1; + + const prompt = this.buildPrompt(triggerReason, triggerText); + try { + const decision = await this.callDetectorLLM(prompt); + if (!decision.shouldRespond) { + log.debug( + { + event: "chat_opportunity.tier2.negative", + meetingId: this.meetingId, + triggerReason, + reason: decision.reason, + }, + "MeetChatOpportunityDetector: Tier 2 declined", + ); + return; + } + this.stats.tier2PositiveCount += 1; + + // Escalation cooldown — suppress back-to-back fires. + const cooldownMs = this.config.escalationCooldownSec * 1_000; + const nowAfter = this.now(); + if ( + this.lastEscalationAt !== null && + nowAfter - this.lastEscalationAt < cooldownMs + ) { + this.stats.escalationsSuppressed += 1; + log.debug( + { + event: "chat_opportunity.escalation.suppressed", + meetingId: this.meetingId, + msSinceLast: nowAfter - this.lastEscalationAt, + }, + "MeetChatOpportunityDetector: escalation suppressed by cooldown", + ); + return; + } + + this.lastEscalationAt = nowAfter; + this.stats.escalationsFired += 1; + log.info( + { + event: "chat_opportunity.escalation.fired", + meetingId: this.meetingId, + triggerReason, + decisionReason: decision.reason, + }, + "MeetChatOpportunityDetector: firing opportunity callback", + ); + try { + this.onOpportunity(decision.reason); + } catch (err) { + log.error( + { err, meetingId: this.meetingId }, + "MeetChatOpportunityDetector: onOpportunity callback threw", + ); + } + } catch (err) { + // Restore the debounce clock on failure so the next trigger isn't + // silently suppressed for the remainder of the debounce window. + this.lastTier2CallAt = prevTier2CallAt; + log.warn( + { err, meetingId: this.meetingId, triggerReason }, + "MeetChatOpportunityDetector: Tier 2 LLM call failed", + ); + } finally { + this.tier2InFlight = false; + } + } + + // ── Prompt construction ─────────────────────────────────────────────────── + + private buildPrompt(triggerReason: string, triggerText: string): string { + const windowMs = this.config.tier2MaxTranscriptSec * 1_000; + const cutoff = this.now() - windowMs; + const transcriptLines = this.transcriptBuffer + .filter((e) => e.tMs >= cutoff) + .map((e) => `${e.speaker}: ${e.text}`); + const transcriptBlock = + transcriptLines.length === 0 ? "(none)" : transcriptLines.join("\n"); + const chatBlock = + this.chatBuffer.length === 0 + ? "(none)" + : this.chatBuffer.map((e) => `${e.fromName}: ${e.text}`).join("\n"); + return ( + `Recent transcript (last ${this.config.tier2MaxTranscriptSec}s):\n` + + `${transcriptBlock}\n\n` + + `Recent chat (last ${CHAT_BUFFER_SIZE}):\n${chatBlock}\n\n` + + `Trigger chunk: ${triggerText}\n` + + `Tier 1 reason: ${triggerReason}\n\n` + + "Would the AI assistant chiming in via meeting chat be appropriate " + + "and helpful here? Reply JSON only: " + + '{ shouldRespond: bool, reason: string }' + ); + } + + // ── Buffer maintenance ──────────────────────────────────────────────────── + + private trimTranscriptBuffer(): void { + const cutoff = this.now() - this.config.tier2MaxTranscriptSec * 1_000; + while ( + this.transcriptBuffer.length > 0 && + this.transcriptBuffer[0].tMs < cutoff + ) { + this.transcriptBuffer.shift(); + } + } +}