Skip to content

Commit e8a70ac

Browse files
Fix API alerts (#1075)
We had some small bugs related with alerts: 1. The function for deduping alerts was only returning alerts related to secrets. All the other alerts were being dropped when calling this function. 2. We were returning non-critical alerts in the `/messages/` endpoint 3. PII alerts were never recorded because the context was never passed to the function. Discovered by @kd This PR fixes the 3 of them and introduces some unit tests.
1 parent 2efb453 commit e8a70ac

File tree

9 files changed

+165
-32
lines changed

9 files changed

+165
-32
lines changed

src/codegate/api/v1.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,9 @@ async def get_workspace_messages(workspace_name: str) -> List[v1_models.Conversa
414414

415415
try:
416416
prompts_with_output_alerts_usage = (
417-
await dbreader.get_prompts_with_output_alerts_usage_by_workspace_id(ws.id)
417+
await dbreader.get_prompts_with_output_alerts_usage_by_workspace_id(
418+
ws.id, AlertSeverity.CRITICAL.value
419+
)
418420
)
419421
conversations, _ = await v1_processing.parse_messages_in_conversations(
420422
prompts_with_output_alerts_usage

src/codegate/api/v1_processing.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -534,4 +534,4 @@ async def remove_duplicate_alerts(alerts: List[v1_models.Alert]) -> List[v1_mode
534534
seen[key] = alert
535535
unique_alerts.append(alert)
536536

537-
return list(seen.values())
537+
return unique_alerts

src/codegate/db/connection.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ async def get_prompts_with_output(self, workpace_id: str) -> List[GetPromptWithO
586586
return prompts
587587

588588
async def get_prompts_with_output_alerts_usage_by_workspace_id(
589-
self, workspace_id: str
589+
self, workspace_id: str, trigger_category: Optional[str] = None
590590
) -> List[GetPromptWithOutputsRow]:
591591
"""
592592
Get all prompts with their outputs, alerts and token usage by workspace_id.
@@ -602,12 +602,17 @@ async def get_prompts_with_output_alerts_usage_by_workspace_id(
602602
LEFT JOIN outputs o ON p.id = o.prompt_id
603603
LEFT JOIN alerts a ON p.id = a.prompt_id
604604
WHERE p.workspace_id = :workspace_id
605+
AND a.trigger_category LIKE :trigger_category
605606
ORDER BY o.timestamp DESC, a.timestamp DESC
606607
""" # noqa: E501
607608
)
608-
conditions = {"workspace_id": workspace_id}
609-
rows = await self._exec_select_conditions_to_pydantic(
610-
IntermediatePromptWithOutputUsageAlerts, sql, conditions, should_raise=True
609+
# If trigger category is None we want to get all alerts
610+
trigger_category = trigger_category if trigger_category else "%"
611+
conditions = {"workspace_id": workspace_id, "trigger_category": trigger_category}
612+
rows: List[IntermediatePromptWithOutputUsageAlerts] = (
613+
await self._exec_select_conditions_to_pydantic(
614+
IntermediatePromptWithOutputUsageAlerts, sql, conditions, should_raise=True
615+
)
611616
)
612617

613618
prompts_dict: Dict[str, GetPromptWithOutputsRow] = {}

src/codegate/pipeline/pii/analyzer.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def __init__(self):
100100
PiiAnalyzer._instance = self
101101

102102
def analyze(
103-
self, text: str, context: Optional["PipelineContext"] = None
103+
self, text: str, context: Optional[PipelineContext] = None
104104
) -> Tuple[str, List[Dict[str, Any]], PiiSessionStore]:
105105
# Prioritize credit card detection first
106106
entities = [

src/codegate/pipeline/pii/manager.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from typing import Any, Dict, List, Tuple
1+
from typing import Any, Dict, List, Optional, Tuple
22

33
import structlog
44

5+
from codegate.pipeline.base import PipelineContext
56
from codegate.pipeline.pii.analyzer import PiiAnalyzer, PiiSessionStore
67

78
logger = structlog.get_logger("codegate")
@@ -52,9 +53,11 @@ def session_store(self) -> PiiSessionStore:
5253
# Always return the analyzer's current session store
5354
return self.analyzer.session_store
5455

55-
def analyze(self, text: str) -> Tuple[str, List[Dict[str, Any]]]:
56+
def analyze(
57+
self, text: str, context: Optional[PipelineContext] = None
58+
) -> Tuple[str, List[Dict[str, Any]]]:
5659
# Call analyzer and get results
57-
anonymized_text, found_pii, _ = self.analyzer.analyze(text)
60+
anonymized_text, found_pii, _ = self.analyzer.analyze(text, context=context)
5861

5962
# Log found PII details (without modifying the found_pii list)
6063
if found_pii:

src/codegate/pipeline/pii/pii.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async def process(
8080
if "content" in message and message["content"]:
8181
# This is where analyze and anonymize the text
8282
original_text = str(message["content"])
83-
anonymized_text, pii_details = self.pii_manager.analyze(original_text)
83+
anonymized_text, pii_details = self.pii_manager.analyze(original_text, context)
8484

8585
if pii_details:
8686
total_pii_found += len(pii_details)

tests/api/__init__.py

Whitespace-only changes.

tests/api/test_v1_processing.py

+142-19
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
import pytest
66

7-
from codegate.api.v1_models import PartialQuestions
7+
from codegate.api import v1_models
88
from codegate.api.v1_processing import (
99
_get_partial_question_answer,
1010
_group_partial_messages,
1111
_is_system_prompt,
1212
parse_output,
1313
parse_request,
14+
remove_duplicate_alerts,
1415
)
1516
from codegate.db.models import GetPromptWithOutputsRow
1617

@@ -193,14 +194,14 @@ async def test_get_question_answer(request_msg_list, output_msg_str, row):
193194
# 1) No subsets: all items stand alone
194195
(
195196
[
196-
PartialQuestions(
197+
v1_models.PartialQuestions(
197198
messages=["A"],
198199
timestamp=datetime.datetime(2023, 1, 1, 0, 0, 0),
199200
message_id="pq1",
200201
provider="providerA",
201202
type="chat",
202203
),
203-
PartialQuestions(
204+
v1_models.PartialQuestions(
204205
messages=["B"],
205206
timestamp=datetime.datetime(2023, 1, 1, 0, 0, 1),
206207
message_id="pq2",
@@ -214,14 +215,14 @@ async def test_get_question_answer(request_msg_list, output_msg_str, row):
214215
# - "Hello" is a subset of "Hello, how are you?"
215216
(
216217
[
217-
PartialQuestions(
218+
v1_models.PartialQuestions(
218219
messages=["Hello"],
219220
timestamp=datetime.datetime(2022, 1, 1, 0, 0, 0),
220221
message_id="pq1",
221222
provider="providerA",
222223
type="chat",
223224
),
224-
PartialQuestions(
225+
v1_models.PartialQuestions(
225226
messages=["Hello", "How are you?"],
226227
timestamp=datetime.datetime(2022, 1, 1, 0, 0, 10),
227228
message_id="pq2",
@@ -238,28 +239,28 @@ async def test_get_question_answer(request_msg_list, output_msg_str, row):
238239
# superset.
239240
(
240241
[
241-
PartialQuestions(
242+
v1_models.PartialQuestions(
242243
messages=["Hello"],
243244
timestamp=datetime.datetime(2023, 1, 1, 10, 0, 0),
244245
message_id="pq1",
245246
provider="providerA",
246247
type="chat",
247248
),
248-
PartialQuestions(
249+
v1_models.PartialQuestions(
249250
messages=["Hello"],
250251
timestamp=datetime.datetime(2023, 1, 1, 11, 0, 0),
251252
message_id="pq2",
252253
provider="providerA",
253254
type="chat",
254255
),
255-
PartialQuestions(
256+
v1_models.PartialQuestions(
256257
messages=["Hello"],
257258
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 0),
258259
message_id="pq3",
259260
provider="providerA",
260261
type="chat",
261262
),
262-
PartialQuestions(
263+
v1_models.PartialQuestions(
263264
messages=["Hello", "Bye"],
264265
timestamp=datetime.datetime(2023, 1, 1, 11, 0, 5),
265266
message_id="pq4",
@@ -281,68 +282,68 @@ async def test_get_question_answer(request_msg_list, output_msg_str, row):
281282
(
282283
[
283284
# Superset
284-
PartialQuestions(
285+
v1_models.PartialQuestions(
285286
messages=["hi", "welcome", "bye"],
286287
timestamp=datetime.datetime(2023, 5, 1, 9, 0, 0),
287288
message_id="pqS1",
288289
provider="providerB",
289290
type="chat",
290291
),
291292
# Subsets for pqS1
292-
PartialQuestions(
293+
v1_models.PartialQuestions(
293294
messages=["hi", "welcome"],
294295
timestamp=datetime.datetime(2023, 5, 1, 9, 0, 5),
295296
message_id="pqA1",
296297
provider="providerB",
297298
type="chat",
298299
),
299-
PartialQuestions(
300+
v1_models.PartialQuestions(
300301
messages=["hi", "bye"],
301302
timestamp=datetime.datetime(2023, 5, 1, 9, 0, 10),
302303
message_id="pqA2",
303304
provider="providerB",
304305
type="chat",
305306
),
306-
PartialQuestions(
307+
v1_models.PartialQuestions(
307308
messages=["hi", "bye"],
308309
timestamp=datetime.datetime(2023, 5, 1, 9, 0, 12),
309310
message_id="pqA3",
310311
provider="providerB",
311312
type="chat",
312313
),
313314
# Another superset
314-
PartialQuestions(
315+
v1_models.PartialQuestions(
315316
messages=["apple", "banana", "cherry"],
316317
timestamp=datetime.datetime(2023, 5, 2, 10, 0, 0),
317318
message_id="pqS2",
318319
provider="providerB",
319320
type="chat",
320321
),
321322
# Subsets for pqS2
322-
PartialQuestions(
323+
v1_models.PartialQuestions(
323324
messages=["banana"],
324325
timestamp=datetime.datetime(2023, 5, 2, 10, 0, 1),
325326
message_id="pqB1",
326327
provider="providerB",
327328
type="chat",
328329
),
329-
PartialQuestions(
330+
v1_models.PartialQuestions(
330331
messages=["apple", "banana"],
331332
timestamp=datetime.datetime(2023, 5, 2, 10, 0, 3),
332333
message_id="pqB2",
333334
provider="providerB",
334335
type="chat",
335336
),
336337
# Another item alone, not a subset nor superset
337-
PartialQuestions(
338+
v1_models.PartialQuestions(
338339
messages=["xyz"],
339340
timestamp=datetime.datetime(2023, 5, 3, 8, 0, 0),
340341
message_id="pqC1",
341342
provider="providerB",
342343
type="chat",
343344
),
344345
# Different provider => should remain separate
345-
PartialQuestions(
346+
v1_models.PartialQuestions(
346347
messages=["hi", "welcome"],
347348
timestamp=datetime.datetime(2023, 5, 1, 9, 0, 10),
348349
message_id="pqProvDiff",
@@ -394,7 +395,7 @@ def test_group_partial_messages(pq_list, expected_group_ids):
394395
# Execute
395396
grouped = _group_partial_messages(pq_list)
396397

397-
# Convert from list[list[PartialQuestions]] -> list[list[str]]
398+
# Convert from list[list[v1_models.PartialQuestions]] -> list[list[str]]
398399
# so we can compare with expected_group_ids easily.
399400
grouped_ids = [[pq.message_id for pq in group] for group in grouped]
400401

@@ -406,3 +407,125 @@ def test_group_partial_messages(pq_list, expected_group_ids):
406407
is_matched = True
407408
break
408409
assert is_matched
410+
411+
412+
@pytest.mark.asyncio
413+
@pytest.mark.parametrize(
414+
"alerts,expected_count,expected_ids",
415+
[
416+
# Test Case 1: Non-secret alerts pass through unchanged
417+
(
418+
[
419+
v1_models.Alert(
420+
id="1",
421+
prompt_id="p1",
422+
code_snippet=None,
423+
trigger_string="test1",
424+
trigger_type="other-alert",
425+
trigger_category="info",
426+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 0),
427+
),
428+
v1_models.Alert(
429+
id="2",
430+
prompt_id="p2",
431+
code_snippet=None,
432+
trigger_string="test2",
433+
trigger_type="other-alert",
434+
trigger_category="info",
435+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 1),
436+
),
437+
],
438+
2, # Expected count
439+
["1", "2"], # Expected IDs preserved
440+
),
441+
# Test Case 2: Duplicate secrets within 5 seconds - keep newer only
442+
(
443+
[
444+
v1_models.Alert(
445+
id="1",
446+
prompt_id="p1",
447+
code_snippet=None,
448+
trigger_string="secret1 Context xyz",
449+
trigger_type="codegate-secrets",
450+
trigger_category="critical",
451+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 0),
452+
),
453+
v1_models.Alert(
454+
id="2",
455+
prompt_id="p2",
456+
code_snippet=None,
457+
trigger_string="secret1 Context abc",
458+
trigger_type="codegate-secrets",
459+
trigger_category="critical",
460+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 3),
461+
),
462+
],
463+
1, # Expected count
464+
["2"], # Only newer alert ID
465+
),
466+
# Test Case 3: Similar secrets beyond 5 seconds - keep both
467+
(
468+
[
469+
v1_models.Alert(
470+
id="1",
471+
prompt_id="p1",
472+
code_snippet=None,
473+
trigger_string="secret1 Context xyz",
474+
trigger_type="codegate-secrets",
475+
trigger_category="critical",
476+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 0),
477+
),
478+
v1_models.Alert(
479+
id="2",
480+
prompt_id="p2",
481+
code_snippet=None,
482+
trigger_string="secret1 Context abc",
483+
trigger_type="codegate-secrets",
484+
trigger_category="critical",
485+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 6),
486+
),
487+
],
488+
2, # Expected count
489+
["1", "2"], # Both alerts preserved
490+
),
491+
# Test Case 4: Mix of secret and non-secret alerts
492+
(
493+
[
494+
v1_models.Alert(
495+
id="1",
496+
prompt_id="p1",
497+
code_snippet=None,
498+
trigger_string="secret1 Context xyz",
499+
trigger_type="codegate-secrets",
500+
trigger_category="critical",
501+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 0),
502+
),
503+
v1_models.Alert(
504+
id="2",
505+
prompt_id="p2",
506+
code_snippet=None,
507+
trigger_string="non-secret alert",
508+
trigger_type="other-alert",
509+
trigger_category="info",
510+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 1),
511+
),
512+
v1_models.Alert(
513+
id="3",
514+
prompt_id="p3",
515+
code_snippet=None,
516+
trigger_string="secret1 Context abc",
517+
trigger_type="codegate-secrets",
518+
trigger_category="critical",
519+
timestamp=datetime.datetime(2023, 1, 1, 12, 0, 3),
520+
),
521+
],
522+
2, # Expected count
523+
["2", "3"], # Non-secret alert and newest secret alert
524+
),
525+
],
526+
)
527+
async def test_remove_duplicate_alerts(alerts, expected_count, expected_ids):
528+
result = await remove_duplicate_alerts(alerts)
529+
assert len(result) == expected_count
530+
result_ids = [alert.id for alert in result]
531+
assert sorted(result_ids) == sorted(expected_ids)

0 commit comments

Comments
 (0)