Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/refusal-detection.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion apps/worker/src/jobs/process-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions packages/lib/src/refusal-detection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
92 changes: 92 additions & 0 deletions packages/lib/src/refusal-detection.ts
Original file line number Diff line number Diff line change
@@ -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;
}