Skip to content

Commit 69bbdf7

Browse files
Merge pull request trustyai-explainability#29 from saichandrapandraju/multi-detector-state
Refactor detector management to store multiple detector types
2 parents c43a351 + 746cd4f commit 69bbdf7

File tree

6 files changed

+175
-16
lines changed

6 files changed

+175
-16
lines changed

detectors/built_in/app.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from fastapi import HTTPException
2-
2+
from contextlib import asynccontextmanager
33
from base_detector_registry import BaseDetectorRegistry
44
from regex_detectors import RegexDetectorRegistry
55
from file_type_detectors import FileTypeDetectorRegistry
@@ -8,21 +8,32 @@
88
from detectors.common.scheme import ContentAnalysisHttpRequest, ContentsAnalysisResponse
99
from detectors.common.app import DetectorBaseAPI as FastAPI
1010

11-
app = FastAPI()
11+
@asynccontextmanager
12+
async def lifespan(app: FastAPI):
13+
app.set_detector(RegexDetectorRegistry(), "regex")
14+
app.set_detector(FileTypeDetectorRegistry(), "file_type")
15+
yield
16+
17+
app.cleanup_detector()
18+
19+
20+
app = FastAPI(lifespan=lifespan)
1221
Instrumentator().instrument(app).expose(app)
1322

1423

15-
registry : dict[str, BaseDetectorRegistry] = {
16-
"regex": RegexDetectorRegistry(),
17-
"file_type": FileTypeDetectorRegistry(),
18-
}
24+
# registry : dict[str, BaseDetectorRegistry] = {
25+
# "regex": RegexDetectorRegistry(),
26+
# "file_type": FileTypeDetectorRegistry(),
27+
# }
1928

2029
@app.post("/api/v1/text/contents", response_model=ContentsAnalysisResponse)
2130
def detect_content(request: ContentAnalysisHttpRequest):
2231
detections = []
2332
for content in request.contents:
2433
message_detections = []
25-
for detector_kind, detector_registry in registry.items():
34+
for detector_kind, detector_registry in app.get_all_detectors().items():
35+
if not isinstance(detector_registry, BaseDetectorRegistry):
36+
raise TypeError(f"Detector {detector_kind} is not a valid BaseDetectorRegistry")
2637
if detector_kind in request.detector_params:
2738
try:
2839
message_detections += detector_registry.handle_request(content, request.detector_params)
@@ -37,7 +48,9 @@ def detect_content(request: ContentAnalysisHttpRequest):
3748
@app.get("/registry")
3849
def get_registry():
3950
result = {}
40-
for detector_type, detector_registry in registry.items():
51+
for detector_type, detector_registry in app.get_all_detectors().items():
52+
if not isinstance(detector_registry, BaseDetectorRegistry):
53+
raise TypeError(f"Detector {detector_type} is not a valid BaseDetectorRegistry")
4154
result[detector_type] = {}
4255
for detector_name, detector_fn in detector_registry.get_registry().items():
4356
result[detector_type][detector_name] = detector_fn.__doc__

detectors/common/app.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
class DetectorBaseAPI(FastAPI):
3535
def __init__(self, *args, **kwargs):
3636
super().__init__(*args, **kwargs)
37+
self.state.detectors = {}
3738
self.add_exception_handler(
3839
RequestValidationError, self.validation_exception_handler
3940
)
@@ -94,17 +95,21 @@ async def http_exception_handler(self, request, exc):
9495
content={"code": exc.status_code, "message": exc.detail},
9596
)
9697

97-
def set_detector(self, detector) -> None:
98+
def set_detector(self, detector, detector_name="default") -> None:
9899
"""Store detector in app.state"""
99-
self.state.detector = detector
100+
self.state.detectors[detector_name] = detector
100101

101-
def get_detector(self):
102+
def get_detector(self, detector_name="default"):
102103
"""Retrieve detector from app.state"""
103-
return getattr(self.state, 'detector', None)
104+
return self.state.detectors.get(detector_name)
105+
106+
def get_all_detectors(self) -> dict:
107+
"""Retrieve all detectors from app.state"""
108+
return self.state.detectors
104109

105110
def cleanup_detector(self) -> None:
106111
"""Clean up detector resources"""
107-
self.state.detector = None
112+
self.state.detectors.clear()
108113

109114
async def health():
110115
return "ok"

detectors/llm_judge/app.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from contextlib import asynccontextmanager
22
from typing import Annotated, Dict
33

4-
from fastapi import Header
4+
from fastapi import Header, HTTPException
55
from prometheus_fastapi_instrumentator import Instrumentator
66

77
from detectors.common.app import DetectorBaseAPI as FastAPI
@@ -48,6 +48,8 @@ async def detector_unary_handler(
4848
):
4949
"""Analyze content using LLM-as-Judge evaluation."""
5050
detector: LLMJudgeDetector = app.get_detector()
51+
if not detector:
52+
raise HTTPException(status_code=503, detail="Detector not found")
5153
return ContentsAnalysisResponse(root=await detector.run(request))
5254

5355

@@ -63,7 +65,7 @@ async def list_metrics():
6365
"""List all available evaluation metrics."""
6466
detector: LLMJudgeDetector = app.get_detector()
6567
if not detector:
66-
return {"metrics": [], "total": 0}
68+
raise HTTPException(status_code=503, detail="Detector not found")
6769

6870
metrics = detector.list_available_metrics()
6971
return MetricsListResponse(metrics=metrics, total=len(metrics))

tests/detectors/builtIn/test_filetype.py

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class TestFileTypeDetectors:
77
@pytest.fixture
88
def client(self):
99
from detectors.built_in.app import app
10+
from detectors.built_in.file_type_detectors import FileTypeDetectorRegistry
11+
12+
app.set_detector(FileTypeDetectorRegistry(), "file_type")
13+
1014
return TestClient(app)
1115

1216
@pytest.fixture
@@ -280,4 +284,79 @@ def test_multiple_filetype_valid_and_invalid(self, client: TestClient):
280284
assert not any(d["detection"] == "invalid_yaml" for d in detections[5])
281285

282286
# 7: invalid yaml
283-
assert any(d["detection"] == "invalid_yaml" for d in detections[6])
287+
assert any(d["detection"] == "invalid_yaml" for d in detections[6])
288+
289+
290+
# === ERROR HANDLING & INVALID DETECTOR TYPES =================================================
291+
def test_unregistered_detector_kind_ignored(self, client: TestClient):
292+
"""Test that requesting an unregistered detector kind is silently ignored"""
293+
payload = {
294+
"contents": ['{"a": 1}'],
295+
"detector_params": {"nonexistent_detector": ["some_value"]}
296+
}
297+
resp = client.post("/api/v1/text/contents", json=payload)
298+
assert resp.status_code == 200
299+
# Should return empty list since nonexistent_detector is not registered
300+
assert resp.json()[0] == []
301+
302+
def test_mixed_valid_invalid_detector_kinds(self, client: TestClient):
303+
"""Test mixing valid and invalid detector kinds"""
304+
payload = {
305+
"contents": ['{a: 1, b: 2}'],
306+
"detector_params": {
307+
"file_type": ["json"], # valid detector kind
308+
"nonexistent_detector": ["some_value"] # invalid detector kind
309+
}
310+
}
311+
resp = client.post("/api/v1/text/contents", json=payload)
312+
assert resp.status_code == 200
313+
detections = resp.json()[0]
314+
# Should only process the valid detector kind
315+
assert detections[0]["detection"] == "invalid_json"
316+
317+
def test_empty_detector_params(self, client: TestClient):
318+
"""Test with empty detector_params"""
319+
payload = {
320+
"contents": ['{"a": 1}'],
321+
"detector_params": {}
322+
}
323+
resp = client.post("/api/v1/text/contents", json=payload)
324+
assert resp.status_code == 200
325+
# Should return empty list since no detectors are specified
326+
assert resp.json()[0] == []
327+
328+
def test_multiple_invalid_file_types(self, client: TestClient):
329+
"""Test multiple invalid file types to ensure all errors are handled"""
330+
payload = {
331+
"contents": ['test content'],
332+
"detector_params": {"file_type": ["invalid_type_1", "invalid_type_2"]}
333+
}
334+
resp = client.post("/api/v1/text/contents", json=payload)
335+
assert resp.status_code == 400
336+
data = resp.json()
337+
assert "message" in data
338+
assert "Unrecognized file type" in data["message"]
339+
340+
def test_mixed_valid_invalid_file_types(self, client: TestClient):
341+
"""Test mixing valid and invalid file types"""
342+
payload = {
343+
"contents": ['{a: 1, b: 2}'],
344+
"detector_params": {"file_type": ["json", "invalid_type"]}
345+
}
346+
resp = client.post("/api/v1/text/contents", json=payload)
347+
assert resp.status_code == 400
348+
data = resp.json()
349+
assert "message" in data
350+
assert "Unrecognized file type" in data["message"]
351+
352+
def test_case_sensitivity_file_types(self, client: TestClient):
353+
"""Test case sensitivity of file types"""
354+
payload = {
355+
"contents": ['{"a": 1}'],
356+
"detector_params": {"file_type": ["JSON"]} # uppercase
357+
}
358+
resp = client.post("/api/v1/text/contents", json=payload)
359+
assert resp.status_code == 400
360+
data = resp.json()
361+
assert "message" in data
362+
assert "Unrecognized file type" in data["message"]

tests/detectors/builtIn/test_regex.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ class TestRegexDetectors:
55
@pytest.fixture
66
def client(self):
77
from detectors.built_in.app import app
8+
from detectors.built_in.regex_detectors import RegexDetectorRegistry
9+
10+
app.set_detector(RegexDetectorRegistry(), "regex")
11+
812
return TestClient(app)
913

1014
@pytest.mark.parametrize(
@@ -194,4 +198,59 @@ def test_single_detector(self, client):
194198
assert any("[email protected]" in d["text"] for d in results[0])
195199

196200

201+
# === ERROR HANDLING & INVALID DETECTOR TYPES =================================================
202+
def test_unregistered_detector_kind_ignored(self, client):
203+
"""Test that requesting an unregistered detector kind is silently ignored"""
204+
payload = {
205+
"contents": ["[email protected]"],
206+
"detector_params": {"nonexistent_detector": ["some_value"]}
207+
}
208+
resp = client.post("/api/v1/text/contents", json=payload)
209+
assert resp.status_code == 200
210+
# Should return empty list since nonexistent_detector is not registered
211+
assert resp.json()[0] == []
212+
213+
def test_mixed_valid_invalid_detector_kinds(self, client):
214+
"""Test mixing valid and invalid detector kinds"""
215+
payload = {
216+
"contents": ["Contact me at [email protected]"],
217+
"detector_params": {
218+
"regex": ["email"], # valid detector kind
219+
"nonexistent_detector": ["some_value"] # invalid detector kind
220+
}
221+
}
222+
resp = client.post("/api/v1/text/contents", json=payload)
223+
assert resp.status_code == 200
224+
detections = resp.json()[0]
225+
# Should only process the valid detector kind
226+
assert detections[0]["text"] == "[email protected]"
227+
228+
def test_empty_detector_params(self, client):
229+
"""Test with empty detector_params"""
230+
payload = {
231+
"contents": ["[email protected]"],
232+
"detector_params": {}
233+
}
234+
resp = client.post("/api/v1/text/contents", json=payload)
235+
assert resp.status_code == 200
236+
# Should return empty list since no detectors are specified
237+
assert resp.json()[0] == []
238+
239+
def test_null_regex_pattern(self, client):
240+
"""Test with null regex pattern"""
241+
payload = {
242+
"contents": ["[email protected]"],
243+
"detector_params": {"regex": [None]}
244+
}
245+
resp = client.post("/api/v1/text/contents", json=payload)
246+
assert resp.status_code == 500 # Should cause an error when processing None
247+
248+
def test_malformed_regex_groups(self, client):
249+
"""Test malformed regex with unmatched groups"""
250+
payload = {
251+
"contents": ["test content"],
252+
"detector_params": {"regex": ["(unclosed group"]}
253+
}
254+
resp = client.post("/api/v1/text/contents", json=payload)
255+
assert resp.status_code == 500 # Should cause regex compilation error
197256

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ deps =
1010
-r{toxinidir}/detectors/huggingface/requirements.txt
1111
-r{toxinidir}/detectors/common/requirements.txt
1212
-r{toxinidir}/detectors/llm_judge/requirements.txt
13+
-r{toxinidir}/detectors/built_in/requirements.txt
1314
setenv =
1415
PYTHONPATH = {toxinidir}/detectors/huggingface:{toxinidir}/detectors/llm_judge:{toxinidir}/detectors:{toxinidir}
1516
commands =

0 commit comments

Comments
 (0)