mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-27 20:38:32 +02:00
Fix analytics + query history
This commit is contained in:
@@ -119,6 +119,11 @@ class AuthType(str, Enum):
|
||||
SAML = "saml"
|
||||
|
||||
|
||||
class QAFeedbackType(str, Enum):
|
||||
LIKE = "like" # User likes the answer, used for metrics
|
||||
DISLIKE = "dislike" # User dislikes the answer, used for metrics
|
||||
|
||||
|
||||
class SearchFeedbackType(str, Enum):
|
||||
ENDORSE = "endorse" # boost this document for all future queries
|
||||
REJECT = "reject" # down-boost this document for all future queries
|
||||
|
@@ -9,8 +9,10 @@ from sqlalchemy import func
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from danswer.configs.constants import QAFeedbackType
|
||||
from danswer.db.models import QueryEvent
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.models import ChatMessage
|
||||
from danswer.db.models import ChatMessageFeedback
|
||||
from danswer.db.models import ChatSession
|
||||
|
||||
|
||||
def fetch_query_analytics(
|
||||
@@ -20,19 +22,29 @@ def fetch_query_analytics(
|
||||
) -> Sequence[tuple[int, int, int, datetime.date]]:
|
||||
stmt = (
|
||||
select(
|
||||
func.count(QueryEvent.id),
|
||||
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)),
|
||||
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)),
|
||||
cast(QueryEvent.time_created, Date),
|
||||
func.count(ChatMessage.id),
|
||||
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
|
||||
func.sum(
|
||||
case(
|
||||
(ChatMessageFeedback.is_positive == False, 1), else_=0 # noqa: E712
|
||||
)
|
||||
),
|
||||
cast(ChatMessage.time_sent, Date),
|
||||
)
|
||||
.join(
|
||||
ChatMessageFeedback,
|
||||
ChatMessageFeedback.chat_message_id == ChatMessage.id,
|
||||
isouter=True,
|
||||
)
|
||||
.where(
|
||||
QueryEvent.time_created >= start,
|
||||
ChatMessage.time_sent >= start,
|
||||
)
|
||||
.where(
|
||||
QueryEvent.time_created <= end,
|
||||
ChatMessage.time_sent <= end,
|
||||
)
|
||||
.group_by(cast(QueryEvent.time_created, Date))
|
||||
.order_by(cast(QueryEvent.time_created, Date))
|
||||
.where(ChatMessage.message_type == MessageType.ASSISTANT)
|
||||
.group_by(cast(ChatMessage.time_sent, Date))
|
||||
.order_by(cast(ChatMessage.time_sent, Date))
|
||||
)
|
||||
|
||||
return db_session.execute(stmt).all() # type: ignore
|
||||
@@ -45,20 +57,26 @@ def fetch_per_user_query_analytics(
|
||||
) -> Sequence[tuple[int, int, int, datetime.date, UUID]]:
|
||||
stmt = (
|
||||
select(
|
||||
func.count(QueryEvent.id),
|
||||
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)),
|
||||
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)),
|
||||
cast(QueryEvent.time_created, Date),
|
||||
QueryEvent.user_id,
|
||||
func.count(ChatMessage.id),
|
||||
func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
|
||||
func.sum(
|
||||
case(
|
||||
(ChatMessageFeedback.is_positive == False, 1), else_=0 # noqa: E712
|
||||
)
|
||||
),
|
||||
cast(ChatMessage.time_sent, Date),
|
||||
ChatSession.user_id,
|
||||
)
|
||||
.join(ChatSession, ChatSession.id == ChatMessage.chat_session_id)
|
||||
.where(
|
||||
ChatMessage.time_sent >= start,
|
||||
)
|
||||
.where(
|
||||
QueryEvent.time_created >= start,
|
||||
ChatMessage.time_sent <= end,
|
||||
)
|
||||
.where(
|
||||
QueryEvent.time_created <= end,
|
||||
)
|
||||
.group_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
|
||||
.order_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
|
||||
.where(ChatMessage.message_type == MessageType.ASSISTANT)
|
||||
.group_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id)
|
||||
.order_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id)
|
||||
)
|
||||
|
||||
return db_session.execute(stmt).all() # type: ignore
|
||||
|
@@ -10,34 +10,39 @@ from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from danswer.configs.constants import QAFeedbackType
|
||||
from danswer.db.models import QueryEvent
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.db.models import ChatMessage
|
||||
from danswer.db.models import ChatSession
|
||||
from danswer.db.models import User
|
||||
|
||||
SortByOptions = Literal["time_created", "feedback"]
|
||||
SortByOptions = Literal["time_sent"]
|
||||
|
||||
|
||||
def build_query_history_query(
|
||||
start: datetime.datetime,
|
||||
end: datetime.datetime,
|
||||
query: str | None,
|
||||
feedback_type: QAFeedbackType | None,
|
||||
sort_by_field: SortByOptions,
|
||||
sort_by_direction: Literal["asc", "desc"],
|
||||
offset: int,
|
||||
limit: int | None,
|
||||
) -> Select[tuple[QueryEvent]]:
|
||||
) -> Select[tuple[ChatMessage]]:
|
||||
stmt = (
|
||||
select(QueryEvent)
|
||||
select(ChatMessage)
|
||||
.where(
|
||||
QueryEvent.time_created >= start,
|
||||
ChatMessage.time_sent >= start,
|
||||
)
|
||||
.where(
|
||||
QueryEvent.time_created <= end,
|
||||
ChatMessage.time_sent <= end,
|
||||
)
|
||||
.where(
|
||||
or_(
|
||||
ChatMessage.message_type == MessageType.ASSISTANT,
|
||||
ChatMessage.message_type == MessageType.USER,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
order_by_field = cast(InstrumentedAttribute, getattr(QueryEvent, sort_by_field))
|
||||
order_by_field = cast(InstrumentedAttribute, getattr(ChatMessage, sort_by_field))
|
||||
if sort_by_direction == "asc":
|
||||
stmt = stmt.order_by(order_by_field.asc())
|
||||
else:
|
||||
@@ -48,17 +53,6 @@ def build_query_history_query(
|
||||
if limit:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
if query:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
QueryEvent.llm_answer.ilike(f"%{query}%"),
|
||||
QueryEvent.query.ilike(f"%{query}%"),
|
||||
)
|
||||
)
|
||||
|
||||
if feedback_type:
|
||||
stmt = stmt.where(QueryEvent.feedback == feedback_type)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
@@ -66,18 +60,14 @@ def fetch_query_history(
|
||||
db_session: Session,
|
||||
start: datetime.datetime,
|
||||
end: datetime.datetime,
|
||||
query: str | None = None,
|
||||
feedback_type: QAFeedbackType | None = None,
|
||||
sort_by_field: SortByOptions = "time_created",
|
||||
sort_by_field: SortByOptions = "time_sent",
|
||||
sort_by_direction: Literal["asc", "desc"] = "desc",
|
||||
offset: int = 0,
|
||||
limit: int | None = 500,
|
||||
) -> Sequence[QueryEvent]:
|
||||
) -> Sequence[ChatMessage]:
|
||||
stmt = build_query_history_query(
|
||||
start=start,
|
||||
end=end,
|
||||
query=query,
|
||||
feedback_type=feedback_type,
|
||||
sort_by_field=sort_by_field,
|
||||
sort_by_direction=sort_by_direction,
|
||||
offset=offset,
|
||||
@@ -91,27 +81,25 @@ def fetch_query_history_with_user_email(
|
||||
db_session: Session,
|
||||
start: datetime.datetime,
|
||||
end: datetime.datetime,
|
||||
query: str | None = None,
|
||||
feedback_type: QAFeedbackType | None = None,
|
||||
sort_by_field: SortByOptions = "time_created",
|
||||
sort_by_field: SortByOptions = "time_sent",
|
||||
sort_by_direction: Literal["asc", "desc"] = "desc",
|
||||
offset: int = 0,
|
||||
limit: int | None = 500,
|
||||
) -> Sequence[tuple[QueryEvent, str | None]]:
|
||||
) -> Sequence[tuple[ChatMessage, str | None]]:
|
||||
subquery = build_query_history_query(
|
||||
start=start,
|
||||
end=end,
|
||||
query=query,
|
||||
feedback_type=feedback_type,
|
||||
sort_by_field=sort_by_field,
|
||||
sort_by_direction=sort_by_direction,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
).subquery()
|
||||
subquery_alias = aliased(QueryEvent, subquery)
|
||||
subquery_alias = aliased(ChatMessage, subquery)
|
||||
|
||||
stmt_with_user_email = select(subquery_alias, User.email).join( # type: ignore
|
||||
User, subquery_alias.user_id == User.id, isouter=True
|
||||
stmt_with_user_email = (
|
||||
select(subquery_alias, User.email) # type: ignore
|
||||
.join(ChatSession, subquery_alias.chat_session_id == ChatSession.id)
|
||||
.join(User, ChatSession.user_id == User.id, isouter=True)
|
||||
)
|
||||
|
||||
return db_session.execute(stmt_with_user_email).all() # type: ignore
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
@@ -14,11 +14,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
import danswer.db.models as db_models
|
||||
from danswer.auth.users import current_admin_user
|
||||
from danswer.configs.constants import MessageType
|
||||
from danswer.configs.constants import QAFeedbackType
|
||||
from danswer.db.chat import get_chat_session_by_id
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.feedback import fetch_query_event_by_id
|
||||
from danswer.db.models import Document
|
||||
from ee.danswer.db.document import fetch_documents_from_ids
|
||||
from danswer.db.models import ChatMessage
|
||||
from ee.danswer.db.query_history import (
|
||||
fetch_query_history_with_user_email,
|
||||
)
|
||||
@@ -35,45 +35,112 @@ class AbridgedSearchDoc(BaseModel):
|
||||
link: str | None
|
||||
|
||||
|
||||
class QuerySnapshot(BaseModel):
|
||||
class MessageSnapshot(BaseModel):
|
||||
message: str
|
||||
message_type: MessageType
|
||||
documents: list[AbridgedSearchDoc]
|
||||
feedback: QAFeedbackType | None
|
||||
time_created: datetime
|
||||
|
||||
@classmethod
|
||||
def build(cls, message: ChatMessage) -> "MessageSnapshot":
|
||||
latest_messages_feedback_obj = (
|
||||
message.chat_message_feedbacks[-1]
|
||||
if len(message.chat_message_feedbacks) > 0
|
||||
else None
|
||||
)
|
||||
message_feedback = (
|
||||
(
|
||||
QAFeedbackType.LIKE
|
||||
if latest_messages_feedback_obj.is_positive
|
||||
else QAFeedbackType.DISLIKE
|
||||
)
|
||||
if latest_messages_feedback_obj
|
||||
else None
|
||||
)
|
||||
|
||||
return cls(
|
||||
message=message.message,
|
||||
message_type=message.message_type,
|
||||
documents=[
|
||||
AbridgedSearchDoc(
|
||||
document_id=document.document_id,
|
||||
semantic_identifier=document.semantic_id,
|
||||
link=document.link,
|
||||
)
|
||||
for document in message.search_docs
|
||||
],
|
||||
feedback=message_feedback,
|
||||
time_created=message.time_sent,
|
||||
)
|
||||
|
||||
|
||||
class ChatSessionSnapshot(BaseModel):
|
||||
id: int
|
||||
user_email: str | None
|
||||
query: str
|
||||
llm_answer: str | None
|
||||
retrieved_documents: list[AbridgedSearchDoc]
|
||||
feedback: QAFeedbackType | None
|
||||
name: str | None
|
||||
messages: list[MessageSnapshot]
|
||||
time_created: datetime
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
query_event: db_models.QueryEvent,
|
||||
user_email: str | None,
|
||||
documents: Iterable[Document],
|
||||
) -> "QuerySnapshot":
|
||||
messages: list[ChatMessage],
|
||||
) -> "ChatSessionSnapshot":
|
||||
if len(messages) == 0:
|
||||
raise ValueError("No messages provided")
|
||||
|
||||
chat_session = messages[0].chat_session
|
||||
|
||||
return cls(
|
||||
id=query_event.id,
|
||||
user_email=user_email,
|
||||
query=query_event.query,
|
||||
llm_answer=query_event.llm_answer,
|
||||
retrieved_documents=[
|
||||
AbridgedSearchDoc(
|
||||
document_id=document.id,
|
||||
semantic_identifier=document.semantic_id,
|
||||
link=document.link,
|
||||
)
|
||||
for document in documents
|
||||
id=chat_session.id,
|
||||
user_email=chat_session.user.email if chat_session.user else None,
|
||||
name=chat_session.description,
|
||||
messages=[
|
||||
MessageSnapshot.build(message)
|
||||
for message in sorted(messages, key=lambda m: m.time_sent)
|
||||
if message.message_type != MessageType.SYSTEM
|
||||
],
|
||||
feedback=query_event.feedback,
|
||||
time_created=query_event.time_created,
|
||||
time_created=chat_session.time_created,
|
||||
)
|
||||
|
||||
|
||||
class QuestionAnswerPairSnapshot(BaseModel):
|
||||
user_message: str
|
||||
ai_response: str
|
||||
retrieved_documents: list[AbridgedSearchDoc]
|
||||
feedback: QAFeedbackType | None
|
||||
time_created: datetime
|
||||
|
||||
@classmethod
|
||||
def from_chat_session_snapshot(
|
||||
cls,
|
||||
chat_session_snapshot: ChatSessionSnapshot,
|
||||
) -> list["QuestionAnswerPairSnapshot"]:
|
||||
message_pairs: list[tuple[MessageSnapshot, MessageSnapshot]] = []
|
||||
for ind in range(1, len(chat_session_snapshot.messages), 2):
|
||||
message_pairs.append(
|
||||
(
|
||||
chat_session_snapshot.messages[ind - 1],
|
||||
chat_session_snapshot.messages[ind],
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
cls(
|
||||
user_message=user_message.message,
|
||||
ai_response=ai_message.message,
|
||||
retrieved_documents=ai_message.documents,
|
||||
feedback=ai_message.feedback,
|
||||
time_created=user_message.time_created,
|
||||
)
|
||||
for user_message, ai_message in message_pairs
|
||||
]
|
||||
|
||||
def to_json(self) -> dict[str, str]:
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"query": self.query,
|
||||
"user_email": self.user_email or "",
|
||||
"llm_answer": self.llm_answer or "",
|
||||
"user_message": self.user_message,
|
||||
"ai_response": self.ai_response,
|
||||
"retrieved_documents": "|".join(
|
||||
[
|
||||
doc.link or doc.semantic_identifier
|
||||
@@ -85,61 +152,52 @@ class QuerySnapshot(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
def fetch_and_process_query_history(
|
||||
def fetch_and_process_chat_session_history(
|
||||
db_session: Session,
|
||||
start: datetime | None,
|
||||
end: datetime | None,
|
||||
feedback_type: QAFeedbackType | None,
|
||||
limit: int | None = 500,
|
||||
) -> list[QuerySnapshot]:
|
||||
query_history_with_user_email = fetch_query_history_with_user_email(
|
||||
) -> list[ChatSessionSnapshot]:
|
||||
chat_messages_with_user_email = fetch_query_history_with_user_email(
|
||||
db_session=db_session,
|
||||
start=start
|
||||
or (
|
||||
datetime.now(tz=timezone.utc) - timedelta(days=30)
|
||||
), # default is 30d lookback
|
||||
end=end or datetime.now(tz=timezone.utc),
|
||||
feedback_type=feedback_type,
|
||||
limit=limit,
|
||||
)
|
||||
session_id_to_messages: dict[int, list[ChatMessage]] = defaultdict(list)
|
||||
for message, _ in chat_messages_with_user_email:
|
||||
session_id_to_messages[message.chat_session_id].append(message)
|
||||
|
||||
all_relevant_document_ids: set[str] = set()
|
||||
for query_event, _ in query_history_with_user_email:
|
||||
all_relevant_document_ids = all_relevant_document_ids.union(
|
||||
query_event.retrieved_document_ids or []
|
||||
)
|
||||
document_id_to_document = {
|
||||
document.id: document
|
||||
for document in fetch_documents_from_ids(
|
||||
db_session, list(all_relevant_document_ids)
|
||||
)
|
||||
}
|
||||
|
||||
query_snapshots: list[QuerySnapshot] = []
|
||||
for query_event, user_email in query_history_with_user_email:
|
||||
unique_document_ids = set(query_event.retrieved_document_ids or [])
|
||||
documents = [
|
||||
document_id_to_document[doc_id]
|
||||
for doc_id in unique_document_ids
|
||||
if doc_id in document_id_to_document
|
||||
]
|
||||
query_snapshots.append(
|
||||
QuerySnapshot.build(
|
||||
query_event=query_event, user_email=user_email, documents=documents
|
||||
chat_session_snapshots = [
|
||||
ChatSessionSnapshot.build(messages)
|
||||
for messages in session_id_to_messages.values()
|
||||
]
|
||||
if feedback_type:
|
||||
chat_session_snapshots = [
|
||||
chat_session_snapshot
|
||||
for chat_session_snapshot in chat_session_snapshots
|
||||
if any(
|
||||
message.feedback == feedback_type
|
||||
for message in chat_session_snapshot.messages
|
||||
)
|
||||
)
|
||||
return query_snapshots
|
||||
]
|
||||
|
||||
return chat_session_snapshots
|
||||
|
||||
|
||||
@router.get("/admin/query-history")
|
||||
def get_query_history(
|
||||
@router.get("/admin/chat-session-history")
|
||||
def get_chat_session_history(
|
||||
feedback_type: QAFeedbackType | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
_: db_models.User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[QuerySnapshot]:
|
||||
return fetch_and_process_query_history(
|
||||
) -> list[ChatSessionSnapshot]:
|
||||
return fetch_and_process_chat_session_history(
|
||||
db_session=db_session,
|
||||
start=start,
|
||||
end=end,
|
||||
@@ -147,24 +205,22 @@ def get_query_history(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/query-history/{query_id}")
|
||||
def get_query(
|
||||
query_id: int,
|
||||
@router.get("/admin/chat-session-history/{chat_session_id}")
|
||||
def get_chat_session(
|
||||
chat_session_id: int,
|
||||
_: db_models.User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> QuerySnapshot:
|
||||
) -> ChatSessionSnapshot:
|
||||
try:
|
||||
query_event = fetch_query_event_by_id(query_id=query_id, db_session=db_session)
|
||||
chat_session = get_chat_session_by_id(
|
||||
chat_session_id=chat_session_id, user_id=None, db_session=db_session
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"Query event with id '{query_id}' does not exist.")
|
||||
documents = fetch_documents_from_ids(
|
||||
db_session, query_event.retrieved_document_ids or []
|
||||
)
|
||||
return QuerySnapshot.build(
|
||||
query_event=query_event,
|
||||
user_email=query_event.user.email if query_event.user else None,
|
||||
documents=documents,
|
||||
)
|
||||
raise HTTPException(
|
||||
400, f"Chat session with id '{chat_session_id}' does not exist."
|
||||
)
|
||||
|
||||
return ChatSessionSnapshot.build(messages=chat_session.messages)
|
||||
|
||||
|
||||
@router.get("/admin/query-history-csv")
|
||||
@@ -172,7 +228,7 @@ def get_query_history_as_csv(
|
||||
_: db_models.User | None = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> StreamingResponse:
|
||||
complete_query_history = fetch_and_process_query_history(
|
||||
complete_chat_session_history = fetch_and_process_chat_session_history(
|
||||
db_session=db_session,
|
||||
start=datetime.fromtimestamp(0, tz=timezone.utc),
|
||||
end=datetime.now(tz=timezone.utc),
|
||||
@@ -180,11 +236,19 @@ def get_query_history_as_csv(
|
||||
limit=None,
|
||||
)
|
||||
|
||||
question_answer_pairs: list[QuestionAnswerPairSnapshot] = []
|
||||
for chat_session_snapshot in complete_chat_session_history:
|
||||
question_answer_pairs.extend(
|
||||
QuestionAnswerPairSnapshot.from_chat_session_snapshot(chat_session_snapshot)
|
||||
)
|
||||
|
||||
# Create an in-memory text stream
|
||||
stream = io.StringIO()
|
||||
writer = csv.DictWriter(stream, fieldnames=list(QuerySnapshot.__fields__.keys()))
|
||||
writer = csv.DictWriter(
|
||||
stream, fieldnames=list(QuestionAnswerPairSnapshot.__fields__.keys())
|
||||
)
|
||||
writer.writeheader()
|
||||
for row in complete_query_history:
|
||||
for row in question_answer_pairs:
|
||||
writer.writerow(row.to_json())
|
||||
|
||||
# Reset the stream's position to the start
|
||||
|
@@ -67,7 +67,9 @@ export const UserEditor = ({
|
||||
})}
|
||||
onSelect={(option) => {
|
||||
setSelectedUserIds([
|
||||
...Array.from(new Set([...selectedUserIds, option.value as string])),
|
||||
...Array.from(
|
||||
new Set([...selectedUserIds, option.value as string])
|
||||
),
|
||||
]);
|
||||
}}
|
||||
itemComponent={({ option }) => (
|
||||
|
@@ -91,7 +91,10 @@ export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
|
||||
onSelect={(option) => {
|
||||
setSelectedCCPairIds([
|
||||
...Array.from(
|
||||
new Set([...selectedCCPairIds, parseInt(option.value as string)])
|
||||
new Set([
|
||||
...selectedCCPairIds,
|
||||
parseInt(option.value as string),
|
||||
])
|
||||
),
|
||||
]);
|
||||
}}
|
||||
|
@@ -2,6 +2,7 @@ import {
|
||||
DateRangePicker,
|
||||
DateRangePickerItem,
|
||||
DateRangePickerValue,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { getXDaysAgo } from "./dateUtils";
|
||||
|
||||
@@ -16,9 +17,7 @@ export function DateRangeSelector({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1">
|
||||
Date Range
|
||||
</div>
|
||||
<Text className="my-auto mr-2 font-medium mb-1">Date Range</Text>
|
||||
<DateRangePicker
|
||||
className="max-w-md"
|
||||
value={value}
|
||||
|
@@ -5,17 +5,15 @@ import { FeedbackChart } from "./FeedbackChart";
|
||||
import { QueryPerformanceChart } from "./QueryPerformanceChart";
|
||||
import { BarChartIcon } from "@/components/icons/icons";
|
||||
import { useTimeRange } from "../lib";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
|
||||
return (
|
||||
<main className="pt-4 mx-auto container dark">
|
||||
<main className="pt-4 mx-auto container">
|
||||
{/* TODO: remove this `dark` once we have a mode selector */}
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||
<BarChartIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">Analytics</h1>
|
||||
</div>
|
||||
<AdminPageTitle title="Analytics" icon={<BarChartIcon size={32} />} />
|
||||
|
||||
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
|
||||
|
||||
|
@@ -18,12 +18,18 @@ export interface AbridgedSearchDoc {
|
||||
link: string | null;
|
||||
}
|
||||
|
||||
export interface QuerySnapshot {
|
||||
id: number;
|
||||
query: string;
|
||||
user_email: string | null;
|
||||
llm_answer: string;
|
||||
retrieved_documents: AbridgedSearchDoc[];
|
||||
time_created: string;
|
||||
export interface MessageSnapshot {
|
||||
message: string;
|
||||
message_type: "user" | "assistant";
|
||||
documents: AbridgedSearchDoc[];
|
||||
feedback: Feedback | null;
|
||||
time_created: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionSnapshot {
|
||||
id: number;
|
||||
user_email: string | null;
|
||||
name: string | null;
|
||||
messages: MessageSnapshot[];
|
||||
time_created: string;
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import {
|
||||
ChatSessionSnapshot,
|
||||
QueryAnalytics,
|
||||
QuerySnapshot,
|
||||
UserAnalytics,
|
||||
} from "./analytics/types";
|
||||
import { useState } from "react";
|
||||
@@ -55,12 +55,12 @@ export const useQueryHistory = () => {
|
||||
useState<Feedback | null>(null);
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
|
||||
const url = buildApiPath("/api/admin/query-history", {
|
||||
const url = buildApiPath("/api/admin/chat-session-history", {
|
||||
feedback_type: selectedFeedbackType,
|
||||
start: convertDateToStartOfDay(timeRange.from)?.toISOString(),
|
||||
end: convertDateToEndOfDay(timeRange.to)?.toISOString(),
|
||||
});
|
||||
const swrResponse = useSWR<QuerySnapshot[]>(url, errorHandlingFetcher);
|
||||
const swrResponse = useSWR<ChatSessionSnapshot[]>(url, errorHandlingFetcher);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
|
@@ -4,7 +4,7 @@ export function DownloadAsCSV() {
|
||||
return (
|
||||
<a
|
||||
href="/api/admin/query-history-csv"
|
||||
className="text-gray-300 flex ml-auto py-2 px-4 border border-gray-800 h-fit cursor-pointer hover:bg-gray-800 text-sm"
|
||||
className="flex ml-auto py-2 px-4 border border-border h-fit cursor-pointer hover:bg-hover-light text-sm"
|
||||
>
|
||||
<FiDownload className="my-auto mr-2" />
|
||||
Download as CSV
|
||||
|
@@ -1,7 +1,11 @@
|
||||
import { Feedback } from "@/lib/types";
|
||||
import { Badge } from "@tremor/react";
|
||||
|
||||
export function FeedbackBadge({ feedback }: { feedback?: Feedback | null }) {
|
||||
export function FeedbackBadge({
|
||||
feedback,
|
||||
}: {
|
||||
feedback?: Feedback | "mixed" | null;
|
||||
}) {
|
||||
let feedbackBadge;
|
||||
switch (feedback) {
|
||||
case "like":
|
||||
@@ -18,6 +22,13 @@ export function FeedbackBadge({ feedback }: { feedback?: Feedback | null }) {
|
||||
</Badge>
|
||||
);
|
||||
break;
|
||||
case "mixed":
|
||||
feedbackBadge = (
|
||||
<Badge color="purple" className="text-sm">
|
||||
Mixed
|
||||
</Badge>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
feedbackBadge = (
|
||||
<Badge color="gray" className="text-sm">
|
||||
|
@@ -13,12 +13,9 @@ import {
|
||||
import { Divider } from "@tremor/react";
|
||||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { QuerySnapshot } from "../analytics/types";
|
||||
import {
|
||||
timestampToDateString,
|
||||
timestampToReadableDate,
|
||||
} from "@/lib/dateUtils";
|
||||
import { FiBook, FiFrown, FiMinus, FiSmile } from "react-icons/fi";
|
||||
import { ChatSessionSnapshot } from "../analytics/types";
|
||||
import { timestampToReadableDate } from "@/lib/dateUtils";
|
||||
import { FiFrown, FiMinus, FiSmile } from "react-icons/fi";
|
||||
import { useState } from "react";
|
||||
import { Feedback } from "@/lib/types";
|
||||
import { DateRangeSelector } from "../DateRangeSelector";
|
||||
@@ -30,42 +27,47 @@ import { DownloadAsCSV } from "./DownloadAsCSV";
|
||||
const NUM_IN_PAGE = 20;
|
||||
|
||||
function QueryHistoryTableRow({
|
||||
querySnapshot,
|
||||
chatSessionSnapshot,
|
||||
}: {
|
||||
querySnapshot: QuerySnapshot;
|
||||
chatSessionSnapshot: ChatSessionSnapshot;
|
||||
}) {
|
||||
let finalFeedback: Feedback | "mixed" | null = null;
|
||||
for (const message of chatSessionSnapshot.messages) {
|
||||
if (message.feedback) {
|
||||
if (finalFeedback === null) {
|
||||
finalFeedback = message.feedback;
|
||||
} else if (finalFeedback !== message.feedback) {
|
||||
finalFeedback = "mixed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={querySnapshot.id}
|
||||
className="hover:bg-gradient-to-r hover:from-gray-800 hover:to-indigo-950 cursor-pointer relative"
|
||||
key={chatSessionSnapshot.id}
|
||||
className="hover:bg-hover-light cursor-pointer relative"
|
||||
>
|
||||
<TableCell>{querySnapshot.query}</TableCell>
|
||||
<TableCell>
|
||||
<Text className="whitespace-normal line-clamp-5">
|
||||
{querySnapshot.llm_answer}
|
||||
{chatSessionSnapshot.messages[0]?.message || "-"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{querySnapshot.retrieved_documents.slice(0, 5).map((document) => (
|
||||
<div className="flex" key={document.document_id}>
|
||||
<FiBook className="my-auto mr-1" />{" "}
|
||||
<p className="max-w-xs text-ellipsis overflow-hidden">
|
||||
{document.semantic_identifier}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<Text className="whitespace-normal line-clamp-5">
|
||||
{chatSessionSnapshot.messages[1]?.message || "-"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FeedbackBadge feedback={querySnapshot.feedback} />
|
||||
<FeedbackBadge feedback={finalFeedback} />
|
||||
</TableCell>
|
||||
<TableCell>{querySnapshot.user_email || "-"}</TableCell>
|
||||
<TableCell>{chatSessionSnapshot.user_email || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{timestampToReadableDate(querySnapshot.time_created)}
|
||||
{timestampToReadableDate(chatSessionSnapshot.time_created)}
|
||||
</TableCell>
|
||||
{/* Wrapping in <td> to avoid console warnings */}
|
||||
<td className="w-0 p-0">
|
||||
<Link
|
||||
href={`/admin/performance/query-history/${querySnapshot.id}`}
|
||||
href={`/admin/performance/query-history/${chatSessionSnapshot.id}`}
|
||||
className="absolute w-full h-full left-0"
|
||||
></Link>
|
||||
</td>
|
||||
@@ -82,9 +84,7 @@ function SelectFeedbackType({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1">
|
||||
Feedback Type
|
||||
</div>
|
||||
<Text className="my-auto mr-2 font-medium mb-1">Feedback Type</Text>
|
||||
<div className="max-w-sm space-y-6">
|
||||
<Select
|
||||
value={value}
|
||||
@@ -108,7 +108,7 @@ function SelectFeedbackType({
|
||||
|
||||
export function QueryHistoryTable() {
|
||||
const {
|
||||
data: queryHistoryData,
|
||||
data: chatSessionData,
|
||||
selectedFeedbackType,
|
||||
setSelectedFeedbackType,
|
||||
timeRange,
|
||||
@@ -119,7 +119,7 @@ export function QueryHistoryTable() {
|
||||
|
||||
return (
|
||||
<Card className="mt-8">
|
||||
{queryHistoryData ? (
|
||||
{chatSessionData ? (
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="gap-y-3 flex flex-col">
|
||||
@@ -140,21 +140,20 @@ export function QueryHistoryTable() {
|
||||
<Table className="mt-5">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Query</TableHeaderCell>
|
||||
<TableHeaderCell>LLM Answer</TableHeaderCell>
|
||||
<TableHeaderCell>Retrieved Documents</TableHeaderCell>
|
||||
<TableHeaderCell>First User Message</TableHeaderCell>
|
||||
<TableHeaderCell>First AI Response</TableHeaderCell>
|
||||
<TableHeaderCell>Feedback</TableHeaderCell>
|
||||
<TableHeaderCell>User</TableHeaderCell>
|
||||
<TableHeaderCell>Date</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{queryHistoryData
|
||||
{chatSessionData
|
||||
.slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page)
|
||||
.map((querySnapshot) => (
|
||||
.map((chatSessionSnapshot) => (
|
||||
<QueryHistoryTableRow
|
||||
key={querySnapshot.id}
|
||||
querySnapshot={querySnapshot}
|
||||
key={chatSessionSnapshot.id}
|
||||
chatSessionSnapshot={chatSessionSnapshot}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -163,7 +162,7 @@ export function QueryHistoryTable() {
|
||||
<div className="mt-3 flex">
|
||||
<div className="mx-auto">
|
||||
<PageSelector
|
||||
totalPages={Math.ceil(queryHistoryData.length / NUM_IN_PAGE)}
|
||||
totalPages={Math.ceil(chatSessionData.length / NUM_IN_PAGE)}
|
||||
currentPage={page}
|
||||
onPageChange={(newPage) => {
|
||||
setPage(newPage);
|
||||
|
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
/* TODO: bring this out of EE */
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FiChevronLeft } from "react-icons/fi";
|
||||
|
||||
export function BackButton() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
className="my-auto flex mb-1 hover:bg-gray-800 w-fit pr-2 cursor-pointer rounded-lg"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<FiChevronLeft className="mr-1 my-auto" />
|
||||
Back
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,82 +1,81 @@
|
||||
import { Bold, Text, Card, Title, Divider, Italic } from "@tremor/react";
|
||||
import { QuerySnapshot } from "../../analytics/types";
|
||||
import { buildUrl } from "@/lib/utilsSS";
|
||||
import { BackButton } from "./BackButton";
|
||||
import { Bold, Text, Card, Title, Divider } from "@tremor/react";
|
||||
import { ChatSessionSnapshot, MessageSnapshot } from "../../analytics/types";
|
||||
import { FiBook } from "react-icons/fi";
|
||||
import { processCookies } from "@/lib/userSS";
|
||||
import { cookies } from "next/headers";
|
||||
import { timestampToReadableDate } from "@/lib/dateUtils";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
import { SSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { FeedbackBadge } from "../FeedbackBadge";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
|
||||
function MessageDisplay({ message }: { message: MessageSnapshot }) {
|
||||
return (
|
||||
<div>
|
||||
<Bold className="text-xs mb-1">
|
||||
{message.message_type === "user" ? "User" : "AI"}
|
||||
</Bold>
|
||||
<Text>{message.message}</Text>
|
||||
{message.documents.length > 0 && (
|
||||
<div className="flex flex-col gap-y-2 mt-2">
|
||||
<Bold className="font-bold text-xs">Reference Documents</Bold>
|
||||
{message.documents.slice(0, 5).map((document) => {
|
||||
return (
|
||||
<Text className="flex" key={document.document_id}>
|
||||
<FiBook
|
||||
className={
|
||||
"my-auto mr-1" + (document.link ? " text-link" : " ")
|
||||
}
|
||||
/>
|
||||
{document.link ? (
|
||||
<a href={document.link} target="_blank" className="text-link">
|
||||
{document.semantic_identifier}
|
||||
</a>
|
||||
) : (
|
||||
document.semantic_identifier
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{message.feedback && (
|
||||
<div className="mt-2">
|
||||
<FeedbackBadge feedback={message.feedback} />
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function QueryPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
const response = await fetch(buildUrl(`/admin/query-history/${params.id}`), {
|
||||
next: { revalidate: 0 },
|
||||
headers: {
|
||||
cookie: processCookies(cookies()),
|
||||
},
|
||||
});
|
||||
const queryEvent = (await response.json()) as QuerySnapshot;
|
||||
const response = await fetchSS(`/admin/chat-session-history/${params.id}`);
|
||||
const chatSessionSnapshot = (await response.json()) as ChatSessionSnapshot;
|
||||
|
||||
return (
|
||||
<main className="pt-4 mx-auto container dark">
|
||||
<main className="pt-4 mx-auto container">
|
||||
<BackButton />
|
||||
<SSRAutoRefresh />
|
||||
|
||||
<Card className="mt-4">
|
||||
<Title>Query Details</Title>
|
||||
<Title>Chat Session Details</Title>
|
||||
|
||||
<Text className="flex flex-wrap whitespace-normal mt-1 text-xs">
|
||||
{queryEvent.user_email || "-"},{" "}
|
||||
{timestampToReadableDate(queryEvent.time_created)}
|
||||
{chatSessionSnapshot.user_email || "-"},{" "}
|
||||
{timestampToReadableDate(chatSessionSnapshot.time_created)}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div>
|
||||
<Bold>Query</Bold>
|
||||
<Text className="flex flex-wrap whitespace-normal mt-1">
|
||||
{queryEvent.query}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Bold>Answer</Bold>
|
||||
<Text className="flex flex-wrap whitespace-normal mt-1">
|
||||
{queryEvent.llm_answer}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Bold>Retrieved Documents</Bold>
|
||||
<div className="flex flex-col gap-y-2 mt-1">
|
||||
{queryEvent.retrieved_documents?.map((document) => {
|
||||
return (
|
||||
<Text className="flex" key={document.document_id}>
|
||||
<FiBook
|
||||
className={
|
||||
"my-auto mr-1" +
|
||||
(document.link ? " text-blue-500" : " ")
|
||||
}
|
||||
/>
|
||||
{document.link ? (
|
||||
<a
|
||||
href={document.link}
|
||||
target="_blank"
|
||||
className="text-blue-500"
|
||||
>
|
||||
{document.semantic_identifier}
|
||||
</a>
|
||||
) : (
|
||||
document.semantic_identifier
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{chatSessionSnapshot.messages.map((message) => {
|
||||
return (
|
||||
<MessageDisplay key={message.time_created} message={message} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
|
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { QueryHistoryTable } from "./QueryHistoryTable";
|
||||
import { DatabaseIcon } from "@/components/icons/icons";
|
||||
|
||||
export default function QueryHistoryPage() {
|
||||
return (
|
||||
<main className="pt-4 mx-auto container dark">
|
||||
{/* TODO: remove this `dark` once we have a mode selector */}
|
||||
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
|
||||
<DatabaseIcon size={32} />
|
||||
<h1 className="text-3xl font-bold pl-2">Query History</h1>
|
||||
</div>
|
||||
<main className="pt-4 mx-auto container">
|
||||
|
||||
<AdminPageTitle title="Query History" icon={<DatabaseIcon size={32} />} />
|
||||
|
||||
<QueryHistoryTable />
|
||||
</main>
|
||||
);
|
||||
|
Reference in New Issue
Block a user