Fe for feedback (#346)

This commit is contained in:
Chris Weaver 2023-08-30 12:52:24 -07:00 committed by GitHub
parent 856061c7ea
commit 038f646c09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1339 additions and 391 deletions

View File

@ -23,7 +23,6 @@ from danswer.db.connector_credential_pair import update_connector_credential_pai
from danswer.db.credentials import backend_update_credential_json
from danswer.db.engine import get_db_current_time
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.feedback import create_document_metadata
from danswer.db.index_attempt import create_index_attempt
from danswer.db.index_attempt import get_index_attempt
from danswer.db.index_attempt import get_inprogress_index_attempts

View File

@ -0,0 +1,155 @@
from slack_sdk.models.blocks import ActionsBlock
from slack_sdk.models.blocks import Block
from slack_sdk.models.blocks import ButtonElement
from slack_sdk.models.blocks import SectionBlock
from danswer.bots.slack.constants import DISLIKE_BLOCK_ACTION_ID
from danswer.bots.slack.constants import LIKE_BLOCK_ACTION_ID
from danswer.bots.slack.utils import build_block_id_from_query_event_id
from danswer.configs.app_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY
from danswer.configs.constants import DocumentSource
from danswer.connectors.slack.utils import UserIdReplacer
from danswer.direct_qa.interfaces import DanswerQuote
from danswer.server.models import SearchDoc
def build_feedback_block(query_event_id: int) -> Block:
return ActionsBlock(
block_id=build_block_id_from_query_event_id(query_event_id),
elements=[
ButtonElement(
action_id=LIKE_BLOCK_ACTION_ID,
text="👍",
style="primary",
),
ButtonElement(
action_id=DISLIKE_BLOCK_ACTION_ID,
text="👎",
style="danger",
),
],
)
_MAX_BLURB_LEN = 75
def _build_custom_semantic_identifier(
semantic_identifier: str, blurb: str, source: str
) -> str:
"""
On slack, since we just show the semantic identifier rather than semantic + blurb, we need
to do some custom formatting to make sure the semantic identifier is unique and meaningful.
"""
if source == DocumentSource.SLACK.value:
truncated_blurb = (
f"{blurb[:_MAX_BLURB_LEN]}..." if len(blurb) > _MAX_BLURB_LEN else blurb
)
# NOTE: removing tags so that we don't accidentally tag users in Slack +
# so that it can be used as part of a <link|text> link
truncated_blurb = UserIdReplacer.replace_tags_basic(truncated_blurb)
truncated_blurb = UserIdReplacer.replace_channels_basic(truncated_blurb)
truncated_blurb = UserIdReplacer.replace_special_mentions(truncated_blurb)
if truncated_blurb:
return f"#{semantic_identifier}: {truncated_blurb}"
else:
return f"#{semantic_identifier}"
return semantic_identifier
def build_documents_block(
documents: list[SearchDoc],
already_displayed_doc_identifiers: list[str],
num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY,
) -> SectionBlock:
seen_docs_identifiers = set(already_displayed_doc_identifiers)
top_document_lines: list[str] = []
for d in documents:
if d.document_id in seen_docs_identifiers:
continue
seen_docs_identifiers.add(d.document_id)
custom_semantic_identifier = _build_custom_semantic_identifier(
semantic_identifier=d.semantic_identifier,
blurb=d.blurb,
source=d.source_type,
)
top_document_lines.append(f"- <{d.link}|{custom_semantic_identifier}>")
if len(top_document_lines) >= num_docs_to_display:
break
return SectionBlock(
fields=[
"*Other potentially relevant docs:*",
*top_document_lines,
]
)
def build_quotes_block(
quotes: list[DanswerQuote],
) -> tuple[list[Block], list[str]]:
quote_lines: list[str] = []
doc_identifiers: list[str] = []
for quote in quotes:
doc_id = quote.document_id
doc_link = quote.link
doc_name = quote.semantic_identifier
if doc_link and doc_name and doc_id and doc_id not in doc_identifiers:
doc_identifiers.append(doc_id)
custom_semantic_identifier = _build_custom_semantic_identifier(
semantic_identifier=doc_name,
blurb=quote.blurb,
source=quote.source_type,
)
quote_lines.append(f"- <{doc_link}|{custom_semantic_identifier}>")
if not quote_lines:
return [], []
return (
[
SectionBlock(
fields=[
"*Sources:*",
*quote_lines,
]
)
],
doc_identifiers,
)
def build_qa_response_blocks(
query_event_id: int,
answer: str | None,
quotes: list[DanswerQuote] | None,
documents: list[SearchDoc],
) -> list[Block]:
doc_identifiers: list[str] = []
quotes_blocks: list[Block] = []
if not answer:
answer_block = SectionBlock(
text=f"Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓"
)
else:
answer_block = SectionBlock(text=answer)
if quotes:
quotes_blocks, doc_identifiers = build_quotes_block(quotes)
# if no quotes OR `build_quotes_block()` did not give back any blocks
if not quotes_blocks:
quotes_blocks = [
SectionBlock(
text="*Warning*: no sources were quoted for this answer, so it may be unreliable 😔"
)
]
documents_block = build_documents_block(documents, doc_identifiers)
return (
[answer_block]
+ quotes_blocks
+ [documents_block, build_feedback_block(query_event_id=query_event_id)]
)

View File

@ -0,0 +1,2 @@
LIKE_BLOCK_ACTION_ID = "feedback-like"
DISLIKE_BLOCK_ACTION_ID = "feedback-dislike"

View File

@ -0,0 +1,16 @@
from sqlalchemy.orm import Session
from danswer.configs.constants import QAFeedbackType
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.feedback import update_query_event_feedback
def handle_qa_feedback(query_id: int, feedback_type: QAFeedbackType) -> None:
engine = get_sqlalchemy_engine()
with Session(engine) as db_session:
update_query_event_feedback(
feedback=feedback_type,
query_id=query_id,
user_id=None, # no "user" for Slack bot for now
db_session=db_session,
)

View File

@ -0,0 +1,115 @@
import logging
from retry import retry
from slack_sdk import WebClient
from sqlalchemy.orm import Session
from danswer.bots.slack.blocks import build_qa_response_blocks
from danswer.bots.slack.utils import respond_in_thread
from danswer.configs.app_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
from danswer.configs.app_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
from danswer.configs.app_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.app_configs import DOCUMENT_INDEX_NAME
from danswer.db.engine import get_sqlalchemy_engine
from danswer.direct_qa.answer_question import answer_qa_query
from danswer.server.models import QAResponse
from danswer.server.models import QuestionRequest
def handle_message(
msg: str,
channel: str,
message_ts_to_respond_to: str,
client: WebClient,
logger: logging.Logger,
num_retries: int = DANSWER_BOT_NUM_RETRIES,
answer_generation_timeout: int = DANSWER_BOT_ANSWER_GENERATION_TIMEOUT,
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER,
) -> None:
@retry(
tries=num_retries,
delay=0.25,
backoff=2,
logger=logger,
)
def _get_answer(question: QuestionRequest) -> QAResponse:
engine = get_sqlalchemy_engine()
with Session(engine, expire_on_commit=False) as db_session:
answer = answer_qa_query(
question=question,
user=None,
db_session=db_session,
answer_generation_timeout=answer_generation_timeout,
)
if not answer.error_msg:
return answer
else:
raise RuntimeError(answer.error_msg)
try:
answer = _get_answer(
QuestionRequest(
query=msg,
collection=DOCUMENT_INDEX_NAME,
use_keyword=None,
filters=None,
offset=None,
)
)
except Exception as e:
logger.exception(
f"Unable to process message - did not successfully answer "
f"in {num_retries} attempts"
)
# Optionally, respond in thread with the error message, Used primarily
# for debugging purposes
if should_respond_with_error_msgs:
respond_in_thread(
client=client,
channel=channel,
text=f"Encountered exception when trying to answer: \n\n```{e}```",
thread_ts=message_ts_to_respond_to,
)
return
if not answer.top_ranked_docs:
logger.error(f"Unable to answer question: '{msg}' - no documents found")
# Optionally, respond in thread with the error message, Used primarily
# for debugging purposes
if should_respond_with_error_msgs:
respond_in_thread(
client=client,
channel=channel,
text="Found no documents when trying to answer. Did you index any documents?",
thread_ts=message_ts_to_respond_to,
)
return
if not answer.answer and disable_docs_only_answer:
logger.info(
"Unable to find answer - not responding since the "
"`DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER` env variable is set"
)
return
# convert raw response into "nicely" formatted Slack message
blocks = build_qa_response_blocks(
query_event_id=answer.query_event_id,
answer=answer.answer,
documents=answer.top_ranked_docs,
quotes=answer.quotes,
)
try:
respond_in_thread(
client=client,
channel=channel,
blocks=blocks,
thread_ts=message_ts_to_respond_to,
)
except Exception:
logger.exception(
f"Unable to process message - could not respond in slack in {num_retries} attempts"
)
return

View File

@ -0,0 +1,184 @@
import logging
import os
from collections.abc import MutableMapping
from typing import Any
from typing import cast
from slack_sdk import WebClient
from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from danswer.bots.slack.constants import DISLIKE_BLOCK_ACTION_ID
from danswer.bots.slack.constants import LIKE_BLOCK_ACTION_ID
from danswer.bots.slack.handlers.handle_feedback import handle_qa_feedback
from danswer.bots.slack.handlers.handle_message import handle_message
from danswer.bots.slack.utils import get_query_event_id_from_block_id
from danswer.configs.constants import QAFeedbackType
from danswer.utils.logger import setup_logger
logger = setup_logger()
_CHANNEL_ID = "channel_id"
class _ChannelIdAdapter(logging.LoggerAdapter):
"""This is used to add the channel ID to all log messages
emitted in this file"""
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> tuple[str, MutableMapping[str, Any]]:
channel_id = self.extra.get(_CHANNEL_ID) if self.extra else None
if channel_id:
return f"[Channel ID: {channel_id}] {msg}", kwargs
else:
return msg, kwargs
def _get_socket_client() -> SocketModeClient:
# For more info on how to set this up, checkout the docs:
# https://docs.danswer.dev/slack_bot_setup
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
if not app_token:
raise RuntimeError("DANSWER_BOT_SLACK_APP_TOKEN is not set")
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
if not bot_token:
raise RuntimeError("DANSWER_BOT_SLACK_BOT_TOKEN is not set")
return SocketModeClient(
# This app-level token will be used only for establishing a connection
app_token=app_token,
web_client=WebClient(token=bot_token),
)
def _process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> None:
logger.info(f"Received request of type: '{req.type}', with paylod: '{req.payload}'")
if req.type == "events_api":
# Acknowledge the request immediately
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
event = cast(dict[str, Any], req.payload.get("event", {}))
channel = cast(str | None, event.get("channel"))
channel_specific_logger = _ChannelIdAdapter(
logger, extra={_CHANNEL_ID: channel}
)
# Ensure that the message is a new message + of expected type
event_type = event.get("type")
if event_type != "message":
channel_specific_logger.info(
f"Ignoring non-message event of type '{event_type}' for channel '{channel}'"
)
# this should never happen, but we can't continue without a channel since
# we can't send a response without it
if not channel:
channel_specific_logger.error(f"Found message without channel - skipping")
return
message_subtype = event.get("subtype")
# ignore things like channel_join, channel_leave, etc.
# NOTE: "file_share" is just a message with a file attachment, so we
# should not ignore it
if message_subtype not in [None, "file_share"]:
channel_specific_logger.info(
f"Ignoring message with subtype '{message_subtype}' since is is a special message type"
)
return
if event.get("bot_profile"):
channel_specific_logger.info("Ignoring message from bot")
return
message_ts = event.get("ts")
thread_ts = event.get("thread_ts")
# pick the root of the thread (if a thread exists)
message_ts_to_respond_to = cast(str, thread_ts or message_ts)
if thread_ts and message_ts != thread_ts:
channel_specific_logger.info(
"Skipping message since it is not the root of a thread"
)
return
msg = cast(str | None, event.get("text"))
if not msg:
channel_specific_logger.error("Unable to process empty message")
return
# TODO: message should be enqueued and processed elsewhere,
# but doing it here for now for simplicity
handle_message(
msg=msg,
channel=channel,
message_ts_to_respond_to=message_ts_to_respond_to,
client=client.web_client,
logger=cast(logging.Logger, channel_specific_logger),
)
channel_specific_logger.info(
f"Successfully processed message with ts: '{message_ts}'"
)
# handle button clicks
if req.type == "interactive" and req.payload.get("type") == "block_actions":
# Acknowledge the request immediately
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
actions = req.payload.get("actions")
if not actions:
logger.error("Unable to process block actions - no actions found")
return
action = cast(dict[str, Any], actions[0])
action_id = action.get("action_id")
if action_id == LIKE_BLOCK_ACTION_ID:
feedback_type = QAFeedbackType.LIKE
elif action_id == DISLIKE_BLOCK_ACTION_ID:
feedback_type = QAFeedbackType.DISLIKE
else:
logger.error(
f"Unable to process block action - unknown action_id: '{action_id}'"
)
return
block_id = cast(str, action.get("block_id"))
query_event_id = get_query_event_id_from_block_id(block_id)
handle_qa_feedback(
query_id=query_event_id,
feedback_type=feedback_type,
)
logger.info(f"Successfully handled QA feedback for event: {query_event_id}")
def process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> None:
try:
_process_slack_event(client=client, req=req)
except Exception:
logger.exception("Failed to process slack event")
# Follow the guide (https://docs.danswer.dev/slack_bot_setup) to set up
# the slack bot in your workspace, and then add the bot to any channels you want to
# try and answer questions for. Running this file will setup Danswer to listen to all
# messages in those channels and attempt to answer them. As of now, it will only respond
# to messages sent directly in the channel - it will not respond to messages sent within a
# thread.
#
# NOTE: we are using Web Sockets so that you can run this from within a firewalled VPC
# without issue.
if __name__ == "__main__":
socket_client = _get_socket_client()
socket_client.socket_mode_request_listeners.append(process_slack_event) # type: ignore
# Establish a WebSocket connection to the Socket Mode servers
logger.info("Listening for messages from Slack...")
socket_client.connect()
# Just not to stop this process
from threading import Event
Event().wait()

View File

@ -0,0 +1,58 @@
import logging
import random
import string
from typing import cast
from retry import retry
from slack_sdk import WebClient
from slack_sdk.models.blocks import Block
from slack_sdk.models.metadata import Metadata
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
from danswer.connectors.slack.utils import make_slack_api_rate_limited
from danswer.utils.logger import setup_logger
logger = setup_logger()
@retry(
tries=DANSWER_BOT_NUM_RETRIES,
delay=0.25,
backoff=2,
logger=cast(logging.Logger, logger),
)
def respond_in_thread(
client: WebClient,
channel: str,
thread_ts: str,
text: str | None = None,
blocks: list[Block] | None = None,
metadata: Metadata | None = None,
) -> None:
if not text and not blocks:
raise ValueError("One of `text` or `blocks` must be provided")
if text:
logger.info(f"Trying to send message: {text}")
if blocks:
logger.info(f"Trying to send blocks: {blocks}")
slack_call = make_slack_api_rate_limited(client.chat_postMessage)
response = slack_call(
channel=channel,
text=text,
blocks=blocks,
thread_ts=thread_ts,
metadata=metadata,
)
if not response.get("ok"):
raise RuntimeError(f"Unable to post message: {response}")
def build_block_id_from_query_event_id(query_event_id: int) -> str:
return f"{''.join(random.choice(string.ascii_letters) for _ in range(5))}:{query_event_id}"
def get_query_event_id_from_block_id(block_id: str) -> int:
return int(block_id.split(":")[-1])

View File

@ -27,15 +27,15 @@ def fetch_query_event_by_id(query_id: int, db_session: Session) -> QueryEvent:
return query_event
def fetch_doc_m_by_id(doc_id: str, db_session: Session) -> DbDocument:
def fetch_docs_by_id(doc_id: str, db_session: Session) -> DbDocument:
stmt = select(DbDocument).where(DbDocument.id == doc_id)
result = db_session.execute(stmt)
doc_m = result.scalar_one_or_none()
doc = result.scalar_one_or_none()
if not doc_m:
if not doc:
raise ValueError("Invalid Document provided for updating")
return doc_m
return doc
def fetch_docs_ranked_by_boost(
@ -44,29 +44,19 @@ def fetch_docs_ranked_by_boost(
order_func = asc if ascending else desc
stmt = select(DbDocument).order_by(order_func(DbDocument.boost)).limit(limit)
result = db_session.execute(stmt)
doc_m_list = result.scalars().all()
doc_list = result.scalars().all()
return list(doc_m_list)
return list(doc_list)
def create_document_metadata(
doc_id: str,
semantic_id: str,
link: str | None,
db_session: Session,
) -> None:
try:
fetch_doc_m_by_id(doc_id, db_session)
return
except ValueError:
# Document already exists, don't reset its data
pass
def update_document_boost(db_session: Session, document_id: str, boost: int) -> None:
stmt = select(DbDocument).where(DbDocument.id == document_id)
result = db_session.execute(stmt).scalar_one_or_none()
if result is None:
raise ValueError(f"No document found with ID: '{document_id}'")
DbDocument(
id=doc_id,
semantic_id=semantic_id,
link=link,
)
result.boost = boost
db_session.commit()
def create_query_event(
@ -121,7 +111,7 @@ def create_doc_retrieval_feedback(
if user_id != query_event.user_id:
raise ValueError("User trying to give feedback on a query run by another user.")
doc_m = fetch_doc_m_by_id(document_id, db_session)
doc_m = fetch_docs_by_id(document_id, db_session)
retrieval_feedback = DocumentRetrievalFeedback(
qa_event_id=qa_event_id,

View File

@ -1,332 +0,0 @@
import logging
import os
from collections.abc import MutableMapping
from typing import Any
from typing import cast
from retry import retry
from slack_sdk import WebClient
from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
from sqlalchemy.orm import Session
from danswer.configs.app_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
from danswer.configs.app_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
from danswer.configs.app_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
from danswer.configs.app_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY
from danswer.configs.app_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.app_configs import DOCUMENT_INDEX_NAME
from danswer.configs.constants import DocumentSource
from danswer.connectors.slack.utils import make_slack_api_rate_limited
from danswer.connectors.slack.utils import UserIdReplacer
from danswer.db.engine import get_sqlalchemy_engine
from danswer.direct_qa.answer_question import answer_qa_query
from danswer.direct_qa.interfaces import DanswerQuote
from danswer.server.models import QAResponse
from danswer.server.models import QuestionRequest
from danswer.server.models import SearchDoc
from danswer.utils.logger import setup_logger
logger = setup_logger()
_CHANNEL_ID = "channel_id"
class _ChannelIdAdapter(logging.LoggerAdapter):
"""This is used to add the channel ID to all log messages
emitted in this file"""
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> tuple[str, MutableMapping[str, Any]]:
channel_id = self.extra.get(_CHANNEL_ID) if self.extra else None
if channel_id:
return f"[Channel ID: {channel_id}] {msg}", kwargs
else:
return msg, kwargs
def _get_socket_client() -> SocketModeClient:
# For more info on how to set this up, checkout the docs:
# https://docs.danswer.dev/slack_bot_setup
app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN")
if not app_token:
raise RuntimeError("DANSWER_BOT_SLACK_APP_TOKEN is not set")
bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN")
if not bot_token:
raise RuntimeError("DANSWER_BOT_SLACK_BOT_TOKEN is not set")
return SocketModeClient(
# This app-level token will be used only for establishing a connection
app_token=app_token,
web_client=WebClient(token=bot_token),
)
_MAX_BLURB_LEN = 25
def _build_custom_semantic_identifier(
semantic_identifier: str, blurb: str, source: str
) -> str:
"""
On slack, since we just show the semantic identifier rather than semantic + blurb, we need
to do some custom formatting to make sure the semantic identifier is unique and meaningful.
"""
if source == DocumentSource.SLACK.value:
truncated_blurb = (
f"{blurb[:_MAX_BLURB_LEN]}..." if len(blurb) > _MAX_BLURB_LEN else blurb
)
# NOTE: removing tags so that we don't accidentally tag users in Slack +
# so that it can be used as part of a <link|text> link
truncated_blurb = UserIdReplacer.replace_tags_basic(truncated_blurb)
truncated_blurb = UserIdReplacer.replace_channels_basic(truncated_blurb)
truncated_blurb = UserIdReplacer.replace_special_mentions(truncated_blurb)
if truncated_blurb:
return f"#{semantic_identifier}: {truncated_blurb}"
else:
return f"#{semantic_identifier}"
return semantic_identifier
def _process_quotes(quotes: list[DanswerQuote] | None) -> tuple[str | None, list[str]]:
if not quotes:
return None, []
quote_lines: list[str] = []
doc_identifiers: list[str] = []
for quote in quotes:
doc_id = quote.document_id
doc_link = quote.link
doc_name = quote.semantic_identifier
if doc_link and doc_name and doc_id and doc_id not in doc_identifiers:
doc_identifiers.append(doc_id)
custom_semantic_identifier = _build_custom_semantic_identifier(
semantic_identifier=doc_name,
blurb=quote.blurb,
source=quote.source_type,
)
quote_lines.append(f"- <{doc_link}|{custom_semantic_identifier}>")
if not quote_lines:
return None, []
return "\n".join(quote_lines), doc_identifiers
def _process_documents(
documents: list[SearchDoc] | None,
already_displayed_doc_identifiers: list[str],
num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY,
) -> str | None:
if not documents:
return None
seen_docs_identifiers = set(already_displayed_doc_identifiers)
top_document_lines: list[str] = []
for d in documents:
if d.document_id in seen_docs_identifiers:
continue
seen_docs_identifiers.add(d.document_id)
custom_semantic_identifier = _build_custom_semantic_identifier(
semantic_identifier=d.semantic_identifier,
blurb=d.blurb,
source=d.source_type,
)
top_document_lines.append(f"- <{d.link}|{custom_semantic_identifier}>")
if len(top_document_lines) >= num_docs_to_display:
break
return "\n".join(top_document_lines)
@retry(
tries=DANSWER_BOT_NUM_RETRIES,
delay=0.25,
backoff=2,
logger=cast(logging.Logger, logger),
)
def _respond_in_thread(
client: SocketModeClient,
channel: str,
text: str,
thread_ts: str,
) -> None:
logger.info(f"Trying to send message: {text}")
slack_call = make_slack_api_rate_limited(client.web_client.chat_postMessage)
response = slack_call(
channel=channel,
text=text,
thread_ts=thread_ts,
)
if not response.get("ok"):
raise RuntimeError(f"Unable to post message: {response}")
def process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> None:
if req.type == "events_api":
# Acknowledge the request anyway
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
event = cast(dict[str, Any], req.payload.get("event", {}))
channel = cast(str | None, event.get("channel"))
channel_specific_logger = _ChannelIdAdapter(
logger, extra={_CHANNEL_ID: channel}
)
# Ensure that the message is a new message + of expected type
event_type = event.get("type")
if event_type != "message":
channel_specific_logger.info(
f"Ignoring non-message event of type '{event_type}' for channel '{channel}'"
)
# this should never happen, but we can't continue without a channel since
# we can't send a response without it
if not channel:
channel_specific_logger.error(f"Found message without channel - skipping")
return
message_subtype = event.get("subtype")
# ignore things like channel_join, channel_leave, etc.
# NOTE: "file_share" is just a message with a file attachment, so we
# should not ignore it
if message_subtype not in [None, "file_share"]:
channel_specific_logger.info(
f"Ignoring message with subtype '{message_subtype}' since is is a special message type"
)
return
if event.get("bot_profile"):
channel_specific_logger.info("Ignoring message from bot")
return
message_ts = event.get("ts")
thread_ts = event.get("thread_ts")
# pick the root of the thread (if a thread exists)
message_ts_to_respond_to = cast(str, thread_ts or message_ts)
if thread_ts and message_ts != thread_ts:
channel_specific_logger.info(
"Skipping message since it is not the root of a thread"
)
return
msg = cast(str | None, event.get("text"))
if not msg:
channel_specific_logger.error("Unable to process empty message")
return
# TODO: message should be enqueued and processed elsewhere,
# but doing it here for now for simplicity
@retry(
tries=DANSWER_BOT_NUM_RETRIES,
delay=0.25,
backoff=2,
logger=cast(logging.Logger, logger),
)
def _get_answer(question: QuestionRequest) -> QAResponse:
engine = get_sqlalchemy_engine()
with Session(engine, expire_on_commit=False) as db_session:
answer = answer_qa_query(
question=question,
user=None,
db_session=db_session,
answer_generation_timeout=DANSWER_BOT_ANSWER_GENERATION_TIMEOUT,
)
if not answer.error_msg:
return answer
else:
raise RuntimeError(answer.error_msg)
try:
answer = _get_answer(
QuestionRequest(
query=msg,
collection=DOCUMENT_INDEX_NAME,
use_keyword=None,
filters=None,
offset=None,
)
)
except Exception as e:
channel_specific_logger.exception(
f"Unable to process message - did not successfully answer "
f"in {DANSWER_BOT_NUM_RETRIES} attempts"
)
# Optionally, respond in thread with the error message, Used primarily
# for debugging purposes
if DANSWER_BOT_DISPLAY_ERROR_MSGS:
_respond_in_thread(
client=client,
channel=channel,
text=f"Encountered exception when trying to answer: \n\n```{e}```",
thread_ts=message_ts_to_respond_to,
)
return
# convert raw response into "nicely" formatted Slack message
quote_str, doc_identifiers = _process_quotes(answer.quotes)
top_documents_str = _process_documents(answer.top_ranked_docs, doc_identifiers)
if not answer.answer:
if DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER:
logger.info(
"Unable to find answer - not responding since the "
"`DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER` env variable is set"
)
return
text = f"Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓\n\n{top_documents_str}"
else:
top_documents_str_with_header = (
f"*Other potentially relevant docs:*\n{top_documents_str}"
)
if quote_str:
text = f"{answer.answer}\n\n*Sources:*\n{quote_str}\n\n{top_documents_str_with_header}"
else:
text = f"{answer.answer}\n\n*Warning*: no sources were quoted for this answer, so it may be unreliable 😔\n\n{top_documents_str_with_header}"
try:
_respond_in_thread(
client=client,
channel=channel,
text=text,
thread_ts=message_ts_to_respond_to,
)
except Exception:
channel_specific_logger.exception(
f"Unable to process message - could not respond in slack in {DANSWER_BOT_NUM_RETRIES} attempts"
)
return
channel_specific_logger.info(
f"Successfully processed message with ts: '{message_ts}'"
)
# Follow the guide (https://docs.danswer.dev/slack_bot_setup) to set up
# the slack bot in your workspace, and then add the bot to any channels you want to
# try and answer questions for. Running this file will setup Danswer to listen to all
# messages in those channels and attempt to answer them. As of now, it will only respond
# to messages sent directly in the channel - it will not respond to messages sent within a
# thread.
#
# NOTE: we are using Web Sockets so that you can run this from within a firewalled VPC
# without issue.
if __name__ == "__main__":
socket_client = _get_socket_client()
socket_client.socket_mode_request_listeners.append(process_slack_event) # type: ignore
# Establish a WebSocket connection to the Socket Mode servers
logger.info("Listening for messages from Slack...")
socket_client.connect()
# Just not to stop this process
from threading import Event
Event().wait()

View File

@ -85,6 +85,7 @@ def retrieve_ranked_documents(
f"Semantic search returned no results with filters: {filters_log_msg}"
)
return None, None
logger.info(top_chunks)
ranked_chunks = semantic_reranking(query, top_chunks[:num_rerank])
top_docs = [

View File

@ -50,6 +50,7 @@ from danswer.db.deletion_attempt import create_deletion_attempt
from danswer.db.deletion_attempt import get_deletion_attempts
from danswer.db.engine import get_session
from danswer.db.feedback import fetch_docs_ranked_by_boost
from danswer.db.feedback import update_document_boost
from danswer.db.index_attempt import create_index_attempt
from danswer.db.index_attempt import get_latest_index_attempts
from danswer.db.models import DeletionAttempt
@ -63,6 +64,7 @@ from danswer.server.models import ApiKey
from danswer.server.models import AuthStatus
from danswer.server.models import AuthUrl
from danswer.server.models import BoostDoc
from danswer.server.models import BoostUpdateRequest
from danswer.server.models import ConnectorBase
from danswer.server.models import ConnectorCredentialPairIdentifier
from danswer.server.models import ConnectorIndexingStatus
@ -104,6 +106,7 @@ def get_most_boosted_docs(
BoostDoc(
document_id=doc.id,
semantic_id=doc.semantic_id,
# source=doc.source,
link=doc.link or "",
boost=doc.boost,
hidden=doc.hidden,
@ -112,6 +115,22 @@ def get_most_boosted_docs(
]
@router.post("/admin/doc-boosts")
def document_boost_update(
boost_update: BoostUpdateRequest,
_: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_document_boost(
db_session=db_session,
document_id=boost_update.document_id,
boost=boost_update.boost,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/admin/connector/google-drive/app-credential")
def check_google_app_credentials_exist(
_: User = Depends(current_admin_user),

View File

@ -115,6 +115,11 @@ class BoostDoc(BaseModel):
hidden: bool
class BoostUpdateRequest(BaseModel):
document_id: str
boost: int
class SearchDoc(BaseModel):
document_id: str
semantic_identifier: str

View File

@ -29,7 +29,7 @@ autorestart=true
# If not setup, this will just fail 5 times and then stop.
# More details on setup here: https://docs.danswer.dev/slack_bot_setup
[program:slack_bot_listener]
command=python danswer/listeners/slack_listener.py
command=python danswer/bots/slack/listener.py
stdout_logfile=/var/log/slack_bot_listener.log
stdout_logfile_maxbytes=52428800
redirect_stderr=true

View File

@ -2,7 +2,7 @@ import { Button } from "@/components/Button";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { StatusRow } from "@/components/admin/connectors/table/ConnectorsTable";
import { PencilIcon } from "@/components/icons/icons";
import { EditIcon } from "@/components/icons/icons";
import { deleteConnector } from "@/lib/connector";
import {
GoogleDriveConfig,
@ -44,7 +44,7 @@ const EditableColumn = ({ connectorIndexingStatus }: EditableColumnProps) => {
className="cursor-pointer"
>
<div className="mr-2">
<PencilIcon size={20} />
<EditIcon size={20} />
</div>
</div>
</div>

View File

@ -0,0 +1,239 @@
"use client";
import { LoadingAnimation } from "@/components/Loading";
import { PageSelector } from "@/components/PageSelector";
import { BasicTable } from "@/components/admin/connectors/BasicTable";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import {
CheckmarkIcon,
EditIcon,
ThumbsUpIcon,
} from "@/components/icons/icons";
import { useMostReactedToDocuments } from "@/lib/hooks";
import { DocumentBoostStatus, User } from "@/lib/types";
import { useState } from "react";
const numPages = 8;
const numToDisplay = 10;
const updateBoost = async (documentId: string, boost: number) => {
const response = await fetch("/api/manage/admin/doc-boosts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
document_id: documentId,
boost,
}),
});
if (response.ok) {
return null;
}
const responseJson = await response.json();
return responseJson.message || responseJson.detail || "Unknown error";
};
const ScoreSection = ({
documentId,
initialScore,
setPopup,
refresh,
}: {
documentId: string;
initialScore: number;
setPopup: (popupSpec: PopupSpec | null) => void;
refresh: () => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [score, setScore] = useState(initialScore);
const onSubmit = async () => {
const errorMsg = await updateBoost(documentId, score);
if (errorMsg) {
setPopup({
message: errorMsg,
type: "error",
});
} else {
setPopup({
message: "Updated score!",
type: "success",
});
refresh();
setIsOpen(false);
}
};
if (isOpen) {
return (
<div className="m-auto flex">
<input
value={score}
onChange={(e) => {
if (!isNaN(Number(e.target.value))) {
setScore(Number(e.target.value));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSubmit();
}
}}
className="border bg-slate-700 text-gray-200 border-gray-300 rounded py-1 px-3 w-16"
/>
<div onClick={onSubmit} className="cursor-pointer my-auto ml-2">
<CheckmarkIcon size={20} className="text-green-700" />
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
<div className="flex my-auto">
<div className="w-6 flex">
<div className="ml-auto">{initialScore}</div>
</div>
<div className="cursor-pointer ml-2" onClick={() => setIsOpen(true)}>
<EditIcon size={20} />
</div>
</div>
</div>
);
};
interface DocumentFeedbackTableProps {
documents: DocumentBoostStatus[];
refresh: () => void;
}
const DocumentFeedbackTable = ({
documents,
refresh,
}: DocumentFeedbackTableProps) => {
const [page, setPage] = useState(1);
const { popup, setPopup } = usePopup();
return (
<div>
{popup}
<BasicTable
columns={[
{
header: "Document Name",
key: "name",
},
{
header: "Score",
key: "score",
},
]}
data={documents
.slice((page - 1) * numToDisplay, page * numToDisplay)
.map((document) => {
return {
name: (
<a
className="text-blue-600"
href={document.link}
target="_blank"
rel="noopener noreferrer"
>
{document.semantic_id}
</a>
),
score: (
<div className="ml-auto flex w-16">
<div key={document.document_id} className="h-8 ml-auto mr-8">
<ScoreSection
documentId={document.document_id}
initialScore={document.boost}
refresh={refresh}
setPopup={setPopup}
/>
</div>
</div>
),
};
})}
/>
<div className="mt-3 flex">
<div className="mx-auto">
<PageSelector
totalPages={Math.ceil(documents.length / numToDisplay)}
currentPage={page}
onPageChange={(newPage) => setPage(newPage)}
/>
</div>
</div>
</div>
);
};
const Main = () => {
const {
data: mostLikedDocuments,
isLoading: isMostLikedDocumentsLoading,
error: mostLikedDocumentsError,
refreshDocs: refreshMostLikedDocuments,
} = useMostReactedToDocuments(false, numToDisplay * numPages);
const {
data: mostDislikedDocuments,
isLoading: isMostLikedDocumentLoading,
error: mostDislikedDocumentsError,
refreshDocs: refreshMostDislikedDocuments,
} = useMostReactedToDocuments(true, numToDisplay * numPages);
const refresh = () => {
refreshMostLikedDocuments();
refreshMostDislikedDocuments();
};
if (isMostLikedDocumentsLoading || isMostLikedDocumentLoading) {
return <LoadingAnimation text="Loading" />;
}
if (
mostLikedDocumentsError ||
mostDislikedDocumentsError ||
!mostLikedDocuments ||
!mostDislikedDocuments
) {
return (
<div className="text-red-600">
Error loading documents -{" "}
{mostDislikedDocumentsError || mostLikedDocumentsError}
</div>
);
}
return (
<div className="mb-8">
<h2 className="font-bold text-xxl mb-2">Most Liked Documents</h2>
<DocumentFeedbackTable documents={mostLikedDocuments} refresh={refresh} />
<h2 className="font-bold text-xl mb-2 mt-4">Most Disliked Documents</h2>
<DocumentFeedbackTable
documents={mostDislikedDocuments}
refresh={refresh}
/>
</div>
);
};
const Page = () => {
return (
<div>
<div className="border-solid border-gray-600 border-b pb-2 mb-4 flex">
<ThumbsUpIcon size={32} />
<h1 className="text-3xl font-bold pl-2">Document Feedback</h1>
</div>
<Main />
</div>
);
};
export default Page;

View File

@ -18,6 +18,7 @@ import {
ProductboardIcon,
LinearIcon,
UsersIcon,
ThumbsUpIcon,
} from "@/components/icons/icons";
import { DISABLE_AUTH } from "@/lib/constants";
import { getCurrentUserSS } from "@/lib/userSS";
@ -219,6 +220,20 @@ export default async function AdminLayout({
},
],
},
{
name: "Document Management",
items: [
{
name: (
<div className="flex">
<ThumbsUpIcon size={18} />
<div className="ml-1">Feedback</div>
</div>
),
link: "/admin/documents/feedback",
},
],
},
]}
/>
<div className="px-12 min-h-screen bg-gray-900 text-gray-100 w-full">

View File

@ -0,0 +1,149 @@
import React from "react";
const PAGINATION_OPTIONS_ON_EACH_SIDE = 2;
const getPaginationOptions = (
currentPage: number,
pageCount: number
): number[] => {
const paginationOptions = [currentPage];
// if (currentPage !== 1) {
// paginationOptions.push(currentPage)
// }
let offset = 1;
// Add one because currentPage is included
const maxPaginationOptions = PAGINATION_OPTIONS_ON_EACH_SIDE * 2 + 1;
while (paginationOptions.length < maxPaginationOptions) {
let added = false;
if (currentPage + offset <= pageCount) {
paginationOptions.push(currentPage + offset);
added = true;
}
if (currentPage - offset >= 1) {
paginationOptions.unshift(currentPage - offset);
added = true;
}
if (!added) {
break;
}
offset++;
}
return paginationOptions;
};
const scrollUp = () => {
setTimeout(() => window.scrollTo({ top: 0 }), 50);
};
type PageLinkProps = {
linkText: string | number;
pageChangeHandler?: () => void;
active?: boolean;
unclickable?: boolean;
};
const PageLink = ({
linkText,
pageChangeHandler,
active,
unclickable,
}: PageLinkProps) => (
<div
className={`
inline-block
text-sm
border
px-3
py-1
leading-5
-ml-px
text-gray-300
border-gray-600
${!unclickable ? "hover:bg-gray-600" : ""}
${!unclickable ? "cursor-pointer" : ""}
first:ml-0
first:rounded-l-md
last:rounded-r-md
${active ? "bg-gray-700" : ""}
`}
onClick={() => {
if (pageChangeHandler) {
pageChangeHandler();
}
}}
>
{linkText}
</div>
);
interface PageSelectorProps {
currentPage: number;
totalPages: number;
onPageChange: (newPage: number) => void;
shouldScroll?: boolean;
}
export const PageSelector = ({
currentPage,
totalPages,
onPageChange,
shouldScroll = false,
}: PageSelectorProps) => {
const paginationOptions = getPaginationOptions(currentPage, totalPages);
const modifiedScrollUp = () => {
if (shouldScroll) {
scrollUp();
}
};
return (
<div style={{ display: "inline-block" }}>
<PageLink
linkText=""
pageChangeHandler={() => {
onPageChange(Math.max(currentPage - 1, 1));
modifiedScrollUp();
}}
/>
{!paginationOptions.includes(1) && (
<>
<PageLink
linkText="1"
active={currentPage === 1}
pageChangeHandler={() => {
onPageChange(1);
modifiedScrollUp();
}}
/>
<PageLink linkText="..." unclickable={true} />
</>
)}
{(!paginationOptions.includes(1)
? paginationOptions.slice(2)
: paginationOptions
).map((page) => {
return (
<PageLink
key={page}
active={page === currentPage}
linkText={page}
pageChangeHandler={() => {
onPageChange(page);
modifiedScrollUp();
}}
/>
);
})}
<PageLink
linkText=""
pageChangeHandler={() => {
onPageChange(Math.min(currentPage + 1, totalPages));
modifiedScrollUp();
}}
/>
</div>
);
};

View File

@ -3,7 +3,7 @@ import React, { FC } from "react";
type Column = {
header: string;
key: string;
width?: number;
width?: number | string;
};
type TableData = {
@ -25,10 +25,10 @@ export const BasicTable: FC<BasicTableProps> = ({ columns, data }) => {
<th
key={index}
className={
(column.width ? `w-${column.width} ` : "") +
"px-4 py-2 font-bold" +
(index === 0 ? " rounded-tl-sm" : "") +
(index === columns.length - 1 ? " rounded-tr-sm" : "") +
(column.width ? ` w-${column.width}` : "")
(index === columns.length - 1 ? " rounded-tr-sm" : "")
}
>
{column.header}
@ -44,8 +44,8 @@ export const BasicTable: FC<BasicTableProps> = ({ columns, data }) => {
<td
key={colIndex}
className={
"py-2 px-4 border-b border-gray-800" +
(column.width ? ` w-${column.width}` : "")
(column.width ? `w-${column.width} ` : "") +
"py-2 px-4 border-b border-gray-800"
}
>
{row[column.key]}

View File

@ -14,7 +14,10 @@ import {
X,
Question,
Users,
ThumbsUp,
ThumbsDown,
} from "@phosphor-icons/react";
import { FiCheck, FiEdit, FiThumbsDown, FiThumbsUp } from "react-icons/fi";
import { SiBookstack } from "react-icons/si";
import { FaFile, FaGlobe } from "react-icons/fa";
import Image from "next/image";
@ -122,11 +125,11 @@ export const BrainIcon = ({
return <Brain size={size} className={className} />;
};
export const PencilIcon = ({
export const EditIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <PencilSimple size={size} className={className} />;
return <FiEdit size={size} className={className} />;
};
export const XIcon = ({
@ -136,6 +139,27 @@ export const XIcon = ({
return <X size={size} className={className} />;
};
export const ThumbsUpIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiThumbsUp size={size} className={className} />;
};
export const ThumbsDownIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiThumbsDown size={size} className={className} />;
};
export const CheckmarkIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiCheck size={size} className={className} />;
};
//
// COMPANY LOGOS
//

View File

@ -0,0 +1,57 @@
import { DanswerDocument } from "@/lib/search/interfaces";
import { DocumentFeedbackBlock } from "./DocumentFeedbackBlock";
import { getSourceIcon } from "../source";
import { useState } from "react";
import { usePopup } from "../admin/connectors/Popup";
interface DocumentDisplayProps {
document: DanswerDocument;
queryEventId: number | null;
}
export const DocumentDisplay = ({
document,
queryEventId,
}: DocumentDisplayProps) => {
const { popup, setPopup } = usePopup();
const [isHovered, setIsHovered] = useState(false);
return (
<div
key={document.semantic_identifier}
className="text-sm border-b border-gray-800 mb-3"
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => setIsHovered(false)}
>
{popup}
<div className="flex">
<a
className={
"rounded-lg flex font-bold " +
(document.link ? "" : "pointer-events-none")
}
href={document.link}
target="_blank"
rel="noopener noreferrer"
>
{getSourceIcon(document.source_type, 20)}
<p className="truncate break-all ml-2">
{document.semantic_identifier || document.document_id}
</p>
</a>
<div className="ml-auto">
{isHovered && queryEventId && (
<DocumentFeedbackBlock
documentId={document.document_id}
queryId={queryEventId}
setPopup={setPopup}
/>
)}
</div>
</div>
<p className="pl-1 py-3 text-gray-200">{document.blurb}</p>
</div>
);
};

View File

@ -0,0 +1,123 @@
import { PopupSpec } from "../admin/connectors/Popup";
import { ThumbsDownIcon, ThumbsUpIcon } from "../icons/icons";
type DocumentFeedbackType = "endorse" | "reject" | "hide" | "unhide";
const giveDocumentFeedback = async (
documentId: string,
queryId: number,
searchFeedback: DocumentFeedbackType
): Promise<string | null> => {
const response = await fetch("/api/doc-retrieval-feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query_id: queryId,
search_feedback: searchFeedback,
click: false,
document_rank: 0,
document_id: documentId,
}),
});
return response.ok
? null
: response.statusText || (await response.json()).message;
};
interface DocumentFeedbackIconProps {
documentId: string;
queryId: number;
setPopup: (popupSpec: PopupSpec | null) => void;
feedbackType: DocumentFeedbackType;
}
const DocumentFeedback = ({
documentId,
queryId,
setPopup,
feedbackType,
}: DocumentFeedbackIconProps) => {
let icon = null;
const size = 16;
if (feedbackType === "endorse") {
icon = (
<ThumbsUpIcon
size={size}
className="my-auto flex flex-shrink-0 text-green-600"
/>
);
}
if (feedbackType === "reject") {
icon = (
<ThumbsDownIcon
size={size}
className="my-auto flex flex-shrink-0 text-red-700"
/>
);
}
if (!icon) {
// TODO: support other types of feedback
return null;
}
return (
<div
onClick={async () => {
console.log("HI");
const errorMsg = await giveDocumentFeedback(
documentId,
queryId,
feedbackType
);
console.log(errorMsg);
if (!errorMsg) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
} else {
setPopup({
message: `Error giving feedback - ${errorMsg}`,
type: "error",
});
}
}}
className="cursor-pointer"
>
{icon}
</div>
);
};
interface DocumentFeedbackBlockProps {
documentId: string;
queryId: number;
setPopup: (popupSpec: PopupSpec | null) => void;
}
export const DocumentFeedbackBlock = ({
documentId,
queryId,
setPopup,
}: DocumentFeedbackBlockProps) => {
return (
<div className="flex">
<DocumentFeedback
documentId={documentId}
queryId={queryId}
setPopup={setPopup}
feedbackType="endorse"
/>
<div className="ml-2">
<DocumentFeedback
documentId={documentId}
queryId={queryId}
setPopup={setPopup}
feedbackType="reject"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,92 @@
import { useState } from "react";
import { PopupSpec, usePopup } from "../admin/connectors/Popup";
import { ThumbsDownIcon, ThumbsUpIcon } from "../icons/icons";
type Feedback = "like" | "dislike";
const giveFeedback = async (
queryId: number,
feedback: Feedback
): Promise<boolean> => {
const response = await fetch("/api/query-feedback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query_id: queryId,
feedback,
}),
});
return response.ok;
};
interface QAFeedbackIconProps {
queryId: number;
setPopup: (popupSpec: PopupSpec | null) => void;
feedbackType: Feedback;
}
const QAFeedback = ({
queryId,
setPopup,
feedbackType,
}: QAFeedbackIconProps) => {
const [isHovered, setIsHovered] = useState(false);
const size = isHovered ? 22 : 20;
const paddingY = isHovered ? "" : "py-0.5 ";
return (
<div
onClick={async () => {
const isSuccessful = await giveFeedback(queryId, feedbackType);
if (isSuccessful) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
}
}}
onMouseEnter={() => {
setIsHovered(true);
}}
onMouseLeave={() => setIsHovered(false)}
className={"cursor-pointer " + paddingY}
>
{feedbackType === "like" ? (
<ThumbsUpIcon
size={size}
className="my-auto flex flex-shrink-0 text-green-600"
/>
) : (
<ThumbsDownIcon
size={size}
className="my-auto flex flex-shrink-0 text-red-700"
/>
)}
</div>
);
};
interface QAFeedbackBlockProps {
queryId: number;
}
export const QAFeedbackBlock = ({ queryId }: QAFeedbackBlockProps) => {
const { popup, setPopup } = usePopup();
return (
<div className="flex">
{popup}
<QAFeedback queryId={queryId} setPopup={setPopup} feedbackType="like" />
<div className="ml-2">
<QAFeedback
queryId={queryId}
setPopup={setPopup}
feedbackType="dislike"
/>
</div>
</div>
);
};

View File

@ -9,6 +9,8 @@ import {
FlowType,
SearchDefaultOverrides,
} from "@/lib/search/interfaces";
import { QAFeedbackBlock } from "./QAFeedback";
import { DocumentDisplay } from "./DocumentDisplay";
const removeDuplicateDocs = (documents: DanswerDocument[]) => {
const seen = new Set<string>();
@ -37,7 +39,7 @@ export const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
return null;
}
const { answer, quotes, documents, error } = searchResponse;
const { answer, quotes, documents, error, queryEventId } = searchResponse;
if (isFetching && !answer && !documents) {
return (
@ -132,6 +134,10 @@ export const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
</div>
</div>
)}
<div className="ml-auto mt-auto">
<QAFeedbackBlock queryId={1} />
</div>
</div>
)}
</>
@ -145,27 +151,12 @@ export const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
<div className="font-bold border-b mb-4 pb-1 border-gray-800">
Results
</div>
{removeDuplicateDocs(documents).map((doc) => (
<div
key={doc.semantic_identifier}
className="text-sm border-b border-gray-800 mb-3"
>
<a
className={
"rounded-lg flex font-bold " +
(doc.link ? "" : "pointer-events-none")
}
href={doc.link}
target="_blank"
rel="noopener noreferrer"
>
{getSourceIcon(doc.source_type, 20)}
<p className="truncate break-all ml-2">
{doc.semantic_identifier || doc.document_id}
</p>
</a>
<p className="pl-1 py-3 text-gray-200">{doc.blurb}</p>
</div>
{removeDuplicateDocs(documents).map((document) => (
<DocumentDisplay
key={document.document_id}
document={document}
queryEventId={queryEventId}
/>
))}
</div>
)}

View File

@ -65,6 +65,7 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
suggestedSearchType: null,
suggestedFlowType: null,
error: null,
queryEventId: null,
};
const updateCurrentAnswer = (answer: string) =>
setSearchResponse((prevState) => ({
@ -96,6 +97,11 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
...(prevState || initialSearchResponse),
error,
}));
const updateQueryEventId = (queryEventId: number) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
queryEventId,
}));
let lastSearchCancellationToken = useRef<CancellationToken | null>(null);
const onSearch = async ({
@ -141,6 +147,10 @@ export const SearchSection: React.FC<SearchSectionProps> = ({
cancellationToken: lastSearchCancellationToken.current,
fn: updateError,
}),
updateQueryEventId: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateQueryEventId,
}),
selectedSearchType: searchType ?? selectedSearchType,
offset: offset ?? defaultOverrides.offset,
});

View File

@ -1,5 +1,5 @@
import { Credential } from "@/lib/types";
import useSWR, { useSWRConfig } from "swr";
import { Credential, DocumentBoostStatus } from "@/lib/types";
import useSWR, { mutate, useSWRConfig } from "swr";
import { fetcher } from "./fetcher";
const CREDENTIAL_URL = "/api/manage/admin/credential";
@ -13,3 +13,22 @@ export const usePublicCredentials = () => {
refreshCredentials: () => mutate(CREDENTIAL_URL),
};
};
const MOST_REACTED_DOCS_URL = "/api/manage/doc-boosts";
const buildReactedDocsUrl = (ascending: boolean, limit: number) => {
return `/api/manage/admin/doc-boosts?ascending=${ascending}&limit=${limit}`;
};
export const useMostReactedToDocuments = (
ascending: boolean,
limit: number
) => {
const url = buildReactedDocsUrl(ascending, limit);
const swrResponse = useSWR<DocumentBoostStatus[]>(url, fetcher);
return {
...swrResponse,
refreshDocs: () => mutate(url),
};
};

View File

@ -36,6 +36,7 @@ export interface SearchResponse {
quotes: Quote[] | null;
documents: DanswerDocument[] | null;
error: string | null;
queryEventId: number | null;
}
export interface Source {
@ -57,6 +58,7 @@ export interface SearchRequestArgs {
updateSuggestedSearchType: (searchType: SearchType) => void;
updateSuggestedFlowType: (flowType: FlowType) => void;
updateError: (error: string) => void;
updateQueryEventId: (queryEventID: number) => void;
selectedSearchType: SearchType | null;
offset: number | null;
}

View File

@ -60,6 +60,7 @@ export const searchRequestStreamed = async ({
updateSuggestedSearchType,
updateSuggestedFlowType,
updateError,
updateQueryEventId,
selectedSearchType,
offset,
}: SearchRequestArgs) => {
@ -174,6 +175,12 @@ export const searchRequestStreamed = async ({
return;
}
// check for query ID section
if (chunk.query_event_id) {
updateQueryEventId(chunk.query_event_id);
return;
}
// should never reach this
console.log("Unknown chunk:", chunk);
});

View File

@ -29,6 +29,14 @@ export type ValidStatuses =
| "in_progress"
| "not_started";
export interface DocumentBoostStatus {
document_id: string;
semantic_id: string;
link: string;
boost: number;
hidden: boolean;
}
// CONNECTORS
export interface ConnectorBase<T> {
name: string;