From 29f5b26196973c42fd8a9d64e187967986d95fd3 Mon Sep 17 00:00:00 2001 From: Liren Tu Date: Thu, 18 Jun 2026 21:10:23 -0700 Subject: [PATCH] feat(worker): detect and call out model refusals (#30) Adds a tested refusal detector (packages/lib/refusal-detection) and wires it into the worker: when a model declines to answer ("I can't help with that", "I must decline"), the run is logged, reported to telemetry as a prompt_refused event, and excluded from brand/competitor mention counting so refusals don't masquerade as visibility. Surfacing refusal rates in the dashboard UI is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/refusal-detection.md | 6 ++ apps/worker/src/jobs/process-prompt.ts | 20 ++++- packages/lib/package.json | 1 + packages/lib/src/refusal-detection.test.ts | 50 ++++++++++++ packages/lib/src/refusal-detection.ts | 92 ++++++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 .changeset/refusal-detection.md create mode 100644 packages/lib/src/refusal-detection.test.ts create mode 100644 packages/lib/src/refusal-detection.ts diff --git a/.changeset/refusal-detection.md b/.changeset/refusal-detection.md new file mode 100644 index 00000000..ea2bd495 --- /dev/null +++ b/.changeset/refusal-detection.md @@ -0,0 +1,6 @@ +--- +"@workspace/worker": patch +"@workspace/lib": patch +--- + +Model refusals (e.g. "I can't help with that") are now detected: they no longer count as brand mentions, and each refusal is logged and reported to telemetry so declines are visible. diff --git a/apps/worker/src/jobs/process-prompt.ts b/apps/worker/src/jobs/process-prompt.ts index b46efd20..8f81b3e8 100644 --- a/apps/worker/src/jobs/process-prompt.ts +++ b/apps/worker/src/jobs/process-prompt.ts @@ -11,6 +11,7 @@ import { type Provider, } from "@workspace/lib/providers"; import type { Citation } from "@workspace/lib/text-extraction"; +import { detectRefusal } from "@workspace/lib/refusal-detection"; import boss from "../boss"; import { trackWorkerEvent } from "../telemetry"; @@ -235,7 +236,24 @@ async function runModelIteration({ const safeTextContent = typeof textContent === "string" ? textContent : ""; - const { brandMentioned, competitorsMentioned } = analyzeMentions(safeTextContent, brand, competitorsList); + // Explicitly call out refusals (issue #30). A refusal ("I can't help with + // that") isn't a real answer, so it carries no visibility signal — skip + // mention analysis and record it as not mentioned, while surfacing it in logs + // and telemetry so operators can see how often engines decline. + const refusal = detectRefusal(safeTextContent); + if (refusal.isRefusal) { + console.warn(`${logPrefix} Model refused to answer (matched: "${refusal.matchedPhrase}")`); + trackWorkerEvent("prompt_refused", { + brand_id: brand.id, + model: config.model, + provider: config.provider ?? "unknown", + matched_phrase: refusal.matchedPhrase ?? "", + }); + } + + const { brandMentioned, competitorsMentioned } = refusal.isRefusal + ? { brandMentioned: false, competitorsMentioned: [] as string[] } + : analyzeMentions(safeTextContent, brand, competitorsList); const recordedVersion = modelVersion ?? config.version ?? config.provider; diff --git a/packages/lib/package.json b/packages/lib/package.json index 0ca5ad66..e070dcf8 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -18,6 +18,7 @@ "./auth/permissions": "./src/auth/permissions.ts", "./constants": "./src/constants.ts", "./text-extraction": "./src/text-extraction.ts", + "./refusal-detection": "./src/refusal-detection.ts", "./dataforseo": "./src/dataforseo.ts", "./onboarding": "./src/onboarding/index.ts", "./tag-utils": "./src/tag-utils.ts", diff --git a/packages/lib/src/refusal-detection.test.ts b/packages/lib/src/refusal-detection.test.ts new file mode 100644 index 00000000..6f1c1b78 --- /dev/null +++ b/packages/lib/src/refusal-detection.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { detectRefusal, isRefusal } from "./refusal-detection"; + +describe("detectRefusal", () => { + it("flags a terse refusal", () => { + expect(isRefusal("I can't help with that.")).toBe(true); + expect(isRefusal("Sorry, but I must decline.")).toBe(true); + expect(isRefusal("I'm unable to assist with this request.")).toBe(true); + }); + + it("returns the matched phrase", () => { + const r = detectRefusal("I can't help with that request."); + expect(r.isRefusal).toBe(true); + expect(r.matchedPhrase).toBe("i can't help with that"); + }); + + it("handles curly apostrophes", () => { + expect(isRefusal("I can’t help with that.")).toBe(true); + }); + + it("flags a refusal that opens a longer explanation", () => { + const content = + "I'm sorry, but I can't assist with that. Providing this kind of content would not be appropriate, and I'd rather point you to safer alternatives instead."; + expect(isRefusal(content)).toBe(true); + }); + + it("does NOT flag a real answer that mentions a partial limitation", () => { + const content = + "Acme is a popular CRM. I can't share their exact internal pricing, but public plans start around $25/user. Competitors include Beta and Gamma."; + expect(isRefusal(content)).toBe(false); + }); + + it("does NOT flag ordinary answers", () => { + expect(isRefusal("Acme is a leading provider of widgets and analytics.")).toBe(false); + expect(isRefusal("The best options are Acme, Beta, and Gamma.")).toBe(false); + }); + + it("returns false for empty content", () => { + expect(isRefusal("")).toBe(false); + expect(isRefusal(" ")).toBe(false); + }); + + it("does not scan deep into long responses for a stray refusal phrase", () => { + // A genuine, long answer that happens to contain a refusal-like phrase far + // past the opening should not be classified as a refusal. + const longAnswer = `${"Acme is great. ".repeat(60)} In rare edge cases I can't do that, but overall it's solid.`; + expect(longAnswer.length).toBeGreaterThan(600); + expect(isRefusal(longAnswer)).toBe(false); + }); +}); diff --git a/packages/lib/src/refusal-detection.ts b/packages/lib/src/refusal-detection.ts new file mode 100644 index 00000000..536b8d95 --- /dev/null +++ b/packages/lib/src/refusal-detection.ts @@ -0,0 +1,92 @@ +// Detecting when a model *refuses* to answer a prompt (e.g. "I can't help with +// that", "I must decline") rather than producing a real answer. Refusals carry +// no visibility signal — they shouldn't count as brand mentions and they're +// worth calling out explicitly so operators can see how often engines decline. +// See issue #30. + +// Strong, unambiguous refusal openers. Kept as full phrases (not bare "can't") +// so partial refusals inside a genuine answer ("I can't share exact pricing, +// but Acme costs roughly…") don't trip the detector. +export const REFUSAL_PHRASES: readonly string[] = [ + "i can't help with that", + "i cannot help with that", + "i can't help with this", + "i cannot help with this", + "i can't assist with that", + "i cannot assist with that", + "i'm not able to help with that", + "i am not able to help with that", + "i'm unable to help with that", + "i am unable to help with that", + "i'm not able to assist", + "i am not able to assist", + "i'm unable to assist", + "i am unable to assist", + "i can't comply with that", + "i cannot comply with that", + "i can't comply with this", + "i cannot comply with this", + "i can't fulfill that", + "i cannot fulfill that", + "i can't fulfill this", + "i cannot fulfill this", + "i won't be able to help", + "i must decline", + "i have to decline", + "i'm sorry, but i can't", + "i'm sorry but i can't", + "i'm sorry, but i cannot", + "i'm sorry but i cannot", + "i can't do that", + "i cannot do that", + "i'm not able to provide that", + "i am not able to provide that", + "i can't provide that information", + "i cannot provide that information", + "i won't be providing", + "i can't engage with that", + "i cannot engage with that", +]; + +// Refusals are usually short, and the refusal phrasing leads the response. We +// look in the opening segment, and also scan short responses in full, so a +// terse "Sorry — I must decline." is caught wherever the phrase sits. +const OPENING_SEGMENT_CHARS = 240; +const SHORT_RESPONSE_CHARS = 600; + +function normalize(content: string): string { + // Fold curly apostrophes so "can't" matches whatever quote style the model used. + return content.replace(/[‘’]/g, "'").toLowerCase(); +} + +export interface RefusalResult { + isRefusal: boolean; + /** The refusal phrase that matched, when `isRefusal` is true. */ + matchedPhrase?: string; +} + +/** + * Detect whether `content` is a refusal to answer. Returns the matched phrase so + * callers can log/report exactly what tripped the detector. + */ +export function detectRefusal(content: string): RefusalResult { + const normalized = normalize(content.trim()); + if (!normalized) return { isRefusal: false }; + + const opening = normalized.slice(0, OPENING_SEGMENT_CHARS); + for (const phrase of REFUSAL_PHRASES) { + if (opening.includes(phrase)) return { isRefusal: true, matchedPhrase: phrase }; + } + + if (normalized.length <= SHORT_RESPONSE_CHARS) { + for (const phrase of REFUSAL_PHRASES) { + if (normalized.includes(phrase)) return { isRefusal: true, matchedPhrase: phrase }; + } + } + + return { isRefusal: false }; +} + +export function isRefusal(content: string): boolean { + return detectRefusal(content).isRefusal; +}