From af953ff8a339e10688d76e6a2ea907084fd4d276 Mon Sep 17 00:00:00 2001 From: skylares <93623871+skylares@users.noreply.github.com> Date: Fri, 17 Jan 2025 18:31:42 -0500 Subject: [PATCH] Paginate Query History table (#3592) * Add pagination for query history table * Fix method name * Fix mypy --- backend/ee/onyx/db/query_history.py | 120 +++++++- backend/ee/onyx/server/query_history/api.py | 290 ++---------------- .../ee/onyx/server/query_history/models.py | 218 +++++++++++++ backend/onyx/configs/constants.py | 1 + backend/onyx/server/documents/models.py | 2 + backend/scripts/chat_feedback_dump.py | 1 + .../integration/common_utils/managers/chat.py | 39 +++ .../admin/performance/DateRangeSelector.tsx | 13 +- web/src/app/ee/admin/performance/lib.ts | 21 -- .../query-history/QueryHistoryTable.tsx | 140 ++++++--- .../performance/query-history/[id]/page.tsx | 4 +- .../app/ee/admin/performance/usage/types.ts | 1 + web/src/hooks/usePaginatedFetch.tsx | 6 +- web/src/lib/types.ts | 2 +- 14 files changed, 517 insertions(+), 341 deletions(-) create mode 100644 backend/ee/onyx/server/query_history/models.py diff --git a/backend/ee/onyx/db/query_history.py b/backend/ee/onyx/db/query_history.py index c2b3612a508..b5a6f8db4af 100644 --- a/backend/ee/onyx/db/query_history.py +++ b/backend/ee/onyx/db/query_history.py @@ -1,27 +1,135 @@ -import datetime -from typing import Literal +from collections.abc import Sequence +from datetime import datetime from sqlalchemy import asc from sqlalchemy import BinaryExpression from sqlalchemy import ColumnElement from sqlalchemy import desc +from sqlalchemy import distinct from sqlalchemy.orm import contains_eager from sqlalchemy.orm import joinedload from sqlalchemy.orm import Session +from sqlalchemy.sql import case +from sqlalchemy.sql import func +from sqlalchemy.sql import select +from sqlalchemy.sql.expression import literal from sqlalchemy.sql.expression import UnaryExpression +from onyx.configs.constants import QAFeedbackType from onyx.db.models import ChatMessage +from onyx.db.models import ChatMessageFeedback from onyx.db.models import ChatSession -SortByOptions = Literal["time_sent"] + +def _build_filter_conditions( + start_time: datetime | None, + end_time: datetime | None, + feedback_filter: QAFeedbackType | None, +) -> list[ColumnElement]: + """ + Helper function to build all filter conditions for chat sessions. + Filters by start and end time, feedback type, and any sessions without messages. + start_time: Date from which to filter + end_time: Date to which to filter + feedback_filter: Feedback type to filter by + Returns: List of filter conditions + """ + conditions = [] + + if start_time is not None: + conditions.append(ChatSession.time_created >= start_time) + if end_time is not None: + conditions.append(ChatSession.time_created <= end_time) + + if feedback_filter is not None: + feedback_subq = ( + select(ChatMessage.chat_session_id) + .join(ChatMessageFeedback) + .group_by(ChatMessage.chat_session_id) + .having( + case( + ( + case( + {literal(feedback_filter == QAFeedbackType.LIKE): True}, + else_=False, + ), + func.bool_and(ChatMessageFeedback.is_positive), + ), + ( + case( + {literal(feedback_filter == QAFeedbackType.DISLIKE): True}, + else_=False, + ), + func.bool_and(func.not_(ChatMessageFeedback.is_positive)), + ), + else_=func.bool_or(ChatMessageFeedback.is_positive) + & func.bool_or(func.not_(ChatMessageFeedback.is_positive)), + ) + ) + ) + conditions.append(ChatSession.id.in_(feedback_subq)) + + return conditions + + +def get_total_filtered_chat_sessions_count( + db_session: Session, + start_time: datetime | None, + end_time: datetime | None, + feedback_filter: QAFeedbackType | None, +) -> int: + conditions = _build_filter_conditions(start_time, end_time, feedback_filter) + stmt = ( + select(func.count(distinct(ChatSession.id))) + .select_from(ChatSession) + .filter(*conditions) + ) + return db_session.scalar(stmt) or 0 + + +def get_page_of_chat_sessions( + start_time: datetime | None, + end_time: datetime | None, + db_session: Session, + page_num: int, + page_size: int, + feedback_filter: QAFeedbackType | None = None, +) -> Sequence[ChatSession]: + conditions = _build_filter_conditions(start_time, end_time, feedback_filter) + + subquery = ( + select(ChatSession.id, ChatSession.time_created) + .filter(*conditions) + .order_by(ChatSession.id, desc(ChatSession.time_created)) + .distinct(ChatSession.id) + .limit(page_size) + .offset(page_num * page_size) + .subquery() + ) + + stmt = ( + select(ChatSession) + .join(subquery, ChatSession.id == subquery.c.id) + .outerjoin(ChatMessage, ChatSession.id == ChatMessage.chat_session_id) + .options( + joinedload(ChatSession.user), + joinedload(ChatSession.persona), + contains_eager(ChatSession.messages).joinedload( + ChatMessage.chat_message_feedbacks + ), + ) + .order_by(desc(ChatSession.time_created), asc(ChatMessage.id)) + ) + + return db_session.scalars(stmt).unique().all() def fetch_chat_sessions_eagerly_by_time( - start: datetime.datetime, - end: datetime.datetime, + start: datetime, + end: datetime, db_session: Session, limit: int | None = 500, - initial_time: datetime.datetime | None = None, + initial_time: datetime | None = None, ) -> list[ChatSession]: time_order: UnaryExpression = desc(ChatSession.time_created) message_order: UnaryExpression = asc(ChatMessage.id) diff --git a/backend/ee/onyx/server/query_history/api.py b/backend/ee/onyx/server/query_history/api.py index c1ad267a825..f872c28d586 100644 --- a/backend/ee/onyx/server/query_history/api.py +++ b/backend/ee/onyx/server/query_history/api.py @@ -1,19 +1,23 @@ import csv import io from datetime import datetime -from datetime import timedelta from datetime import timezone -from typing import Literal from uuid import UUID from fastapi import APIRouter from fastapi import Depends from fastapi import HTTPException +from fastapi import Query from fastapi.responses import StreamingResponse -from pydantic import BaseModel from sqlalchemy.orm import Session from ee.onyx.db.query_history import fetch_chat_sessions_eagerly_by_time +from ee.onyx.db.query_history import get_page_of_chat_sessions +from ee.onyx.db.query_history import get_total_filtered_chat_sessions_count +from ee.onyx.server.query_history.models import ChatSessionMinimal +from ee.onyx.server.query_history.models import ChatSessionSnapshot +from ee.onyx.server.query_history.models import MessageSnapshot +from ee.onyx.server.query_history.models import QuestionAnswerPairSnapshot from onyx.auth.users import current_admin_user from onyx.auth.users import get_display_email from onyx.chat.chat_utils import create_chat_chain @@ -23,257 +27,15 @@ from onyx.db.chat import get_chat_session_by_id from onyx.db.chat import get_chat_sessions_by_user from onyx.db.engine import get_session -from onyx.db.models import ChatMessage from onyx.db.models import ChatSession from onyx.db.models import User +from onyx.server.documents.models import PaginatedReturn from onyx.server.query_and_chat.models import ChatSessionDetails from onyx.server.query_and_chat.models import ChatSessionsResponse router = APIRouter() -class AbridgedSearchDoc(BaseModel): - """A subset of the info present in `SearchDoc`""" - - document_id: str - semantic_identifier: str - link: str | None - - -class MessageSnapshot(BaseModel): - message: str - message_type: MessageType - documents: list[AbridgedSearchDoc] - feedback_type: QAFeedbackType | None - feedback_text: str | 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 - ) - feedback_type = ( - ( - QAFeedbackType.LIKE - if latest_messages_feedback_obj.is_positive - else QAFeedbackType.DISLIKE - ) - if latest_messages_feedback_obj - else None - ) - feedback_text = ( - latest_messages_feedback_obj.feedback_text - 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_type=feedback_type, - feedback_text=feedback_text, - time_created=message.time_sent, - ) - - -class ChatSessionMinimal(BaseModel): - id: UUID - user_email: str - name: str | None - first_user_message: str - first_ai_message: str - assistant_id: int | None - assistant_name: str | None - time_created: datetime - feedback_type: QAFeedbackType | Literal["mixed"] | None - flow_type: SessionType - conversation_length: int - - -class ChatSessionSnapshot(BaseModel): - id: UUID - user_email: str - name: str | None - messages: list[MessageSnapshot] - assistant_id: int | None - assistant_name: str | None - time_created: datetime - flow_type: SessionType - - -class QuestionAnswerPairSnapshot(BaseModel): - chat_session_id: UUID - # 1-indexed message number in the chat_session - # e.g. the first message pair in the chat_session is 1, the second is 2, etc. - message_pair_num: int - user_message: str - ai_response: str - retrieved_documents: list[AbridgedSearchDoc] - feedback_type: QAFeedbackType | None - feedback_text: str | None - persona_name: str | None - user_email: str - time_created: datetime - flow_type: SessionType - - @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( - chat_session_id=chat_session_snapshot.id, - message_pair_num=ind + 1, - user_message=user_message.message, - ai_response=ai_message.message, - retrieved_documents=ai_message.documents, - feedback_type=ai_message.feedback_type, - feedback_text=ai_message.feedback_text, - persona_name=chat_session_snapshot.assistant_name, - user_email=get_display_email(chat_session_snapshot.user_email), - time_created=user_message.time_created, - flow_type=chat_session_snapshot.flow_type, - ) - for ind, (user_message, ai_message) in enumerate(message_pairs) - ] - - def to_json(self) -> dict[str, str | None]: - return { - "chat_session_id": str(self.chat_session_id), - "message_pair_num": str(self.message_pair_num), - "user_message": self.user_message, - "ai_response": self.ai_response, - "retrieved_documents": "|".join( - [ - doc.link or doc.semantic_identifier - for doc in self.retrieved_documents - ] - ), - "feedback_type": self.feedback_type.value if self.feedback_type else "", - "feedback_text": self.feedback_text or "", - "persona_name": self.persona_name, - "user_email": self.user_email, - "time_created": str(self.time_created), - "flow_type": self.flow_type, - } - - -def determine_flow_type(chat_session: ChatSession) -> SessionType: - return SessionType.SLACK if chat_session.onyxbot_flow else SessionType.CHAT - - -def fetch_and_process_chat_session_history_minimal( - db_session: Session, - start: datetime, - end: datetime, - feedback_filter: QAFeedbackType | None = None, - limit: int | None = 500, -) -> list[ChatSessionMinimal]: - chat_sessions = fetch_chat_sessions_eagerly_by_time( - start=start, end=end, db_session=db_session, limit=limit - ) - - minimal_sessions = [] - for chat_session in chat_sessions: - if not chat_session.messages: - continue - - first_user_message = next( - ( - message.message - for message in chat_session.messages - if message.message_type == MessageType.USER - ), - "", - ) - first_ai_message = next( - ( - message.message - for message in chat_session.messages - if message.message_type == MessageType.ASSISTANT - ), - "", - ) - - has_positive_feedback = any( - feedback.is_positive - for message in chat_session.messages - for feedback in message.chat_message_feedbacks - ) - - has_negative_feedback = any( - not feedback.is_positive - for message in chat_session.messages - for feedback in message.chat_message_feedbacks - ) - - feedback_type: QAFeedbackType | Literal["mixed"] | None = ( - "mixed" - if has_positive_feedback and has_negative_feedback - else QAFeedbackType.LIKE - if has_positive_feedback - else QAFeedbackType.DISLIKE - if has_negative_feedback - else None - ) - - if feedback_filter: - if feedback_filter == QAFeedbackType.LIKE and not has_positive_feedback: - continue - if feedback_filter == QAFeedbackType.DISLIKE and not has_negative_feedback: - continue - - flow_type = determine_flow_type(chat_session) - - minimal_sessions.append( - ChatSessionMinimal( - id=chat_session.id, - user_email=get_display_email( - chat_session.user.email if chat_session.user else None - ), - name=chat_session.description, - first_user_message=first_user_message, - first_ai_message=first_ai_message, - assistant_id=chat_session.persona_id, - assistant_name=( - chat_session.persona.name if chat_session.persona else None - ), - time_created=chat_session.time_created, - feedback_type=feedback_type, - flow_type=flow_type, - conversation_length=len( - [ - m - for m in chat_session.messages - if m.message_type != MessageType.SYSTEM - ] - ), - ) - ) - - return minimal_sessions - - def fetch_and_process_chat_session_history( db_session: Session, start: datetime, @@ -319,7 +81,7 @@ def snapshot_from_chat_session( except RuntimeError: return None - flow_type = determine_flow_type(chat_session) + flow_type = SessionType.SLACK if chat_session.onyxbot_flow else SessionType.CHAT return ChatSessionSnapshot( id=chat_session.id, @@ -371,22 +133,38 @@ def get_user_chat_sessions( @router.get("/admin/chat-session-history") def get_chat_session_history( + page_num: int = Query(0, ge=0), + page_size: int = Query(10, ge=10), feedback_type: QAFeedbackType | None = None, - start: datetime | None = None, - end: datetime | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, _: User | None = Depends(current_admin_user), db_session: Session = Depends(get_session), -) -> list[ChatSessionMinimal]: - return fetch_and_process_chat_session_history_minimal( +) -> PaginatedReturn[ChatSessionMinimal]: + page_of_chat_sessions = get_page_of_chat_sessions( + page_num=page_num, + page_size=page_size, 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), + start_time=start_time, + end_time=end_time, feedback_filter=feedback_type, ) + total_filtered_chat_sessions_count = get_total_filtered_chat_sessions_count( + db_session=db_session, + start_time=start_time, + end_time=end_time, + feedback_filter=feedback_type, + ) + + return PaginatedReturn( + items=[ + ChatSessionMinimal.from_chat_session(chat_session) + for chat_session in page_of_chat_sessions + ], + total_items=total_filtered_chat_sessions_count, + ) + @router.get("/admin/chat-session-history/{chat_session_id}") def get_chat_session_admin( diff --git a/backend/ee/onyx/server/query_history/models.py b/backend/ee/onyx/server/query_history/models.py new file mode 100644 index 00000000000..521e41b0c2e --- /dev/null +++ b/backend/ee/onyx/server/query_history/models.py @@ -0,0 +1,218 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from onyx.auth.users import get_display_email +from onyx.configs.constants import MessageType +from onyx.configs.constants import QAFeedbackType +from onyx.configs.constants import SessionType +from onyx.db.models import ChatMessage +from onyx.db.models import ChatSession + + +class AbridgedSearchDoc(BaseModel): + """A subset of the info present in `SearchDoc`""" + + document_id: str + semantic_identifier: str + link: str | None + + +class MessageSnapshot(BaseModel): + id: int + message: str + message_type: MessageType + documents: list[AbridgedSearchDoc] + feedback_type: QAFeedbackType | None + feedback_text: str | 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 + ) + feedback_type = ( + ( + QAFeedbackType.LIKE + if latest_messages_feedback_obj.is_positive + else QAFeedbackType.DISLIKE + ) + if latest_messages_feedback_obj + else None + ) + feedback_text = ( + latest_messages_feedback_obj.feedback_text + if latest_messages_feedback_obj + else None + ) + return cls( + id=message.id, + 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_type=feedback_type, + feedback_text=feedback_text, + time_created=message.time_sent, + ) + + +class ChatSessionMinimal(BaseModel): + id: UUID + user_email: str + name: str | None + first_user_message: str + first_ai_message: str + assistant_id: int | None + assistant_name: str | None + time_created: datetime + feedback_type: QAFeedbackType | None + flow_type: SessionType + conversation_length: int + + @classmethod + def from_chat_session(cls, chat_session: ChatSession) -> "ChatSessionMinimal": + first_user_message = next( + ( + message.message + for message in chat_session.messages + if message.message_type == MessageType.USER + ), + "", + ) + first_ai_message = next( + ( + message.message + for message in chat_session.messages + if message.message_type == MessageType.ASSISTANT + ), + "", + ) + + list_of_message_feedbacks = [ + feedback.is_positive + for message in chat_session.messages + for feedback in message.chat_message_feedbacks + ] + session_feedback_type = None + if list_of_message_feedbacks: + if all(list_of_message_feedbacks): + session_feedback_type = QAFeedbackType.LIKE + elif not any(list_of_message_feedbacks): + session_feedback_type = QAFeedbackType.DISLIKE + else: + session_feedback_type = QAFeedbackType.MIXED + + return cls( + id=chat_session.id, + user_email=get_display_email( + chat_session.user.email if chat_session.user else None + ), + name=chat_session.description, + first_user_message=first_user_message, + first_ai_message=first_ai_message, + assistant_id=chat_session.persona_id, + assistant_name=( + chat_session.persona.name if chat_session.persona else None + ), + time_created=chat_session.time_created, + feedback_type=session_feedback_type, + flow_type=SessionType.SLACK + if chat_session.onyxbot_flow + else SessionType.CHAT, + conversation_length=len( + [ + message + for message in chat_session.messages + if message.message_type != MessageType.SYSTEM + ] + ), + ) + + +class ChatSessionSnapshot(BaseModel): + id: UUID + user_email: str + name: str | None + messages: list[MessageSnapshot] + assistant_id: int | None + assistant_name: str | None + time_created: datetime + flow_type: SessionType + + +class QuestionAnswerPairSnapshot(BaseModel): + chat_session_id: UUID + # 1-indexed message number in the chat_session + # e.g. the first message pair in the chat_session is 1, the second is 2, etc. + message_pair_num: int + user_message: str + ai_response: str + retrieved_documents: list[AbridgedSearchDoc] + feedback_type: QAFeedbackType | None + feedback_text: str | None + persona_name: str | None + user_email: str + time_created: datetime + flow_type: SessionType + + @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( + chat_session_id=chat_session_snapshot.id, + message_pair_num=ind + 1, + user_message=user_message.message, + ai_response=ai_message.message, + retrieved_documents=ai_message.documents, + feedback_type=ai_message.feedback_type, + feedback_text=ai_message.feedback_text, + persona_name=chat_session_snapshot.assistant_name, + user_email=get_display_email(chat_session_snapshot.user_email), + time_created=user_message.time_created, + flow_type=chat_session_snapshot.flow_type, + ) + for ind, (user_message, ai_message) in enumerate(message_pairs) + ] + + def to_json(self) -> dict[str, str | None]: + return { + "chat_session_id": str(self.chat_session_id), + "message_pair_num": str(self.message_pair_num), + "user_message": self.user_message, + "ai_response": self.ai_response, + "retrieved_documents": "|".join( + [ + doc.link or doc.semantic_identifier + for doc in self.retrieved_documents + ] + ), + "feedback_type": self.feedback_type.value if self.feedback_type else "", + "feedback_text": self.feedback_text or "", + "persona_name": self.persona_name, + "user_email": self.user_email, + "time_created": str(self.time_created), + "flow_type": self.flow_type, + } diff --git a/backend/onyx/configs/constants.py b/backend/onyx/configs/constants.py index 129019a4c75..b64cfcd2158 100644 --- a/backend/onyx/configs/constants.py +++ b/backend/onyx/configs/constants.py @@ -200,6 +200,7 @@ class SessionType(str, Enum): class QAFeedbackType(str, Enum): LIKE = "like" # User likes the answer, used for metrics DISLIKE = "dislike" # User dislikes the answer, used for metrics + MIXED = "mixed" # User likes some answers and dislikes other, used for chat session metrics class SearchFeedbackType(str, Enum): diff --git a/backend/onyx/server/documents/models.py b/backend/onyx/server/documents/models.py index 64880587bb7..a8c5dc2f057 100644 --- a/backend/onyx/server/documents/models.py +++ b/backend/onyx/server/documents/models.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from pydantic import Field +from ee.onyx.server.query_history.models import ChatSessionMinimal from onyx.configs.app_configs import MASK_CREDENTIAL_PREFIX from onyx.configs.constants import DocumentSource from onyx.connectors.models import DocumentErrorSummary @@ -212,6 +213,7 @@ def from_db_model(cls, error: DbIndexAttemptError) -> "IndexAttemptError": IndexAttemptSnapshot, FullUserSnapshot, InvitedUserSnapshot, + ChatSessionMinimal, ) diff --git a/backend/scripts/chat_feedback_dump.py b/backend/scripts/chat_feedback_dump.py index 693e0e6a134..6fe9cbc7df1 100644 --- a/backend/scripts/chat_feedback_dump.py +++ b/backend/scripts/chat_feedback_dump.py @@ -108,6 +108,7 @@ # class MessageSnapshot(BaseModel): +# id: int # message: str # message_type: MessageType # documents: list[AbridgedSearchDoc] diff --git a/backend/tests/integration/common_utils/managers/chat.py b/backend/tests/integration/common_utils/managers/chat.py index 625e8bbd57c..88245b077dd 100644 --- a/backend/tests/integration/common_utils/managers/chat.py +++ b/backend/tests/integration/common_utils/managers/chat.py @@ -1,13 +1,18 @@ import json +from datetime import datetime +from urllib.parse import urlencode from uuid import UUID import requests from requests.models import Response +from ee.onyx.server.query_history.models import ChatSessionMinimal +from onyx.configs.constants import QAFeedbackType from onyx.context.search.models import RetrievalDetails from onyx.file_store.models import FileDescriptor from onyx.llm.override_models import LLMOverride from onyx.llm.override_models import PromptOverride +from onyx.server.documents.models import PaginatedReturn from onyx.server.query_and_chat.models import ChatSessionCreationRequest from onyx.server.query_and_chat.models import CreateChatMessageRequest from tests.integration.common_utils.constants import API_SERVER_URL @@ -133,3 +138,37 @@ def get_chat_history( ) for msg in response.json()["messages"] ] + + @staticmethod + def get_chat_session_history( + user_performing_action: DATestUser, + page_size: int, + page_num: int, + feedback_type: QAFeedbackType | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, + ) -> PaginatedReturn[ChatSessionMinimal]: + query_params = { + "page_num": page_num, + "page_size": page_size, + "feedback_type": feedback_type if feedback_type else None, + "start_time": start_time if start_time else None, + "end_time": end_time if end_time else None, + } + # Remove None values + query_params = { + key: value for key, value in query_params.items() if value is not None + } + + response = requests.get( + f"{API_SERVER_URL}/admin/chat-session-history?{urlencode(query_params, doseq=True)}", + headers=user_performing_action.headers, + ) + response.raise_for_status() + data = response.json() + return PaginatedReturn( + items=[ + ChatSessionMinimal(**chat_session) for chat_session in data["items"] + ], + total_items=data["total_items"], + ) diff --git a/web/src/app/ee/admin/performance/DateRangeSelector.tsx b/web/src/app/ee/admin/performance/DateRangeSelector.tsx index 51f510e15ae..60adc0f21a5 100644 --- a/web/src/app/ee/admin/performance/DateRangeSelector.tsx +++ b/web/src/app/ee/admin/performance/DateRangeSelector.tsx @@ -10,7 +10,6 @@ import { cn } from "@/lib/utils"; import { CalendarIcon } from "lucide-react"; import { format } from "date-fns"; import { getXDaysAgo } from "./dateUtils"; -import { Separator } from "@/components/ui/separator"; export const THIRTY_DAYS = "30d"; @@ -84,8 +83,16 @@ export const DateRangeSelector = memo(function DateRangeSelector({ defaultMonth={value?.from} selected={value} onSelect={(range) => { - if (range?.from && range?.to) { - onValueChange({ from: range.from, to: range.to }); + if (range?.from) { + if (range.to) { + // Normal range selection when initialized with a range + onValueChange({ from: range.from, to: range.to }); + } else { + // Single date selection when initilized without a range + const to = new Date(range.from); + const from = new Date(to.setDate(to.getDate() - 1)); + onValueChange({ from, to }); + } } }} numberOfMonths={2} diff --git a/web/src/app/ee/admin/performance/lib.ts b/web/src/app/ee/admin/performance/lib.ts index f85181b84c7..6b6f313f7bb 100644 --- a/web/src/app/ee/admin/performance/lib.ts +++ b/web/src/app/ee/admin/performance/lib.ts @@ -65,27 +65,6 @@ export const useOnyxBotAnalytics = (timeRange: DateRangePickerValue) => { }; }; -export const useQueryHistory = ({ - selectedFeedbackType, - timeRange, -}: { - selectedFeedbackType: Feedback | null; - timeRange: DateRange; -}) => { - const url = buildApiPath("/api/admin/chat-session-history", { - feedback_type: selectedFeedbackType, - start: convertDateToStartOfDay(timeRange?.from)?.toISOString(), - end: convertDateToEndOfDay(timeRange?.to)?.toISOString(), - }); - - const swrResponse = useSWR(url, errorHandlingFetcher); - - return { - ...swrResponse, - refreshQueryHistory: () => mutate(url), - }; -}; - export function getDatesList(startDate: Date): string[] { const datesList: string[] = []; const endDate = new Date(); // current date diff --git a/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx b/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx index e07059db45d..37d4c937358 100644 --- a/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx +++ b/web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx @@ -1,4 +1,3 @@ -import { useQueryHistory, useTimeRange } from "../lib"; import { Separator } from "@/components/ui/separator"; import { Table, @@ -20,8 +19,8 @@ import { import { ThreeDotsLoader } from "@/components/Loading"; import { ChatSessionMinimal } from "../usage/types"; import { timestampToReadableDate } from "@/lib/dateUtils"; -import { FiFrown, FiMinus, FiSmile } from "react-icons/fi"; -import { useCallback, useState } from "react"; +import { FiFrown, FiMinus, FiSmile, FiMeh } from "react-icons/fi"; +import { useCallback, useState, useMemo } from "react"; import { Feedback } from "@/lib/types"; import { DateRange, DateRangeSelector } from "../DateRangeSelector"; import { PageSelector } from "@/components/PageSelector"; @@ -29,8 +28,11 @@ import Link from "next/link"; import { FeedbackBadge } from "./FeedbackBadge"; import { DownloadAsCSV } from "./DownloadAsCSV"; import CardSection from "@/components/admin/CardSection"; +import usePaginatedFetch from "@/hooks/usePaginatedFetch"; +import { ErrorCallout } from "@/components/ErrorCallout"; -const NUM_IN_PAGE = 20; +const ITEMS_PER_PAGE = 20; +const PAGES_PER_BATCH = 2; function QueryHistoryTableRow({ chatSessionMinimal, @@ -108,6 +110,12 @@ function SelectFeedbackType({ Dislike + +
+ + Mixed +
+
@@ -116,31 +124,55 @@ function SelectFeedbackType({ } export function QueryHistoryTable() { - const [selectedFeedbackType, setSelectedFeedbackType] = useState< - Feedback | "all" - >("all"); - const [timeRange, setTimeRange] = useTimeRange(); + const [dateRange, setDateRange] = useState(undefined); + const [filters, setFilters] = useState<{ + feedback_type?: Feedback | "all"; + start_time?: string; + end_time?: string; + }>({}); - const { data: chatSessionData } = useQueryHistory({ - selectedFeedbackType: - selectedFeedbackType === "all" ? null : selectedFeedbackType, - timeRange, + const { + currentPageData: chatSessionData, + isLoading, + error, + currentPage, + totalPages, + goToPage, + refresh, + } = usePaginatedFetch({ + itemsPerPage: ITEMS_PER_PAGE, + pagesPerBatch: PAGES_PER_BATCH, + endpoint: "/api/admin/chat-session-history", + filter: filters, }); - const [page, setPage] = useState(1); + const onTimeRangeChange = useCallback((value: DateRange) => { + setDateRange(value); - const onTimeRangeChange = useCallback( - (value: DateRange) => { - if (value) { - setTimeRange((prevTimeRange) => ({ - ...prevTimeRange, - from: new Date(value.from), - to: new Date(value.to), - })); - } - }, - [setTimeRange] - ); + if (value?.from && value?.to) { + setFilters((prev) => ({ + ...prev, + start_time: value.from.toISOString(), + end_time: value.to.toISOString(), + })); + } else { + setFilters((prev) => { + const newFilters = { ...prev }; + delete newFilters.start_time; + delete newFilters.end_time; + return newFilters; + }); + } + }, []); + + if (error) { + return ( + + ); + } return ( @@ -148,12 +180,22 @@ export function QueryHistoryTable() {
{ + setFilters((prev) => { + const newFilters = { ...prev }; + if (value === "all") { + delete newFilters.feedback_type; + } else { + newFilters.feedback_type = value; + } + return newFilters; + }); + }} />
@@ -172,33 +214,33 @@ export function QueryHistoryTable() { Date - - {chatSessionData && - chatSessionData - .slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page) - .map((chatSessionMinimal) => ( - - ))} - + {isLoading ? ( + + + + + + + + ) : ( + + {chatSessionData?.map((chatSessionMinimal) => ( + + ))} + + )} {chatSessionData && (
{ - setPage(newPage); - window.scrollTo({ - top: 0, - left: 0, - behavior: "smooth", - }); - }} + totalPages={totalPages} + currentPage={currentPage} + onPageChange={goToPage} />
diff --git a/web/src/app/ee/admin/performance/query-history/[id]/page.tsx b/web/src/app/ee/admin/performance/query-history/[id]/page.tsx index ee5a03f28f6..2cbb635a223 100644 --- a/web/src/app/ee/admin/performance/query-history/[id]/page.tsx +++ b/web/src/app/ee/admin/performance/query-history/[id]/page.tsx @@ -106,9 +106,7 @@ export default function QueryPage(props: { params: Promise<{ id: string }> }) {
{chatSessionSnapshot.messages.map((message) => { - return ( - - ); + return ; })}
diff --git a/web/src/app/ee/admin/performance/usage/types.ts b/web/src/app/ee/admin/performance/usage/types.ts index d5d9cd7c399..7c20155940b 100644 --- a/web/src/app/ee/admin/performance/usage/types.ts +++ b/web/src/app/ee/admin/performance/usage/types.ts @@ -25,6 +25,7 @@ export interface AbridgedSearchDoc { } export interface MessageSnapshot { + id: number; message: string; message_type: "user" | "assistant"; documents: AbridgedSearchDoc[]; diff --git a/web/src/hooks/usePaginatedFetch.tsx b/web/src/hooks/usePaginatedFetch.tsx index 329471f90c4..ba68ee154dd 100644 --- a/web/src/hooks/usePaginatedFetch.tsx +++ b/web/src/hooks/usePaginatedFetch.tsx @@ -5,12 +5,14 @@ import { AcceptedUserSnapshot, InvitedUserSnapshot, } from "@/lib/types"; +import { ChatSessionMinimal } from "@/app/ee/admin/performance/usage/types"; import { errorHandlingFetcher } from "@/lib/fetcher"; type PaginatedType = | IndexAttemptSnapshot | AcceptedUserSnapshot - | InvitedUserSnapshot; + | InvitedUserSnapshot + | ChatSessionMinimal; interface PaginatedApiResponse { items: T[]; @@ -22,7 +24,7 @@ interface PaginationConfig { pagesPerBatch: number; endpoint: string; query?: string; - filter?: Record; + filter?: Record; refreshIntervalInMs?: number; } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index dc0a8fae078..ed9159891a7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -98,7 +98,7 @@ export type ValidStatuses = | "in_progress" | "not_started"; export type TaskStatus = "PENDING" | "STARTED" | "SUCCESS" | "FAILURE"; -export type Feedback = "like" | "dislike"; +export type Feedback = "like" | "dislike" | "mixed"; export type AccessType = "public" | "private" | "sync"; export type SessionType = "Chat" | "Search" | "Slack";