Skip to content

Commit 800a036

Browse files
committed
fix: mid-word matches, empty-quote wildcards, case-sensitive misses
Third round of review findings on the Article 9 recognizer. _whole_words grew any substring match outward rather than dropping ones that started mid-word: a quote of "he" turned "When the interviewer asked, he said..." into "<SEXUAL_ORIENTATION> interviewer asked, <SEXUAL_ORIENTATION> said...". Replaced with _span_for_match, which drops mid-word starts and keeps rightward growth for inflections (Muslim -> Muslims). The empty-quote guard only checked for `not item.text`, so a quote of a single space matched every word boundary in the transcript -- reproduced as "I<HEALTH>manage<HEALTH>my<HEALTH>condition...". Now requires at least one alphanumeric character. text.find() was also case-sensitive, so a re-capitalized quote ("Diabetes" vs the transcript's "diabetes") silently dropped the finding -- undetected Article 9 leakage with no signal. Now case-insensitive matching via re.finditer, offsets unchanged, plus a warning log on an unmatched quote. guard_nemo read response["content"], which could KeyError past all four fail-closed checks on an unexpected response shape. Now .get("content"), which routes into the existing empty-content failure path. .env.example did not document the three new SCREENING_LLM_GUARDRAIL_* settings; an unset var defaults to localhost and fails closed, so a deployed environment missing it would 502 every request with no obvious cause. Two items reported, not fixed, because the real fix is a design change: the 900s guardrail timeout runs inside run_in_threadpool and can pin AnyIO's threadpool for the duration of a cold start (real fix is 202 + polling, already named in infra/gemma/README.md); and a guardrail RuntimeError surfaces as the same 502 as an LLM failure, pointing operators at the wrong system during a Gemma outage.
1 parent 6a42eec commit 800a036

4 files changed

Lines changed: 143 additions & 31 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ SCREENING_LLM_API_KEY=ollama
1010
SCREENING_LLM_MODEL=qwen2.5:3b
1111
SCREENING_LLM_TIMEOUT_S=60
1212

13+
# Article 9 detector — the self-hosted Gemma-4 endpoint the guardrail calls.
14+
# The default points at a local vLLM. In any deployed environment this MUST be
15+
# set to the detector's internal FQDN: the recognizer fails closed, so leaving
16+
# the localhost default in place makes every /screen request return 502.
17+
SCREENING_LLM_GUARDRAIL_BASE_URL=http://localhost:8001/v1
18+
SCREENING_LLM_GUARDRAIL_MODEL=google/gemma-4-31B-it
19+
# Much larger than the assessment timeout: the endpoint scales to zero, so the
20+
# first request after an idle period waits for a GPU to boot and load weights.
21+
SCREENING_LLM_GUARDRAIL_TIMEOUT_S=900
22+
1323
# Confident AI (DeepEval online evals).
1424
CONFIDENT_API_KEY=
1525
CONFIDENT_BASE_URL=https://eu.api.confident-ai.com

app/adapters/guard_nemo.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,13 @@ async def scrub(self, text: str) -> ScrubResult:
154154
response = await self._rails.generate_async(
155155
messages=[{"role": "user", "content": _encode(text)}]
156156
)
157-
content = response["content"] if isinstance(response, dict) else str(response)
157+
# `.get`, not `["content"]`: a dict-shaped response without that key
158+
# would raise KeyError straight past every fail-closed check below and
159+
# surface as an unclassified 500. Missing content is just another way
160+
# the rail failed to produce scrubbed text.
161+
content = (
162+
response.get("content") if isinstance(response, dict) else str(response)
163+
)
158164
content = "" if content is None else str(content)
159165
content = content.strip()
160166

app/adapters/llm_guardrail_recognizer.py

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
GLiNER's 0.552, and beat the frontier cloud model too (see INE-16).
66
"""
77

8+
import logging
9+
import re
10+
811
import instructor
912
from openai import OpenAI
1013
from presidio_analyzer import EntityRecognizer, RecognizerResult
1114
from pydantic import BaseModel
1215

1316
from app.config import settings
1417

18+
logger = logging.getLogger("screen")
19+
1520
_ARTICLE9_ENTITIES = [
1621
"RELIGION",
1722
"HEALTH",
@@ -33,28 +38,32 @@ def _is_word_char(char: str) -> bool:
3338
return char.isalnum() or char == "_"
3439

3540

36-
def _whole_words(text: str, start: int, end: int) -> tuple[int, int]:
37-
"""Grow a span outwards until neither edge cuts a word in half.
41+
def _span_for_match(text: str, start: int, end: int) -> tuple[int, int] | None:
42+
"""Turn a raw substring hit into a span that does not cut a word in half.
43+
44+
Substring matching is blind to word boundaries, and the two ways it can
45+
straddle one need opposite treatment:
3846
39-
`str.find` is plain substring matching, so a quote of "Black" as an
40-
ETHNICITY also lands inside "BlackRock" -- and redacting that span alone
41-
leaves "<ETHNICITY>Rock", corrupting the employer the candidate is scored
42-
on. Growing (rather than dropping the occurrence) is the safe direction:
43-
the span never shrinks, so this can only ever redact more, never less. It
44-
also catches the inflected form -- a model that quotes "Muslim" against a
45-
transcript saying "Muslims" would otherwise leave the trailing "s" behind.
47+
* The hit STARTS mid-word -- "he" inside "the"/"When", "MS" inside "CMS".
48+
There is no reading of that occurrence under which the candidate
49+
disclosed anything, so it is dropped. Growing it leftwards instead (the
50+
previous behaviour) redacted whole innocent words: a quote of "he"
51+
turned "When the interviewer" into "<SEXUAL_ORIENTATION> interviewer".
52+
* The hit ENDS mid-word -- "Muslim" against a transcript saying "Muslims".
53+
That is the same disclosure inflected, so the span is grown rightwards.
54+
Stopping at the raw end would leave "<RELIGION>s" behind.
4655
4756
Args:
4857
text: The transcript the offsets refer to.
4958
start: Start offset of the raw substring match.
5059
end: End offset (exclusive) of the raw substring match.
5160
5261
Returns:
53-
The widened ``(start, end)``. Unchanged when both edges already sit on
54-
a word boundary, or when the quote itself begins/ends with punctuation.
62+
The span to redact, or None when the hit began mid-word and should be
63+
discarded.
5564
"""
56-
while start > 0 and _is_word_char(text[start]) and _is_word_char(text[start - 1]):
57-
start -= 1
65+
if start > 0 and _is_word_char(text[start]) and _is_word_char(text[start - 1]):
66+
return None
5867
while end < len(text) and _is_word_char(text[end - 1]) and _is_word_char(text[end]):
5968
end += 1
6069
return start, end
@@ -142,30 +151,61 @@ def analyze(self, text, entities, nlp_artifacts=None):
142151
seen: set[tuple[str, int, int]] = set()
143152
for item in detected.entities:
144153
entity_type = item.entity_type.strip().upper()
145-
if entity_type not in requested or not item.text:
154+
if entity_type not in requested:
155+
continue
156+
# A quote with no alphanumeric character in it -- "", " ", "." --
157+
# occurs almost everywhere in the transcript. Redacting every hit
158+
# replaces the separators between words and destroys the text the
159+
# model is scored on ("I<HEALTH>manage<HEALTH>my..."), so such a
160+
# quote is never actionable.
161+
if not any(char.isalnum() for char in item.text):
146162
continue
147163
# Offsets are computed here, never asked of the model -- LLMs are
148-
# unreliable at character arithmetic. No exact match means no
149-
# trustworthy span, so the finding is dropped rather than guessed.
164+
# unreliable at character arithmetic. No match means no trustworthy
165+
# span, so the finding is dropped rather than guessed.
166+
#
167+
# Case-insensitive on purpose: a model that returns "Diabetes"
168+
# against a transcript saying "diabetes" is pointing at a real
169+
# disclosure, and the offsets it produces are still exact. Matching
170+
# case-sensitively dropped it silently -- under-redaction of an
171+
# Article 9 category is the one failure this recognizer exists to
172+
# prevent.
150173
#
151174
# Every occurrence, not just the first: the same disclosure often
152175
# appears more than once ("I have diabetes ... my diabetes"), and
153176
# redacting only the first mention leaks the rest to the model.
154177
# `seen` absorbs the duplicates a model asked for "every occurrence"
155178
# tends to return.
156-
match_start = text.find(item.text)
157-
while match_start != -1:
158-
match_end = match_start + len(item.text)
159-
start, end = _whole_words(text, match_start, match_end)
160-
if (entity_type, start, end) not in seen:
161-
seen.add((entity_type, start, end))
162-
results.append(
163-
RecognizerResult(
164-
entity_type=entity_type,
165-
start=start,
166-
end=end,
167-
score=_SCORE,
168-
)
179+
matched = False
180+
for match in re.finditer(re.escape(item.text), text, re.IGNORECASE):
181+
span = _span_for_match(text, match.start(), match.end())
182+
if span is None:
183+
continue
184+
matched = True
185+
key = (entity_type, span[0], span[1])
186+
if key in seen:
187+
continue
188+
seen.add(key)
189+
results.append(
190+
RecognizerResult(
191+
entity_type=entity_type,
192+
start=span[0],
193+
end=span[1],
194+
score=_SCORE,
169195
)
170-
match_start = text.find(item.text, match_end)
196+
)
197+
if not matched:
198+
# Logged, not silent: a dropped quote is an Article 9
199+
# disclosure the model saw and we did not redact. The quote
200+
# itself is special-category data, so only its length is
201+
# recorded.
202+
logger.warning(
203+
"article9_quote_unmatched",
204+
extra={
205+
"context": {
206+
"entity_type": entity_type,
207+
"quote_length": len(item.text),
208+
}
209+
},
210+
)
171211
return results

tests/unit/test_llm_guardrail_recognizer.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,62 @@ def test_drops_quotes_that_are_not_verbatim(recognizer, monkeypatch):
7777
assert recognizer.analyze("She said she is a Quaker.", ["RELIGION"], None) == []
7878

7979

80+
def test_ignores_hits_that_start_inside_another_word(recognizer, monkeypatch):
81+
"""Substring matching is blind to word boundaries: a short quote like "he"
82+
also lands inside "the" and "When". Growing those hits leftwards to the word
83+
edge redacted the innocent word whole -- "When the interviewer" came back as
84+
"<SEXUAL_ORIENTATION> interviewer" -- destroying the transcript the model is
85+
scored on. A hit that begins mid-word is not a disclosure; drop it."""
86+
_model_returns(
87+
monkeypatch,
88+
recognizer,
89+
[DetectedEntity(entity_type="SEXUAL_ORIENTATION", text="he")],
90+
)
91+
92+
text = "When the interviewer asked, he said he is out at work."
93+
results = recognizer.analyze(text, ["SEXUAL_ORIENTATION"], None)
94+
spans = sorted((r.start, r.end) for r in results)
95+
96+
assert spans == [(28, 30), (36, 38)]
97+
assert all(text[s:e] == "he" for s, e in spans)
98+
99+
100+
def test_ignores_quotes_with_no_alphanumeric_characters(recognizer, monkeypatch):
101+
"""A quote of " " or "." matches between every word. Redacting each hit
102+
replaces the separators and shreds the transcript
103+
("I<HEALTH>manage<HEALTH>my..."), so it can never be actionable."""
104+
_model_returns(
105+
monkeypatch, recognizer, [DetectedEntity(entity_type="HEALTH", text=" ")]
106+
)
107+
108+
assert recognizer.analyze("I manage my condition well.", ["HEALTH"], None) == []
109+
110+
111+
def test_matches_regardless_of_case(recognizer, monkeypatch):
112+
"""Models routinely re-capitalise what they quote. The offsets are still
113+
exact, so dropping the finding is pure under-redaction -- Article 9 data
114+
reaching the model, which is the one failure this recognizer prevents."""
115+
_model_returns(
116+
monkeypatch, recognizer, [DetectedEntity(entity_type="HEALTH", text="Diabetes")]
117+
)
118+
119+
results = recognizer.analyze("I have diabetes.", ["HEALTH"], None)
120+
121+
assert [(r.start, r.end) for r in results] == [(7, 15)]
122+
123+
124+
def test_grows_a_hit_that_ends_inside_the_same_word(recognizer, monkeypatch):
125+
""" "Muslim" against a transcript saying "Muslims" is the same disclosure
126+
inflected. Stopping at the raw match end leaves "<RELIGION>s" behind."""
127+
_model_returns(
128+
monkeypatch, recognizer, [DetectedEntity(entity_type="RELIGION", text="Muslim")]
129+
)
130+
131+
results = recognizer.analyze("Two Muslims on the team.", ["RELIGION"], None)
132+
133+
assert [(r.start, r.end) for r in results] == [(4, 11)]
134+
135+
80136
def test_makes_no_call_when_no_supported_entity_is_requested(recognizer):
81137
# No stub: a real call would try to reach the endpoint and fail, so passing
82138
# proves we short-circuit before touching the network.

0 commit comments

Comments
 (0)