Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | 4x 4x 4x 4x 4x 4x 8x 8x 24x 296x 1136x 4x 4x 36x 16485x 16793x 161x 8186x 4886x 14033x 4732x 303x 8186x 149x 4x 150x 150x 1350x 2452x 14x 150x 106x 106x 96x 96x 67x 67x 4x 63x 2x 61x 91x 91x 86x 54x 4x | import type { RrmLocale } from "./language";
import { normaliseForMatching, tokeniseNormalised } from "./text";
/**
* Opt-out detection — the negative path.
*
* Requirements §5b calls this "the product", and the reason is worth keeping
* next to the code: Hyderabad's agents share micro-markets and WhatsApp
* groups, so a thousand cold messages is not a thousand private conversations
* — it is one public act. An agent who says no and is respected is a future
* yes and says so; an agent who says no and gets messaged again tells thirty
* people in a group.
*
* Three outcomes, not two. A binary detector forces a choice between
* suppressing people who never opted out and ignoring people who did, and both
* cost exactly the reputation the programme runs on. `review` is the release
* valve: the message goes to the operator inbox with the matched phrase
* attached as evidence, and nothing is decided automatically.
*/
// `normaliseForMatching` is re-exported so the module's public surface is
// unchanged — it lives in ./text.ts now because language.ts needs the identical
// normalisation and two near-copies had already diverged on `\p{M}`.
export { normaliseForMatching } from "./text";
const tokenise = tokeniseNormalised;
/** Locales in list order. Also breaks ties when two phrases are equally specific. */
const PHRASE_LOCALES = [
"en",
"te-Latn",
"te",
] as const satisfies readonly RrmLocale[];
/**
* Phrases that mean "stop messaging me", per locale. Already in normalised
* form — a test enforces that, because a phrase that does not survive
* `normaliseForMatching` can never match anything.
*
* Seeded from build spec §9.1 and extended with the obvious variants and
* misspellings P1-7.2 asks for: "a person should never have to guess the magic
* word."
*
* Exported so the inbox can show the operator exactly which phrase fired.
*
* One deliberate deviation from §9.1: that list includes "ippudu vaddu", but
* "ippudu vaddu" means *not now*, and requirements §5b.3 is explicit that no
* and not-now are different — collapsing them wastes real prospects. It lives
* in NOT_NOW_PHRASES instead. The bare "vaddu" inside it still matches here,
* which is what sends the message to review rather than to a permanent block.
*/
export const OPT_OUT_PHRASES: Record<RrmLocale, readonly string[]> = {
en: [
"no",
"no thanks",
"no thank you",
"no need",
"no interest",
"not interested",
// Common enough misspelling to be worth its own line.
"not intrested",
"not required",
"not needed",
"dont message",
"do not message",
"dont contact",
"do not contact",
"dont call",
"do not call",
"dont send",
"do not send",
"stop",
"unsubscribe",
"remove",
"remove me",
"delete my number",
"leave me",
"opt out",
"optout",
// Hyderabadi Deccani, written in Latin script so this is where such a
// message lands. Both are everyday, unambiguous refusals here.
"nakko",
"nahi",
],
"te-Latn": [
"vaddu",
"vadhu",
"vaddhu",
"venda",
"oddu",
"avasaram ledu",
"interest ledu",
"asakti ledu",
"message cheyakandi",
"message cheyyakandi",
"call cheyakandi",
"pampakandi",
],
te: [
"వద్దు",
"వద్దండి",
"అవసరం లేదు",
"ఆసక్తి లేదు",
"ఇష్టం లేదు",
"మెసేజ్ చేయకండి",
"కాల్ చేయకండి",
"పంపకండి",
],
};
/**
* Phrases that mean "later", not "never".
*
* §5b.3: these are different, and asking is respectful — a not-now becomes a
* snooze the person is told about, not a permanent block. Every phrase here
* outranks the opt-out list, which is the whole point: "ippudu vaddu" contains
* "vaddu", and treating that as a block is how a real prospect who asked us to
* come back later is lost forever.
*/
export const NOT_NOW_PHRASES: Record<RrmLocale, readonly string[]> = {
en: [
"not now",
"not right now",
"not at the moment",
"later",
"maybe later",
"some other time",
"another time",
"busy",
"next week",
"next month",
"after some time",
"will let you know",
"ill let you know",
],
"te-Latn": [
"ippudu vaddu",
"ippudu kaadu",
"ippudu kadu",
"ippudu ledu",
"ippudu busy",
"tarvata",
"tarvatha",
"taruvatha",
"malli cheppandi",
],
te: ["ఇప్పుడు వద్దు", "ఇప్పుడు కాదు", "తర్వాత", "తరువాత", "తర్వాత చెప్పండి"],
};
/**
* Idioms that contain an opt-out phrase but are not a refusal.
*
* This is why §9.1 cannot be applied literally. "no problem, send me the
* details" normalises to exactly six tokens and contains "no", so "short and
* contains a phrase" would auto-suppress the very message the spec names as
* the one that must never be suppressed. Masking these before matching
* resolves the contradiction without loosening the length rule for anything
* else.
*/
export const NON_DECLINE_IDIOMS: readonly string[] = [
"no problem",
"no problems",
"no probs",
"no worries",
"no issue",
"no issues",
"not a problem",
"no doubt",
"why not",
];
/** Longest a message can be and still auto-suppress. Build spec §9.1. */
export const OPT_OUT_MAX_TOKENS = 6;
export type OptOutReviewReason = "not_now" | "too_long";
type OptOutEvidence = {
/** The list entry that matched, for the event payload and the inbox. */
phrase: string;
/** Which language's list it came from — corroborates the detected locale. */
phraseLocale: RrmLocale;
/** Length of the whole message, the thing the §9.1 rule turns on. */
tokenCount: number;
};
export type OptOutResult =
| ({ kind: "opt_out" } & OptOutEvidence)
| ({ kind: "review"; reason: OptOutReviewReason } & OptOutEvidence)
| { kind: "none" };
type PhraseEntry = {
phrase: string;
locale: RrmLocale;
tokens: readonly string[];
};
function toEntries(lists: Record<RrmLocale, readonly string[]>): PhraseEntry[] {
const entries: PhraseEntry[] = [];
for (const locale of PHRASE_LOCALES) {
for (const phrase of lists[locale]) {
entries.push({ phrase, locale, tokens: phrase.split(" ") });
}
}
// Longest first, so the evidence we record is the most specific phrase that
// matched: "no thanks" rather than "no".
return entries.sort((a, b) => b.tokens.length - a.tokens.length);
}
const OPT_OUT_ENTRIES = toEntries(OPT_OUT_PHRASES);
const NOT_NOW_ENTRIES = toEntries(NOT_NOW_PHRASES);
const IDIOM_TOKENS = NON_DECLINE_IDIOMS.map((idiom) => idiom.split(" "));
function matchesAt(
haystack: readonly string[],
needle: readonly string[],
at: number,
): boolean {
for (let i = 0; i < needle.length; i++) {
if (haystack[at + i] !== needle[i]) return false;
}
return true;
}
/**
* Contiguous token-sequence search, never substring search: "no" must not fire
* on "know" or "now", and "not interested" must not fire on "notes".
*/
function containsSequence(
haystack: readonly string[],
needle: readonly string[],
): boolean {
if (needle.length === 0 || needle.length > haystack.length) return false;
for (let i = 0; i <= haystack.length - needle.length; i++) {
if (matchesAt(haystack, needle, i)) return true;
}
return false;
}
function findPhrase(
tokens: readonly string[],
entries: readonly PhraseEntry[],
): PhraseEntry | null {
for (const entry of entries) {
if (containsSequence(tokens, entry.tokens)) return entry;
}
return null;
}
/**
* Sentinel replacing the tokens of a benign idiom. A real token can never be
* empty (whitespace is collapsed before splitting), so this matches nothing.
*
* Masking rather than deleting: deletion would close the gap and could join
* two unrelated neighbours into a phrase nobody wrote.
*/
const MASK = "";
function maskIdioms(tokens: readonly string[]): string[] {
const masked = [...tokens];
for (const idiom of IDIOM_TOKENS) {
for (let i = 0; i <= masked.length - idiom.length; i++) {
if (!matchesAt(masked, idiom, i)) continue;
for (let j = 0; j < idiom.length; j++) masked[i + j] = MASK;
}
}
return masked;
}
/**
* Classifies an inbound message for the negative path.
*
* Auto-suppression is deliberately narrow — short and unambiguous only. Every
* other message carrying opt-out language becomes `review`, which costs an
* operator ten seconds and cannot cost us a prospect who never asked to leave.
*/
export function detectOptOut(text: string | null | undefined): OptOutResult {
const tokens = tokenise(normaliseForMatching(text));
if (tokens.length === 0) return { kind: "none" };
const match = findPhrase(maskIdioms(tokens), OPT_OUT_ENTRIES);
if (!match) return { kind: "none" };
const evidence: OptOutEvidence = {
phrase: match.phrase,
phraseLocale: match.locale,
tokenCount: tokens.length,
};
// A not-now signal outranks the length rule: "ippudu vaddu" is short and
// contains "vaddu", so §9.1 read literally would block someone who asked us
// to come back later. The reason travels with the result so triage can
// offer snooze instead of DNC (§5b.3).
if (findPhrase(tokens, NOT_NOW_ENTRIES)) {
return { kind: "review", reason: "not_now", ...evidence };
}
// §9.1: only a short message is unambiguous. Measured on the original
// message, not on the idiom-masked copy — masking must never shorten
// someone into auto-suppression.
if (tokens.length > OPT_OUT_MAX_TOKENS) {
return { kind: "review", reason: "too_long", ...evidence };
}
return { kind: "opt_out", ...evidence };
}
/** `unclear` means "no decline detected", not "an ambiguous decline". */
export type DeclineTiming = "not_now" | "never" | "unclear";
/**
* Which kind of decline this is, if any — the snooze-versus-DNC question in
* triage (§5b.3).
*
* No length gate here, on purpose. This answers *which kind* of decline, not
* *whether it is safe to act automatically*; a wordy "thanks but I'm really
* not interested" is still a never. `detectOptOut` is the only function that
* decides about suppressing.
*/
export function classifyDeclineTiming(
text: string | null | undefined,
): DeclineTiming {
const tokens = tokenise(normaliseForMatching(text));
if (tokens.length === 0) return "unclear";
if (findPhrase(tokens, NOT_NOW_ENTRIES)) return "not_now";
return findPhrase(maskIdioms(tokens), OPT_OUT_ENTRIES) ? "never" : "unclear";
}
/**
* Convenience wrapper for the triage call site.
*
* False for a message with no decline in it at all — ask this only about a
* message already known to be a refusal.
*/
export function isNotNowRatherThanNever(
text: string | null | undefined,
): boolean {
return classifyDeclineTiming(text) === "not_now";
}
|