import { DEFAULT_AIR_TEMPLATES } from "./airTemplates"; // Canonical AAC tokens that carry high signal when someone air-writes them — // short, action-oriented, and hard to confuse for casual chat. When the // voice transcript and the air-writing text disagree, these tokens win. const AAC_PRIORITY_TOKENS: ReadonlySet = new Set( ["help", "stop", "water", "done", "more"].filter((t) => DEFAULT_AIR_TEMPLATES.has(t) ) ); export type ResolvedSource = | "voice_only" | "air_only" | "agree" | "conflict_air" | "conflict_voice" | "none"; export interface ResolvedIntent { text: string; source: ResolvedSource; voice_text: string | null; air_text: string | null; } function normalise(s: string | null | undefined): string { return (s ?? "").trim().toLowerCase(); } function tokens(s: string): Set { return new Set( s .toLowerCase() .replace(/[^a-z0-9\s]/g, " ") .split(/\s+/) .filter((w) => w.length > 1) ); } function jaccard(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0; let inter = 0; for (const tok of a) if (b.has(tok)) inter++; const union = a.size + b.size - inter; return union === 0 ? 0 : inter / union; } export function resolveIntent( voiceRaw: string | null, airRaw: string | null ): ResolvedIntent { const voice = normalise(voiceRaw); const air = normalise(airRaw); if (!voice && !air) { return { text: "", source: "none", voice_text: null, air_text: null }; } if (voice && !air) { return { text: voice, source: "voice_only", voice_text: voice, air_text: null, }; } if (!voice && air) { return { text: air, source: "air_only", voice_text: null, air_text: air }; } // Both present. const voiceTokens = tokens(voice); const airTokens = tokens(air); const overlap = jaccard(voiceTokens, airTokens); // Air-text appears as a substring of the voice transcript (or vice versa) — // user probably said the word while also writing it. Treat as agreement. const substringHit = voice.includes(air) || air.includes(voice) || overlap >= 0.5; if (substringHit) { // Prefer the longer / richer form (usually voice), but mark source as agree. const winner = voice.length >= air.length ? voice : air; return { text: winner, source: "agree", voice_text: voice, air_text: air, }; } // Genuine conflict. AAC priority tokens (help/stop/water/done/more) dominate. if (AAC_PRIORITY_TOKENS.has(air)) { return { text: air, source: "conflict_air", voice_text: voice, air_text: air, }; } // Otherwise voice wins — higher information density. return { text: voice, source: "conflict_voice", voice_text: voice, air_text: air, }; }