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

Commit 334e18a

Browse files
Merge pull request #175 from stacklok/setup-dashboard
Added initial dashboard functionality
2 parents 461d77a + 2aedfdf commit 334e18a

File tree

8 files changed

+602
-6
lines changed

8 files changed

+602
-6
lines changed

sql/queries/queries.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,14 @@ LEFT JOIN outputs o ON p.id = o.prompt_id
8686
LEFT JOIN alerts a ON p.id = a.prompt_id
8787
WHERE p.id = ?
8888
ORDER BY o.timestamp DESC, a.timestamp DESC;
89+
90+
91+
-- name: GetPromptWithOutputs :many
92+
SELECT
93+
p.*,
94+
o.id as output_id,
95+
o.output,
96+
o.timestamp as output_timestamp
97+
FROM prompts p
98+
LEFT JOIN outputs o ON p.id = o.prompt_id
99+
ORDER BY o.timestamp DESC;

src/codegate/dashboard/dashboard.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import asyncio
2+
from typing import List
3+
4+
import structlog
5+
from fastapi import APIRouter
6+
7+
from codegate.dashboard.post_processing import match_conversations, parse_get_prompt_with_output
8+
from codegate.dashboard.request_models import Conversation
9+
from codegate.db.connection import DbReader
10+
11+
logger = structlog.get_logger("codegate")
12+
13+
dashboard_router = APIRouter(tags=["Dashboard"])
14+
db_reader = DbReader()
15+
16+
17+
@dashboard_router.get("/dashboard/messages")
18+
async def get_messages() -> List[Conversation]:
19+
"""
20+
Get all the messages from the database and return them as a list of conversations.
21+
"""
22+
prompts_outputs = await db_reader.get_prompts_with_output()
23+
24+
# Parse the prompts and outputs in parallel
25+
async with asyncio.TaskGroup() as tg:
26+
tasks = [tg.create_task(parse_get_prompt_with_output(row)) for row in prompts_outputs]
27+
partial_conversations = [task.result() for task in tasks]
28+
29+
conversations = await match_conversations(partial_conversations)
30+
return conversations
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import asyncio
2+
import json
3+
from typing import List, Optional, Tuple
4+
5+
import structlog
6+
7+
from codegate.dashboard.request_models import (
8+
ChatMessage,
9+
Conversation,
10+
PartialConversation,
11+
QuestionAnswer,
12+
)
13+
from codegate.db.queries import GetPromptWithOutputsRow
14+
15+
logger = structlog.get_logger("codegate")
16+
17+
18+
SYSTEM_PROMPTS = [
19+
"Given the following... please reply with a short summary that is 4-12 words in length, "
20+
"you should summarize what the user is asking for OR what the user is trying to accomplish. "
21+
"You should only respond with the summary, no additional text or explanation, "
22+
"you don't need ending punctuation.",
23+
]
24+
25+
26+
async def _is_system_prompt(message: str) -> bool:
27+
"""
28+
Check if the message is a system prompt.
29+
"""
30+
for prompt in SYSTEM_PROMPTS:
31+
if prompt in message or message in prompt:
32+
return True
33+
return False
34+
35+
36+
async def parse_request(request_str: str) -> Optional[str]:
37+
"""
38+
Parse the request string from the pipeline and return the message.
39+
"""
40+
try:
41+
request = json.loads(request_str)
42+
except Exception as e:
43+
logger.exception(f"Error parsing request: {e}")
44+
return None
45+
46+
messages = []
47+
for message in request.get("messages", []):
48+
role = message.get("role")
49+
if not role == "user":
50+
continue
51+
content = message.get("content")
52+
53+
message_str = ""
54+
if isinstance(content, str):
55+
message_str = content
56+
elif isinstance(content, list):
57+
for content_part in content:
58+
if isinstance(content_part, dict) and content_part.get("type") == "text":
59+
message_str = content_part.get("text")
60+
61+
if message_str and not await _is_system_prompt(message_str):
62+
messages.append(message_str)
63+
64+
# We couldn't get anything from the messages, try the prompt
65+
if not messages:
66+
message_prompt = request.get("prompt", "")
67+
if message_prompt and not await _is_system_prompt(message_prompt):
68+
messages.append(message_prompt)
69+
70+
# If still we don't have anything, return empty string
71+
if not messages:
72+
return None
73+
74+
# Only respond with the latest message
75+
return messages[-1]
76+
77+
78+
async def parse_output(output_str: str) -> Tuple[Optional[str], Optional[str]]:
79+
"""
80+
Parse the output string from the pipeline and return the message and chat_id.
81+
"""
82+
try:
83+
output = json.loads(output_str)
84+
except Exception as e:
85+
logger.exception(f"Error parsing request: {e}")
86+
return None, None
87+
88+
output_message = ""
89+
chat_id = None
90+
if isinstance(output, list):
91+
for output_chunk in output:
92+
if not isinstance(output_chunk, dict):
93+
continue
94+
chat_id = chat_id or output_chunk.get("id")
95+
for choice in output_chunk.get("choices", []):
96+
if not isinstance(choice, dict):
97+
continue
98+
delta_dict = choice.get("delta", {})
99+
output_message += delta_dict.get("content", "")
100+
elif isinstance(output, dict):
101+
chat_id = chat_id or output.get("id")
102+
for choice in output.get("choices", []):
103+
if not isinstance(choice, dict):
104+
continue
105+
output_message += choice.get("message", {}).get("content", "")
106+
107+
return output_message, chat_id
108+
109+
110+
async def parse_get_prompt_with_output(
111+
row: GetPromptWithOutputsRow,
112+
) -> Optional[PartialConversation]:
113+
"""
114+
Parse a row from the get_prompt_with_outputs query and return a PartialConversation
115+
116+
The row contains the raw request and output strings from the pipeline.
117+
"""
118+
async with asyncio.TaskGroup() as tg:
119+
request_task = tg.create_task(parse_request(row.request))
120+
output_task = tg.create_task(parse_output(row.output))
121+
122+
request_msg_str = request_task.result()
123+
output_msg_str, chat_id = output_task.result()
124+
125+
# If we couldn't parse the request or output, return None
126+
if not request_msg_str or not output_msg_str or not chat_id:
127+
return None
128+
129+
request_message = ChatMessage(
130+
message=request_msg_str,
131+
timestamp=row.timestamp,
132+
message_id=row.id,
133+
)
134+
output_message = ChatMessage(
135+
message=output_msg_str,
136+
timestamp=row.output_timestamp,
137+
message_id=row.output_id,
138+
)
139+
question_answer = QuestionAnswer(
140+
question=request_message,
141+
answer=output_message,
142+
)
143+
return PartialConversation(
144+
question_answer=question_answer,
145+
provider=row.provider,
146+
type=row.type,
147+
chat_id=chat_id,
148+
request_timestamp=row.timestamp,
149+
)
150+
151+
152+
async def match_conversations(
153+
partial_conversations: List[Optional[PartialConversation]],
154+
) -> List[Conversation]:
155+
"""
156+
Match partial conversations to form a complete conversation.
157+
"""
158+
convers = {}
159+
for partial_conversation in partial_conversations:
160+
if not partial_conversation:
161+
continue
162+
163+
# Group by chat_id
164+
if partial_conversation.chat_id not in convers:
165+
convers[partial_conversation.chat_id] = []
166+
convers[partial_conversation.chat_id].append(partial_conversation)
167+
168+
# Sort by timestamp
169+
sorted_convers = {
170+
chat_id: sorted(conversations, key=lambda x: x.request_timestamp)
171+
for chat_id, conversations in convers.items()
172+
}
173+
# Create the conversation objects
174+
conversations = []
175+
for chat_id, sorted_convers in sorted_convers.items():
176+
questions_answers = []
177+
for partial_conversation in sorted_convers:
178+
questions_answers.append(partial_conversation.question_answer)
179+
conversations.append(
180+
Conversation(
181+
question_answers=questions_answers,
182+
provider=partial_conversation.provider,
183+
type=partial_conversation.type,
184+
chat_id=chat_id,
185+
conversation_timestamp=sorted_convers[0].request_timestamp,
186+
)
187+
)
188+
189+
return conversations
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import datetime
2+
from typing import List, Optional
3+
4+
from pydantic import BaseModel
5+
6+
7+
class ChatMessage(BaseModel):
8+
"""
9+
Represents a chat message.
10+
"""
11+
12+
message: str
13+
timestamp: datetime.datetime
14+
message_id: str
15+
16+
17+
class QuestionAnswer(BaseModel):
18+
"""
19+
Represents a question and answer pair.
20+
"""
21+
22+
question: ChatMessage
23+
answer: ChatMessage
24+
25+
26+
class PartialConversation(BaseModel):
27+
"""
28+
Represents a partial conversation obtained from a DB row.
29+
"""
30+
31+
question_answer: QuestionAnswer
32+
provider: Optional[str]
33+
type: str
34+
chat_id: str
35+
request_timestamp: datetime.datetime
36+
37+
38+
class Conversation(BaseModel):
39+
"""
40+
Represents a conversation.
41+
"""
42+
43+
question_answers: List[QuestionAnswer]
44+
provider: Optional[str]
45+
type: str
46+
chat_id: str
47+
conversation_timestamp: datetime.datetime

src/codegate/db/connection.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import uuid
66
from pathlib import Path
7-
from typing import AsyncGenerator, AsyncIterator, Optional
7+
from typing import AsyncGenerator, AsyncIterator, List, Optional
88

99
import structlog
1010
from litellm import ChatCompletionRequest, ModelResponse
@@ -13,11 +13,12 @@
1313
from sqlalchemy.ext.asyncio import create_async_engine
1414

1515
from codegate.db.models import Output, Prompt
16+
from codegate.db.queries import AsyncQuerier, GetPromptWithOutputsRow
1617

1718
logger = structlog.get_logger("codegate")
1819

1920

20-
class DbRecorder:
21+
class DbCodeGate:
2122

2223
def __init__(self, sqlite_path: Optional[str] = None):
2324
# Initialize SQLite database engine with proper async URL
@@ -27,6 +28,9 @@ def __init__(self, sqlite_path: Optional[str] = None):
2728
else:
2829
self._db_path = Path(sqlite_path).absolute()
2930

31+
# Initialize SQLite database engine with proper async URL
32+
current_dir = Path(__file__).parent
33+
self._db_path = (current_dir.parent.parent.parent / "codegate.db").absolute()
3034
logger.debug(f"Initializing DB from path: {self._db_path}")
3135
engine_dict = {
3236
"url": f"sqlite+aiosqlite:///{self._db_path}",
@@ -35,13 +39,20 @@ def __init__(self, sqlite_path: Optional[str] = None):
3539
}
3640
self._async_db_engine = create_async_engine(**engine_dict)
3741
self._db_engine = create_engine(**engine_dict)
38-
if not self.does_db_exist():
39-
logger.info(f"Database does not exist at {self._db_path}. Creating..")
40-
asyncio.run(self.init_db())
4142

4243
def does_db_exist(self):
4344
return self._db_path.is_file()
4445

46+
47+
class DbRecorder(DbCodeGate):
48+
49+
def __init__(self, sqlite_path: Optional[str] = None):
50+
super().__init__(sqlite_path)
51+
52+
if not self.does_db_exist():
53+
logger.info(f"Database does not exist at {self._db_path}. Creating..")
54+
asyncio.run(self.init_db())
55+
4556
async def init_db(self):
4657
"""Initialize the database with the schema."""
4758
if self.does_db_exist():
@@ -177,6 +188,19 @@ async def record_output_non_stream(
177188
return await self._record_output(prompt, output_str)
178189

179190

191+
class DbReader(DbCodeGate):
192+
193+
def __init__(self, sqlite_path: Optional[str] = None):
194+
super().__init__(sqlite_path)
195+
196+
async def get_prompts_with_output(self) -> List[GetPromptWithOutputsRow]:
197+
conn = await self._async_db_engine.connect()
198+
querier = AsyncQuerier(conn)
199+
prompts = [prompt async for prompt in querier.get_prompt_with_outputs()]
200+
await conn.close()
201+
return prompts
202+
203+
180204
def init_db_sync():
181205
"""DB will be initialized in the constructor in case it doesn't exist."""
182206
db = DbRecorder()

0 commit comments

Comments
 (0)