Fix analytics + query history

This commit is contained in:
Weves
2023-12-16 16:47:01 -08:00
committed by Chris Weaver
parent 67a4eb6f6f
commit a52711967f
16 changed files with 353 additions and 280 deletions

View File

@@ -119,6 +119,11 @@ class AuthType(str, Enum):
SAML = "saml" 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): class SearchFeedbackType(str, Enum):
ENDORSE = "endorse" # boost this document for all future queries ENDORSE = "endorse" # boost this document for all future queries
REJECT = "reject" # down-boost this document for all future queries REJECT = "reject" # down-boost this document for all future queries

View File

@@ -9,8 +9,10 @@ from sqlalchemy import func
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from danswer.configs.constants import QAFeedbackType from danswer.configs.constants import MessageType
from danswer.db.models import QueryEvent from danswer.db.models import ChatMessage
from danswer.db.models import ChatMessageFeedback
from danswer.db.models import ChatSession
def fetch_query_analytics( def fetch_query_analytics(
@@ -20,19 +22,29 @@ def fetch_query_analytics(
) -> Sequence[tuple[int, int, int, datetime.date]]: ) -> Sequence[tuple[int, int, int, datetime.date]]:
stmt = ( stmt = (
select( select(
func.count(QueryEvent.id), func.count(ChatMessage.id),
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)), func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)), func.sum(
cast(QueryEvent.time_created, Date), 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( .where(
QueryEvent.time_created >= start, ChatMessage.time_sent >= start,
) )
.where( .where(
QueryEvent.time_created <= end, ChatMessage.time_sent <= end,
) )
.group_by(cast(QueryEvent.time_created, Date)) .where(ChatMessage.message_type == MessageType.ASSISTANT)
.order_by(cast(QueryEvent.time_created, Date)) .group_by(cast(ChatMessage.time_sent, Date))
.order_by(cast(ChatMessage.time_sent, Date))
) )
return db_session.execute(stmt).all() # type: ignore 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]]: ) -> Sequence[tuple[int, int, int, datetime.date, UUID]]:
stmt = ( stmt = (
select( select(
func.count(QueryEvent.id), func.count(ChatMessage.id),
func.sum(case((QueryEvent.feedback == QAFeedbackType.LIKE, 1), else_=0)), func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)),
func.sum(case((QueryEvent.feedback == QAFeedbackType.DISLIKE, 1), else_=0)), func.sum(
cast(QueryEvent.time_created, Date), case(
QueryEvent.user_id, (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( .where(
QueryEvent.time_created >= start, ChatMessage.time_sent <= end,
) )
.where( .where(ChatMessage.message_type == MessageType.ASSISTANT)
QueryEvent.time_created <= end, .group_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id)
) .order_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id)
.group_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
.order_by(cast(QueryEvent.time_created, Date), QueryEvent.user_id)
) )
return db_session.execute(stmt).all() # type: ignore return db_session.execute(stmt).all() # type: ignore

View File

@@ -10,34 +10,39 @@ from sqlalchemy.orm import aliased
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from danswer.configs.constants import QAFeedbackType from danswer.configs.constants import MessageType
from danswer.db.models import QueryEvent from danswer.db.models import ChatMessage
from danswer.db.models import ChatSession
from danswer.db.models import User from danswer.db.models import User
SortByOptions = Literal["time_created", "feedback"] SortByOptions = Literal["time_sent"]
def build_query_history_query( def build_query_history_query(
start: datetime.datetime, start: datetime.datetime,
end: datetime.datetime, end: datetime.datetime,
query: str | None,
feedback_type: QAFeedbackType | None,
sort_by_field: SortByOptions, sort_by_field: SortByOptions,
sort_by_direction: Literal["asc", "desc"], sort_by_direction: Literal["asc", "desc"],
offset: int, offset: int,
limit: int | None, limit: int | None,
) -> Select[tuple[QueryEvent]]: ) -> Select[tuple[ChatMessage]]:
stmt = ( stmt = (
select(QueryEvent) select(ChatMessage)
.where( .where(
QueryEvent.time_created >= start, ChatMessage.time_sent >= start,
) )
.where( .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": if sort_by_direction == "asc":
stmt = stmt.order_by(order_by_field.asc()) stmt = stmt.order_by(order_by_field.asc())
else: else:
@@ -48,17 +53,6 @@ def build_query_history_query(
if limit: if limit:
stmt = stmt.limit(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 return stmt
@@ -66,18 +60,14 @@ def fetch_query_history(
db_session: Session, db_session: Session,
start: datetime.datetime, start: datetime.datetime,
end: datetime.datetime, end: datetime.datetime,
query: str | None = None, sort_by_field: SortByOptions = "time_sent",
feedback_type: QAFeedbackType | None = None,
sort_by_field: SortByOptions = "time_created",
sort_by_direction: Literal["asc", "desc"] = "desc", sort_by_direction: Literal["asc", "desc"] = "desc",
offset: int = 0, offset: int = 0,
limit: int | None = 500, limit: int | None = 500,
) -> Sequence[QueryEvent]: ) -> Sequence[ChatMessage]:
stmt = build_query_history_query( stmt = build_query_history_query(
start=start, start=start,
end=end, end=end,
query=query,
feedback_type=feedback_type,
sort_by_field=sort_by_field, sort_by_field=sort_by_field,
sort_by_direction=sort_by_direction, sort_by_direction=sort_by_direction,
offset=offset, offset=offset,
@@ -91,27 +81,25 @@ def fetch_query_history_with_user_email(
db_session: Session, db_session: Session,
start: datetime.datetime, start: datetime.datetime,
end: datetime.datetime, end: datetime.datetime,
query: str | None = None, sort_by_field: SortByOptions = "time_sent",
feedback_type: QAFeedbackType | None = None,
sort_by_field: SortByOptions = "time_created",
sort_by_direction: Literal["asc", "desc"] = "desc", sort_by_direction: Literal["asc", "desc"] = "desc",
offset: int = 0, offset: int = 0,
limit: int | None = 500, limit: int | None = 500,
) -> Sequence[tuple[QueryEvent, str | None]]: ) -> Sequence[tuple[ChatMessage, str | None]]:
subquery = build_query_history_query( subquery = build_query_history_query(
start=start, start=start,
end=end, end=end,
query=query,
feedback_type=feedback_type,
sort_by_field=sort_by_field, sort_by_field=sort_by_field,
sort_by_direction=sort_by_direction, sort_by_direction=sort_by_direction,
offset=offset, offset=offset,
limit=limit, limit=limit,
).subquery() ).subquery()
subquery_alias = aliased(QueryEvent, subquery) subquery_alias = aliased(ChatMessage, subquery)
stmt_with_user_email = select(subquery_alias, User.email).join( # type: ignore stmt_with_user_email = (
User, subquery_alias.user_id == User.id, isouter=True 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 return db_session.execute(stmt_with_user_email).all() # type: ignore

View File

@@ -1,6 +1,6 @@
import csv import csv
import io import io
from collections.abc import Iterable from collections import defaultdict
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from datetime import timezone from datetime import timezone
@@ -14,11 +14,11 @@ from sqlalchemy.orm import Session
import danswer.db.models as db_models import danswer.db.models as db_models
from danswer.auth.users import current_admin_user from danswer.auth.users import current_admin_user
from danswer.configs.constants import MessageType
from danswer.configs.constants import QAFeedbackType 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.engine import get_session
from danswer.db.feedback import fetch_query_event_by_id from danswer.db.models import ChatMessage
from danswer.db.models import Document
from ee.danswer.db.document import fetch_documents_from_ids
from ee.danswer.db.query_history import ( from ee.danswer.db.query_history import (
fetch_query_history_with_user_email, fetch_query_history_with_user_email,
) )
@@ -35,45 +35,112 @@ class AbridgedSearchDoc(BaseModel):
link: str | None 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 id: int
user_email: str | None user_email: str | None
query: str name: str | None
llm_answer: str | None messages: list[MessageSnapshot]
retrieved_documents: list[AbridgedSearchDoc]
feedback: QAFeedbackType | None
time_created: datetime time_created: datetime
@classmethod @classmethod
def build( def build(
cls, cls,
query_event: db_models.QueryEvent, messages: list[ChatMessage],
user_email: str | None, ) -> "ChatSessionSnapshot":
documents: Iterable[Document], if len(messages) == 0:
) -> "QuerySnapshot": raise ValueError("No messages provided")
chat_session = messages[0].chat_session
return cls( return cls(
id=query_event.id, id=chat_session.id,
user_email=user_email, user_email=chat_session.user.email if chat_session.user else None,
query=query_event.query, name=chat_session.description,
llm_answer=query_event.llm_answer, messages=[
retrieved_documents=[ MessageSnapshot.build(message)
AbridgedSearchDoc( for message in sorted(messages, key=lambda m: m.time_sent)
document_id=document.id, if message.message_type != MessageType.SYSTEM
semantic_identifier=document.semantic_id,
link=document.link,
)
for document in documents
], ],
feedback=query_event.feedback, time_created=chat_session.time_created,
time_created=query_event.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]: def to_json(self) -> dict[str, str]:
return { return {
"id": str(self.id), "user_message": self.user_message,
"query": self.query, "ai_response": self.ai_response,
"user_email": self.user_email or "",
"llm_answer": self.llm_answer or "",
"retrieved_documents": "|".join( "retrieved_documents": "|".join(
[ [
doc.link or doc.semantic_identifier 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, db_session: Session,
start: datetime | None, start: datetime | None,
end: datetime | None, end: datetime | None,
feedback_type: QAFeedbackType | None, feedback_type: QAFeedbackType | None,
limit: int | None = 500, limit: int | None = 500,
) -> list[QuerySnapshot]: ) -> list[ChatSessionSnapshot]:
query_history_with_user_email = fetch_query_history_with_user_email( chat_messages_with_user_email = fetch_query_history_with_user_email(
db_session=db_session, db_session=db_session,
start=start start=start
or ( or (
datetime.now(tz=timezone.utc) - timedelta(days=30) datetime.now(tz=timezone.utc) - timedelta(days=30)
), # default is 30d lookback ), # default is 30d lookback
end=end or datetime.now(tz=timezone.utc), end=end or datetime.now(tz=timezone.utc),
feedback_type=feedback_type,
limit=limit, 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() chat_session_snapshots = [
for query_event, _ in query_history_with_user_email: ChatSessionSnapshot.build(messages)
all_relevant_document_ids = all_relevant_document_ids.union( for messages in session_id_to_messages.values()
query_event.retrieved_document_ids or [] ]
) if feedback_type:
document_id_to_document = { chat_session_snapshots = [
document.id: document chat_session_snapshot
for document in fetch_documents_from_ids( for chat_session_snapshot in chat_session_snapshots
db_session, list(all_relevant_document_ids) if any(
) message.feedback == feedback_type
} for message in chat_session_snapshot.messages
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
) )
) ]
return query_snapshots
return chat_session_snapshots
@router.get("/admin/query-history") @router.get("/admin/chat-session-history")
def get_query_history( def get_chat_session_history(
feedback_type: QAFeedbackType | None = None, feedback_type: QAFeedbackType | None = None,
start: datetime | None = None, start: datetime | None = None,
end: datetime | None = None, end: datetime | None = None,
_: db_models.User | None = Depends(current_admin_user), _: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session), db_session: Session = Depends(get_session),
) -> list[QuerySnapshot]: ) -> list[ChatSessionSnapshot]:
return fetch_and_process_query_history( return fetch_and_process_chat_session_history(
db_session=db_session, db_session=db_session,
start=start, start=start,
end=end, end=end,
@@ -147,24 +205,22 @@ def get_query_history(
) )
@router.get("/admin/query-history/{query_id}") @router.get("/admin/chat-session-history/{chat_session_id}")
def get_query( def get_chat_session(
query_id: int, chat_session_id: int,
_: db_models.User | None = Depends(current_admin_user), _: db_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session), db_session: Session = Depends(get_session),
) -> QuerySnapshot: ) -> ChatSessionSnapshot:
try: 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: except ValueError:
raise HTTPException(400, f"Query event with id '{query_id}' does not exist.") raise HTTPException(
documents = fetch_documents_from_ids( 400, f"Chat session with id '{chat_session_id}' does not exist."
db_session, query_event.retrieved_document_ids or [] )
)
return QuerySnapshot.build( return ChatSessionSnapshot.build(messages=chat_session.messages)
query_event=query_event,
user_email=query_event.user.email if query_event.user else None,
documents=documents,
)
@router.get("/admin/query-history-csv") @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_models.User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session), db_session: Session = Depends(get_session),
) -> StreamingResponse: ) -> StreamingResponse:
complete_query_history = fetch_and_process_query_history( complete_chat_session_history = fetch_and_process_chat_session_history(
db_session=db_session, db_session=db_session,
start=datetime.fromtimestamp(0, tz=timezone.utc), start=datetime.fromtimestamp(0, tz=timezone.utc),
end=datetime.now(tz=timezone.utc), end=datetime.now(tz=timezone.utc),
@@ -180,11 +236,19 @@ def get_query_history_as_csv(
limit=None, 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 # Create an in-memory text stream
stream = io.StringIO() stream = io.StringIO()
writer = csv.DictWriter(stream, fieldnames=list(QuerySnapshot.__fields__.keys())) writer = csv.DictWriter(
stream, fieldnames=list(QuestionAnswerPairSnapshot.__fields__.keys())
)
writer.writeheader() writer.writeheader()
for row in complete_query_history: for row in question_answer_pairs:
writer.writerow(row.to_json()) writer.writerow(row.to_json())
# Reset the stream's position to the start # Reset the stream's position to the start

View File

@@ -67,7 +67,9 @@ export const UserEditor = ({
})} })}
onSelect={(option) => { onSelect={(option) => {
setSelectedUserIds([ setSelectedUserIds([
...Array.from(new Set([...selectedUserIds, option.value as string])), ...Array.from(
new Set([...selectedUserIds, option.value as string])
),
]); ]);
}} }}
itemComponent={({ option }) => ( itemComponent={({ option }) => (

View File

@@ -91,7 +91,10 @@ export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
onSelect={(option) => { onSelect={(option) => {
setSelectedCCPairIds([ setSelectedCCPairIds([
...Array.from( ...Array.from(
new Set([...selectedCCPairIds, parseInt(option.value as string)]) new Set([
...selectedCCPairIds,
parseInt(option.value as string),
])
), ),
]); ]);
}} }}

View File

@@ -2,6 +2,7 @@ import {
DateRangePicker, DateRangePicker,
DateRangePickerItem, DateRangePickerItem,
DateRangePickerValue, DateRangePickerValue,
Text,
} from "@tremor/react"; } from "@tremor/react";
import { getXDaysAgo } from "./dateUtils"; import { getXDaysAgo } from "./dateUtils";
@@ -16,9 +17,7 @@ export function DateRangeSelector({
}) { }) {
return ( return (
<div> <div>
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1"> <Text className="my-auto mr-2 font-medium mb-1">Date Range</Text>
Date Range
</div>
<DateRangePicker <DateRangePicker
className="max-w-md" className="max-w-md"
value={value} value={value}

View File

@@ -5,17 +5,15 @@ import { FeedbackChart } from "./FeedbackChart";
import { QueryPerformanceChart } from "./QueryPerformanceChart"; import { QueryPerformanceChart } from "./QueryPerformanceChart";
import { BarChartIcon } from "@/components/icons/icons"; import { BarChartIcon } from "@/components/icons/icons";
import { useTimeRange } from "../lib"; import { useTimeRange } from "../lib";
import { AdminPageTitle } from "@/components/admin/Title";
export default function AnalyticsPage() { export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useTimeRange(); const [timeRange, setTimeRange] = useTimeRange();
return ( 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 */} {/* TODO: remove this `dark` once we have a mode selector */}
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex"> <AdminPageTitle title="Analytics" icon={<BarChartIcon size={32} />} />
<BarChartIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Analytics</h1>
</div>
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} /> <DateRangeSelector value={timeRange} onValueChange={setTimeRange} />

View File

@@ -18,12 +18,18 @@ export interface AbridgedSearchDoc {
link: string | null; link: string | null;
} }
export interface QuerySnapshot { export interface MessageSnapshot {
id: number; message: string;
query: string; message_type: "user" | "assistant";
user_email: string | null; documents: AbridgedSearchDoc[];
llm_answer: string;
retrieved_documents: AbridgedSearchDoc[];
time_created: string;
feedback: Feedback | null; feedback: Feedback | null;
time_created: string;
}
export interface ChatSessionSnapshot {
id: number;
user_email: string | null;
name: string | null;
messages: MessageSnapshot[];
time_created: string;
} }

View File

@@ -1,8 +1,8 @@
import { errorHandlingFetcher } from "@/lib/fetcher"; import { errorHandlingFetcher } from "@/lib/fetcher";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { import {
ChatSessionSnapshot,
QueryAnalytics, QueryAnalytics,
QuerySnapshot,
UserAnalytics, UserAnalytics,
} from "./analytics/types"; } from "./analytics/types";
import { useState } from "react"; import { useState } from "react";
@@ -55,12 +55,12 @@ export const useQueryHistory = () => {
useState<Feedback | null>(null); useState<Feedback | null>(null);
const [timeRange, setTimeRange] = useTimeRange(); const [timeRange, setTimeRange] = useTimeRange();
const url = buildApiPath("/api/admin/query-history", { const url = buildApiPath("/api/admin/chat-session-history", {
feedback_type: selectedFeedbackType, feedback_type: selectedFeedbackType,
start: convertDateToStartOfDay(timeRange.from)?.toISOString(), start: convertDateToStartOfDay(timeRange.from)?.toISOString(),
end: convertDateToEndOfDay(timeRange.to)?.toISOString(), end: convertDateToEndOfDay(timeRange.to)?.toISOString(),
}); });
const swrResponse = useSWR<QuerySnapshot[]>(url, errorHandlingFetcher); const swrResponse = useSWR<ChatSessionSnapshot[]>(url, errorHandlingFetcher);
return { return {
...swrResponse, ...swrResponse,

View File

@@ -4,7 +4,7 @@ export function DownloadAsCSV() {
return ( return (
<a <a
href="/api/admin/query-history-csv" 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" /> <FiDownload className="my-auto mr-2" />
Download as CSV Download as CSV

View File

@@ -1,7 +1,11 @@
import { Feedback } from "@/lib/types"; import { Feedback } from "@/lib/types";
import { Badge } from "@tremor/react"; import { Badge } from "@tremor/react";
export function FeedbackBadge({ feedback }: { feedback?: Feedback | null }) { export function FeedbackBadge({
feedback,
}: {
feedback?: Feedback | "mixed" | null;
}) {
let feedbackBadge; let feedbackBadge;
switch (feedback) { switch (feedback) {
case "like": case "like":
@@ -18,6 +22,13 @@ export function FeedbackBadge({ feedback }: { feedback?: Feedback | null }) {
</Badge> </Badge>
); );
break; break;
case "mixed":
feedbackBadge = (
<Badge color="purple" className="text-sm">
Mixed
</Badge>
);
break;
default: default:
feedbackBadge = ( feedbackBadge = (
<Badge color="gray" className="text-sm"> <Badge color="gray" className="text-sm">

View File

@@ -13,12 +13,9 @@ import {
import { Divider } from "@tremor/react"; import { Divider } from "@tremor/react";
import { Select, SelectItem } from "@tremor/react"; import { Select, SelectItem } from "@tremor/react";
import { ThreeDotsLoader } from "@/components/Loading"; import { ThreeDotsLoader } from "@/components/Loading";
import { QuerySnapshot } from "../analytics/types"; import { ChatSessionSnapshot } from "../analytics/types";
import { import { timestampToReadableDate } from "@/lib/dateUtils";
timestampToDateString, import { FiFrown, FiMinus, FiSmile } from "react-icons/fi";
timestampToReadableDate,
} from "@/lib/dateUtils";
import { FiBook, FiFrown, FiMinus, FiSmile } from "react-icons/fi";
import { useState } from "react"; import { useState } from "react";
import { Feedback } from "@/lib/types"; import { Feedback } from "@/lib/types";
import { DateRangeSelector } from "../DateRangeSelector"; import { DateRangeSelector } from "../DateRangeSelector";
@@ -30,42 +27,47 @@ import { DownloadAsCSV } from "./DownloadAsCSV";
const NUM_IN_PAGE = 20; const NUM_IN_PAGE = 20;
function QueryHistoryTableRow({ 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 ( return (
<TableRow <TableRow
key={querySnapshot.id} key={chatSessionSnapshot.id}
className="hover:bg-gradient-to-r hover:from-gray-800 hover:to-indigo-950 cursor-pointer relative" className="hover:bg-hover-light cursor-pointer relative"
> >
<TableCell>{querySnapshot.query}</TableCell>
<TableCell> <TableCell>
<Text className="whitespace-normal line-clamp-5"> <Text className="whitespace-normal line-clamp-5">
{querySnapshot.llm_answer} {chatSessionSnapshot.messages[0]?.message || "-"}
</Text> </Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
{querySnapshot.retrieved_documents.slice(0, 5).map((document) => ( <Text className="whitespace-normal line-clamp-5">
<div className="flex" key={document.document_id}> {chatSessionSnapshot.messages[1]?.message || "-"}
<FiBook className="my-auto mr-1" />{" "} </Text>
<p className="max-w-xs text-ellipsis overflow-hidden">
{document.semantic_identifier}
</p>
</div>
))}
</TableCell> </TableCell>
<TableCell> <TableCell>
<FeedbackBadge feedback={querySnapshot.feedback} /> <FeedbackBadge feedback={finalFeedback} />
</TableCell> </TableCell>
<TableCell>{querySnapshot.user_email || "-"}</TableCell> <TableCell>{chatSessionSnapshot.user_email || "-"}</TableCell>
<TableCell> <TableCell>
{timestampToReadableDate(querySnapshot.time_created)} {timestampToReadableDate(chatSessionSnapshot.time_created)}
</TableCell> </TableCell>
{/* Wrapping in <td> to avoid console warnings */} {/* Wrapping in <td> to avoid console warnings */}
<td className="w-0 p-0"> <td className="w-0 p-0">
<Link <Link
href={`/admin/performance/query-history/${querySnapshot.id}`} href={`/admin/performance/query-history/${chatSessionSnapshot.id}`}
className="absolute w-full h-full left-0" className="absolute w-full h-full left-0"
></Link> ></Link>
</td> </td>
@@ -82,9 +84,7 @@ function SelectFeedbackType({
}) { }) {
return ( return (
<div> <div>
<div className="text-sm my-auto mr-2 font-medium text-gray-200 mb-1"> <Text className="my-auto mr-2 font-medium mb-1">Feedback Type</Text>
Feedback Type
</div>
<div className="max-w-sm space-y-6"> <div className="max-w-sm space-y-6">
<Select <Select
value={value} value={value}
@@ -108,7 +108,7 @@ function SelectFeedbackType({
export function QueryHistoryTable() { export function QueryHistoryTable() {
const { const {
data: queryHistoryData, data: chatSessionData,
selectedFeedbackType, selectedFeedbackType,
setSelectedFeedbackType, setSelectedFeedbackType,
timeRange, timeRange,
@@ -119,7 +119,7 @@ export function QueryHistoryTable() {
return ( return (
<Card className="mt-8"> <Card className="mt-8">
{queryHistoryData ? ( {chatSessionData ? (
<> <>
<div className="flex"> <div className="flex">
<div className="gap-y-3 flex flex-col"> <div className="gap-y-3 flex flex-col">
@@ -140,21 +140,20 @@ export function QueryHistoryTable() {
<Table className="mt-5"> <Table className="mt-5">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableHeaderCell>Query</TableHeaderCell> <TableHeaderCell>First User Message</TableHeaderCell>
<TableHeaderCell>LLM Answer</TableHeaderCell> <TableHeaderCell>First AI Response</TableHeaderCell>
<TableHeaderCell>Retrieved Documents</TableHeaderCell>
<TableHeaderCell>Feedback</TableHeaderCell> <TableHeaderCell>Feedback</TableHeaderCell>
<TableHeaderCell>User</TableHeaderCell> <TableHeaderCell>User</TableHeaderCell>
<TableHeaderCell>Date</TableHeaderCell> <TableHeaderCell>Date</TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{queryHistoryData {chatSessionData
.slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page) .slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page)
.map((querySnapshot) => ( .map((chatSessionSnapshot) => (
<QueryHistoryTableRow <QueryHistoryTableRow
key={querySnapshot.id} key={chatSessionSnapshot.id}
querySnapshot={querySnapshot} chatSessionSnapshot={chatSessionSnapshot}
/> />
))} ))}
</TableBody> </TableBody>
@@ -163,7 +162,7 @@ export function QueryHistoryTable() {
<div className="mt-3 flex"> <div className="mt-3 flex">
<div className="mx-auto"> <div className="mx-auto">
<PageSelector <PageSelector
totalPages={Math.ceil(queryHistoryData.length / NUM_IN_PAGE)} totalPages={Math.ceil(chatSessionData.length / NUM_IN_PAGE)}
currentPage={page} currentPage={page}
onPageChange={(newPage) => { onPageChange={(newPage) => {
setPage(newPage); setPage(newPage);

View File

@@ -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>
);
}

View File

@@ -1,82 +1,81 @@
import { Bold, Text, Card, Title, Divider, Italic } from "@tremor/react"; import { Bold, Text, Card, Title, Divider } from "@tremor/react";
import { QuerySnapshot } from "../../analytics/types"; import { ChatSessionSnapshot, MessageSnapshot } from "../../analytics/types";
import { buildUrl } from "@/lib/utilsSS";
import { BackButton } from "./BackButton";
import { FiBook } from "react-icons/fi"; import { FiBook } from "react-icons/fi";
import { processCookies } from "@/lib/userSS";
import { cookies } from "next/headers";
import { timestampToReadableDate } from "@/lib/dateUtils"; 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({ export default async function QueryPage({
params, params,
}: { }: {
params: { id: string }; params: { id: string };
}) { }) {
const response = await fetch(buildUrl(`/admin/query-history/${params.id}`), { const response = await fetchSS(`/admin/chat-session-history/${params.id}`);
next: { revalidate: 0 }, const chatSessionSnapshot = (await response.json()) as ChatSessionSnapshot;
headers: {
cookie: processCookies(cookies()),
},
});
const queryEvent = (await response.json()) as QuerySnapshot;
return ( return (
<main className="pt-4 mx-auto container dark"> <main className="pt-4 mx-auto container">
<BackButton /> <BackButton />
<SSRAutoRefresh />
<Card className="mt-4"> <Card className="mt-4">
<Title>Query Details</Title> <Title>Chat Session Details</Title>
<Text className="flex flex-wrap whitespace-normal mt-1 text-xs"> <Text className="flex flex-wrap whitespace-normal mt-1 text-xs">
{queryEvent.user_email || "-"},{" "} {chatSessionSnapshot.user_email || "-"},{" "}
{timestampToReadableDate(queryEvent.time_created)} {timestampToReadableDate(chatSessionSnapshot.time_created)}
</Text> </Text>
<Divider /> <Divider />
<div className="flex flex-col gap-y-3"> <div className="flex flex-col">
<div> {chatSessionSnapshot.messages.map((message) => {
<Bold>Query</Bold> return (
<Text className="flex flex-wrap whitespace-normal mt-1"> <MessageDisplay key={message.time_created} message={message} />
{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> </div>
</Card> </Card>
</main> </main>

View File

@@ -1,16 +1,15 @@
"use client"; "use client";
import { AdminPageTitle } from "@/components/admin/Title";
import { QueryHistoryTable } from "./QueryHistoryTable"; import { QueryHistoryTable } from "./QueryHistoryTable";
import { DatabaseIcon } from "@/components/icons/icons"; import { DatabaseIcon } from "@/components/icons/icons";
export default function QueryHistoryPage() { export default function QueryHistoryPage() {
return ( 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"> <AdminPageTitle title="Query History" icon={<DatabaseIcon size={32} />} />
<DatabaseIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Query History</h1>
</div>
<QueryHistoryTable /> <QueryHistoryTable />
</main> </main>
); );