Skip to content
This repository was archived by the owner on Jun 5, 2025. It is now read-only.

Commit 791a250

Browse files
feat: aggregate alerts per conversation for dashboard (#953)
* method to query alerts per prompt * return alerts from `/messages` endpoint * formatting
1 parent ecdc2e2 commit 791a250

File tree

5 files changed

+131
-6
lines changed

5 files changed

+131
-6
lines changed

src/codegate/api/v1.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,12 @@ async def get_workspace_messages(workspace_name: str) -> List[v1_models.Conversa
405405
raise HTTPException(status_code=500, detail="Internal server error")
406406

407407
try:
408-
prompts_outputs = await dbreader.get_prompts_with_output(ws.id)
409-
conversations, _ = await v1_processing.parse_messages_in_conversations(prompts_outputs)
408+
prompts_with_output_alerts_usage = (
409+
await dbreader.get_prompts_with_output_alerts_usage_by_workspace_id(ws.id)
410+
)
411+
conversations, _ = await v1_processing.parse_messages_in_conversations(
412+
prompts_with_output_alerts_usage
413+
)
410414
return conversations
411415
except Exception:
412416
logger.exception("Error while getting messages")

src/codegate/api/v1_models.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,32 @@ def add_model_token_usage(self, model_token_usage: TokenUsageByModel) -> None:
147147
self.token_usage += model_token_usage.token_usage
148148

149149

150+
class Alert(pydantic.BaseModel):
151+
"""
152+
Represents an alert.
153+
"""
154+
155+
@staticmethod
156+
def from_db_model(db_model: db_models.Alert) -> "Alert":
157+
return Alert(
158+
id=db_model.id,
159+
prompt_id=db_model.prompt_id,
160+
code_snippet=db_model.code_snippet,
161+
trigger_string=db_model.trigger_string,
162+
trigger_type=db_model.trigger_type,
163+
trigger_category=db_model.trigger_category,
164+
timestamp=db_model.timestamp,
165+
)
166+
167+
id: str
168+
prompt_id: str
169+
code_snippet: Optional[CodeSnippet]
170+
trigger_string: Optional[Union[str, dict]]
171+
trigger_type: str
172+
trigger_category: Optional[str]
173+
timestamp: datetime.datetime
174+
175+
150176
class PartialQuestionAnswer(pydantic.BaseModel):
151177
"""
152178
Represents a partial conversation.
@@ -155,6 +181,7 @@ class PartialQuestionAnswer(pydantic.BaseModel):
155181
partial_questions: PartialQuestions
156182
answer: Optional[ChatMessage]
157183
model_token_usage: TokenUsageByModel
184+
alerts: List[Alert] = []
158185

159186

160187
class Conversation(pydantic.BaseModel):
@@ -168,6 +195,7 @@ class Conversation(pydantic.BaseModel):
168195
chat_id: str
169196
conversation_timestamp: datetime.datetime
170197
token_usage_agg: Optional[TokenUsageAggregate]
198+
alerts: List[Alert] = []
171199

172200

173201
class AlertConversation(pydantic.BaseModel):

src/codegate/api/v1_processing.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import requests
99
import structlog
1010

11+
from codegate.api import v1_models
1112
from codegate.api.v1_models import (
1213
AlertConversation,
1314
ChatMessage,
@@ -200,10 +201,15 @@ async def _get_partial_question_answer(
200201
model=model, token_usage=token_usage, provider_type=provider
201202
)
202203

204+
alerts: List[v1_models.Alert] = [
205+
v1_models.Alert.from_db_model(db_alert) for db_alert in row.alerts
206+
]
207+
203208
return PartialQuestionAnswer(
204209
partial_questions=request_message,
205210
answer=output_message,
206211
model_token_usage=model_token_usage,
212+
alerts=alerts,
207213
)
208214

209215

@@ -367,6 +373,7 @@ async def match_conversations(
367373
for group in grouped_partial_questions:
368374
questions_answers: List[QuestionAnswer] = []
369375
token_usage_agg = TokenUsageAggregate(tokens_by_model={}, token_usage=TokenUsage())
376+
alerts: List[v1_models.Alert] = []
370377
first_partial_qa = None
371378
for partial_question in sorted(group, key=lambda x: x.timestamp):
372379
# Partial questions don't contain the answer, so we need to find the corresponding
@@ -385,6 +392,7 @@ async def match_conversations(
385392
qa = _get_question_answer_from_partial(selected_partial_qa)
386393
qa.question.message = parse_question_answer(qa.question.message)
387394
questions_answers.append(qa)
395+
alerts.extend(selected_partial_qa.alerts)
388396
token_usage_agg.add_model_token_usage(selected_partial_qa.model_token_usage)
389397

390398
# only add conversation if we have some answers
@@ -398,6 +406,7 @@ async def match_conversations(
398406
chat_id=first_partial_qa.partial_questions.message_id,
399407
conversation_timestamp=first_partial_qa.partial_questions.timestamp,
400408
token_usage_agg=token_usage_agg,
409+
alerts=alerts,
401410
)
402411
for qa in questions_answers:
403412
map_q_id_to_conversation[qa.question.message_id] = conversation

src/codegate/db/connection.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import uuid
44
from pathlib import Path
5-
from typing import List, Optional, Type
5+
from typing import Dict, List, Optional, Type
66

77
import structlog
88
from alembic import command as alembic_command
@@ -19,6 +19,7 @@
1919
Alert,
2020
GetPromptWithOutputsRow,
2121
GetWorkspaceByNameConditions,
22+
IntermediatePromptWithOutputUsageAlerts,
2223
MuxRule,
2324
Output,
2425
Prompt,
@@ -89,7 +90,6 @@ def does_db_exist(self):
8990

9091

9192
class DbRecorder(DbCodeGate):
92-
9393
def __init__(self, sqlite_path: Optional[str] = None):
9494
super().__init__(sqlite_path)
9595

@@ -517,7 +517,6 @@ async def add_mux(self, mux: MuxRule) -> MuxRule:
517517

518518

519519
class DbReader(DbCodeGate):
520-
521520
def __init__(self, sqlite_path: Optional[str] = None):
522521
super().__init__(sqlite_path)
523522

@@ -586,6 +585,64 @@ async def get_prompts_with_output(self, workpace_id: str) -> List[GetPromptWithO
586585
)
587586
return prompts
588587

588+
async def get_prompts_with_output_alerts_usage_by_workspace_id(
589+
self, workspace_id: str
590+
) -> List[GetPromptWithOutputsRow]:
591+
"""
592+
Get all prompts with their outputs, alerts and token usage by workspace_id.
593+
"""
594+
595+
sql = text(
596+
"""
597+
SELECT
598+
p.id as prompt_id, p.timestamp as prompt_timestamp, p.provider, p.request, p.type,
599+
o.id as output_id, o.output, o.timestamp as output_timestamp, o.input_tokens, o.output_tokens, o.input_cost, o.output_cost,
600+
a.id as alert_id, a.code_snippet, a.trigger_string, a.trigger_type, a.trigger_category, a.timestamp as alert_timestamp
601+
FROM prompts p
602+
LEFT JOIN outputs o ON p.id = o.prompt_id
603+
LEFT JOIN alerts a ON p.id = a.prompt_id
604+
WHERE p.workspace_id = :workspace_id
605+
ORDER BY o.timestamp DESC, a.timestamp DESC
606+
""" # noqa: E501
607+
)
608+
conditions = {"workspace_id": workspace_id}
609+
rows = await self._exec_select_conditions_to_pydantic(
610+
IntermediatePromptWithOutputUsageAlerts, sql, conditions, should_raise=True
611+
)
612+
613+
prompts_dict: Dict[str, GetPromptWithOutputsRow] = {}
614+
for row in rows:
615+
prompt_id = row.prompt_id
616+
if prompt_id not in prompts_dict:
617+
prompts_dict[prompt_id] = GetPromptWithOutputsRow(
618+
id=row.prompt_id,
619+
timestamp=row.prompt_timestamp,
620+
provider=row.provider,
621+
request=row.request,
622+
type=row.type,
623+
output_id=row.output_id,
624+
output=row.output,
625+
output_timestamp=row.output_timestamp,
626+
input_tokens=row.input_tokens,
627+
output_tokens=row.output_tokens,
628+
input_cost=row.input_cost,
629+
output_cost=row.output_cost,
630+
alerts=[],
631+
)
632+
if row.alert_id:
633+
alert = Alert(
634+
id=row.alert_id,
635+
prompt_id=row.prompt_id,
636+
code_snippet=row.code_snippet,
637+
trigger_string=row.trigger_string,
638+
trigger_type=row.trigger_type,
639+
trigger_category=row.trigger_category,
640+
timestamp=row.alert_timestamp,
641+
)
642+
prompts_dict[prompt_id].alerts.append(alert)
643+
644+
return list(prompts_dict.values())
645+
589646
async def get_alerts_by_workspace(
590647
self, workspace_id: str, trigger_category: Optional[str] = None
591648
) -> List[Alert]:

src/codegate/db/models.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22
from enum import Enum
3-
from typing import Annotated, Any, Dict, Optional
3+
from typing import Annotated, Any, Dict, List, Optional
44

55
from pydantic import BaseModel, StringConstraints
66

@@ -131,6 +131,32 @@ class ProviderType(str, Enum):
131131
openrouter = "openai"
132132

133133

134+
class IntermediatePromptWithOutputUsageAlerts(BaseModel):
135+
"""
136+
An intermediate model to represent the result of a query
137+
for a prompt and related outputs, usage stats & alerts.
138+
"""
139+
140+
prompt_id: Any
141+
prompt_timestamp: Any
142+
provider: Optional[Any]
143+
request: Any
144+
type: Any
145+
output_id: Optional[Any]
146+
output: Optional[Any]
147+
output_timestamp: Optional[Any]
148+
input_tokens: Optional[int]
149+
output_tokens: Optional[int]
150+
input_cost: Optional[float]
151+
output_cost: Optional[float]
152+
alert_id: Optional[Any]
153+
code_snippet: Optional[Any]
154+
trigger_string: Optional[Any]
155+
trigger_type: Optional[Any]
156+
trigger_category: Optional[Any]
157+
alert_timestamp: Optional[Any]
158+
159+
134160
class GetPromptWithOutputsRow(BaseModel):
135161
id: Any
136162
timestamp: Any
@@ -144,6 +170,7 @@ class GetPromptWithOutputsRow(BaseModel):
144170
output_tokens: Optional[int]
145171
input_cost: Optional[float]
146172
output_cost: Optional[float]
173+
alerts: List[Alert] = []
147174

148175

149176
class WorkspaceWithSessionInfo(BaseModel):

0 commit comments

Comments
 (0)