fix(llm): stop gating the embedding refusal check on is_ambiguous

An explicitly configured embedding_model is an opt-in to pay for the
similarity check, but is_refusal still gated it on is_ambiguous — a
length/hedge-keyword heuristic that skips anything long or plainly
worded. For chat_structured() this checks the full serialized JSON
output, whose braces/field-name overhead alone often pushed responses
past the 400-char cutoff, so the embedding call silently never fired
for exactly the subtle, on-topic-looking deflections this feature was
built to catch.

Now embed_fn, once supplied, always runs on any non-blank response that
didn't already match the lexical pass. Also widened is_ambiguous's own
threshold/keyword list, since it remains a standalone tested heuristic
for other callers even though is_refusal no longer wires it in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James Veitch
2026-07-29 17:30:19 +01:00
co-authored by Claude Sonnet 5
parent 97249ee281
commit 3c5136af70
2 changed files with 74 additions and 11 deletions
+34 -8
View File
@@ -81,16 +81,31 @@ _SOFT_HEDGE_KEYWORDS: tuple[str, ...] = (
"guideline",
"responsible ai",
"not appropriate",
"inappropriate",
"instead, i",
"i'd rather",
"i would rather",
"controversial",
"harmful",
"offensive",
"disturbing",
"explicit content",
"nsfw",
"consider an alternative",
)
_AMBIGUOUS_LENGTH_THRESHOLD = 400
_AMBIGUOUS_LENGTH_THRESHOLD = 600
"""Below this many characters, a response is short enough that a soft
refusal is plausible and worth the extra embedding check — chosen well
under this pipeline's normal structured-JSON response sizes (typically
1000+ characters), not a tuned/validated threshold."""
1000+ characters), not a tuned/validated threshold.
Note: ``is_refusal`` no longer applies this gate when the caller supplies
``embed_fn`` (see below) — an explicitly configured embedding model always
runs the check regardless of length/keywords. ``is_ambiguous`` is kept as a
standalone, independently tested heuristic for other callers (e.g. logging
"why was this worth embedding-checking") rather than wired into the hybrid
detector itself."""
REFUSAL_EXEMPLARS: tuple[str, ...] = (
"I cannot generate an image or description involving this topic.",
@@ -172,23 +187,34 @@ async def is_refusal(
threshold: float = 0.82,
) -> bool:
"""Hybrid refusal/deflection detector: free lexical pass first, then an
optional embedding-similarity fallback only for ambiguous responses.
embedding-similarity fallback whenever the caller has configured one.
``embed_fn`` is supplied by the caller's own ``LLMProvider.embed()`` —
this function has no idea which backend or model produced ``text``, by
design (ADR: refusal detection is a model-behavior concern, not a
backend one). ``embed_fn=None`` (no embedding model configured) degrades
to lexical-only detection rather than erroring. Any failure while
embedding (unreachable server, no embedding-capable model loaded) is
swallowed the same way — an optional enhancement failing shouldn't take
down the retry loop it's assisting.
to lexical-only detection rather than erroring; ``embed_fn`` present
means the caller already opted in to the extra cost, so every non-blank,
non-lexically-caught response gets checked — no further length/keyword
gating. Any failure while embedding (unreachable server, no
embedding-capable model loaded) is swallowed the same way — an optional
enhancement failing shouldn't take down the retry loop it's assisting.
"""
if not text or not text.strip():
return False # blank responses are the *other* retry trigger, not this one
if is_lexical_refusal(text):
return True
if embed_fn is None or not is_ambiguous(text):
if embed_fn is None:
return False
# embed_fn only exists when the caller explicitly configured an
# embedding_model — that's an opt-in to pay for the check, so run it on
# every non-blank, non-lexically-caught response rather than gating
# further on is_ambiguous. The length/keyword heuristic exists to avoid
# *unwanted* embedding calls when no embedding model is configured (see
# the embed_fn is None branch above); it has no reason to also suppress
# calls once the caller has already asked for them, and doing so was
# exactly what let the subtle/on-topic-looking deflections this feature
# targets slip through undetected.
try:
exemplar_vecs = await _exemplar_embeddings(embed_fn, embed_cache_key)
if not exemplar_vecs:
+40 -3
View File
@@ -125,17 +125,24 @@ class TestIsRefusalHybrid:
assert result is True
assert calls == [] # never reached the embedding step
def test_long_clean_response_skips_embedding_and_is_not_refusal(self):
def test_long_clean_response_is_still_embedding_checked_but_not_a_refusal(self):
# embed_fn present means the caller opted in to the embedding check
# regardless of length/hedge-keywords (is_ambiguous no longer gates
# this) — a long, on-topic response should still be embedding-
# checked, it just shouldn't score as similar to the refusal
# exemplars.
calls = []
async def embed_fn(text):
calls.append(text)
return [1.0, 0.0]
if text == long_text:
return [0.0, 1.0] # orthogonal to the exemplar vector below
return [1.0, 0.0] # exemplars
long_text = "The subject rotates smoothly toward the lens. " * 20
result = _run_async(is_refusal(long_text, embed_fn=embed_fn))
assert result is False
assert calls == [] # not ambiguous -> never reaches the embedding step
assert long_text in calls # embedding check DID run, just scored low
def test_no_embed_fn_degrades_to_lexical_only(self):
# Ambiguous (short), no lexical match, no embed_fn -> can't check further
@@ -175,6 +182,36 @@ class TestIsRefusalHybrid:
)
assert result is False
def test_long_json_shaped_soft_refusal_without_hedge_keywords_is_caught(self):
# Regression case: a structured-output-shaped response (>600 chars
# once you count JSON braces/field names) whose deflection doesn't
# use any of the canned hedge keywords used to be invisible to the
# embedding check entirely, because is_ambiguous gated on length
# and keywords. embed_fn now runs unconditionally once configured.
soft_refusal = (
'{"prompt": "'
+ "Let's take this in a different creative direction that everyone can enjoy. "
* 8
+ '"}'
)
assert len(soft_refusal) >= 600
assert not is_lexical_refusal(soft_refusal)
async def embed_fn(text):
if text == soft_refusal:
return [1.0, 0.0]
return [0.97, 0.24] # exemplars: cos-sim with [1,0] is ~0.97
result = _run_async(
is_refusal(
soft_refusal,
embed_fn=embed_fn,
embed_cache_key="regression-model",
threshold=0.8,
)
)
assert result is True
def test_exemplar_embeddings_cached_across_calls(self):
exemplar_calls = {"n": 0}