mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-03-26 17:51:54 +01:00
Fe for feedback (#346)
This commit is contained in:
parent
856061c7ea
commit
038f646c09
@ -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
|
||||
|
155
backend/danswer/bots/slack/blocks.py
Normal file
155
backend/danswer/bots/slack/blocks.py
Normal 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)]
|
||||
)
|
2
backend/danswer/bots/slack/constants.py
Normal file
2
backend/danswer/bots/slack/constants.py
Normal file
@ -0,0 +1,2 @@
|
||||
LIKE_BLOCK_ACTION_ID = "feedback-like"
|
||||
DISLIKE_BLOCK_ACTION_ID = "feedback-dislike"
|
16
backend/danswer/bots/slack/handlers/handle_feedback.py
Normal file
16
backend/danswer/bots/slack/handlers/handle_feedback.py
Normal 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,
|
||||
)
|
115
backend/danswer/bots/slack/handlers/handle_message.py
Normal file
115
backend/danswer/bots/slack/handlers/handle_message.py
Normal 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
|
184
backend/danswer/bots/slack/listener.py
Normal file
184
backend/danswer/bots/slack/listener.py
Normal 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()
|
58
backend/danswer/bots/slack/utils.py
Normal file
58
backend/danswer/bots/slack/utils.py
Normal 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])
|
@ -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,
|
||||
|
@ -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()
|
@ -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 = [
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
239
web/src/app/admin/documents/feedback/page.tsx
Normal file
239
web/src/app/admin/documents/feedback/page.tsx
Normal 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;
|
@ -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">
|
||||
|
149
web/src/components/PageSelector.tsx
Normal file
149
web/src/components/PageSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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]}
|
||||
|
@ -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
|
||||
//
|
||||
|
57
web/src/components/search/DocumentDisplay.tsx
Normal file
57
web/src/components/search/DocumentDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
123
web/src/components/search/DocumentFeedbackBlock.tsx
Normal file
123
web/src/components/search/DocumentFeedbackBlock.tsx
Normal 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>
|
||||
);
|
||||
};
|
92
web/src/components/search/QAFeedback.tsx
Normal file
92
web/src/components/search/QAFeedback.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
)}
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user