aac-chatbot / frontend /src /lib /resolveIntent.ts
akashkolte's picture
added affect and gesture fix
56a15bc
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<string> = 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<string> {
return new Set(
s
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 1)
);
}
function jaccard(a: Set<string>, b: Set<string>): 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,
};
}