Support more Slack Config Options (#494)

---------

Co-authored-by: Weves <chrisweaver101@gmail.com>
This commit is contained in:
Yuhong Sun
2023-10-03 14:55:29 -07:00
committed by GitHub
parent c2721c7889
commit 59bac1ca8f
14 changed files with 399 additions and 91 deletions

View File

@@ -71,6 +71,20 @@ def build_doc_feedback_block(
)
def get_restate_blocks(
msg: str,
is_bot_msg: bool,
) -> list[Block]:
# Only the slash command needs this context because the user doesnt see their own input
if not is_bot_msg:
return []
return [
HeaderBlock(text="Responding to the Query"),
SectionBlock(text=f"```{msg}```"),
]
def build_documents_blocks(
documents: list[SearchDoc],
query_event_id: int,

View File

@@ -4,6 +4,13 @@ from danswer.db.models import SlackBotConfig
from danswer.db.slack_bot_config import fetch_slack_bot_configs
VALID_SLACK_FILTERS = [
"answerable_prefilter",
"well_answered_postfilter",
"questionmark_prefilter",
]
def get_slack_bot_config_for_channel(
channel_name: str, db_session: Session
) -> SlackBotConfig | None:

View File

@@ -6,7 +6,9 @@ from sqlalchemy.orm import Session
from danswer.bots.slack.blocks import build_documents_blocks
from danswer.bots.slack.blocks import build_qa_response_blocks
from danswer.bots.slack.blocks import get_restate_blocks
from danswer.bots.slack.config import get_slack_bot_config_for_channel
from danswer.bots.slack.utils import fetch_userids_from_emails
from danswer.bots.slack.utils import get_channel_name_from_id
from danswer.bots.slack.utils import respond_in_thread
from danswer.configs.app_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
@@ -25,9 +27,12 @@ from danswer.server.models import QuestionRequest
def handle_message(
msg: str,
channel: str,
message_ts_to_respond_to: str,
message_ts_to_respond_to: str | None,
sender_id: str | None,
client: WebClient,
logger: logging.Logger,
skip_filters: bool = False,
is_bot_msg: bool = False,
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,
@@ -40,20 +45,60 @@ def handle_message(
channel_name=channel_name, db_session=db_session
)
document_set_names: list[str] | None = None
validity_check_enabled = ENABLE_DANSWERBOT_REFLEXION
if slack_bot_config and slack_bot_config.persona:
document_set_names = [
document_set.name
for document_set in slack_bot_config.persona.document_sets
]
validity_check_enabled = slack_bot_config.channel_config.get(
"answer_validity_check_enabled", validity_check_enabled
)
reflexion = ENABLE_DANSWERBOT_REFLEXION
# List of user id to send message to, if None, send to everyone in channel
send_to: list[str] | None = None
respond_sender_only = False
respond_team_member_list = None
if slack_bot_config and slack_bot_config.channel_config:
channel_conf = slack_bot_config.channel_config
if not skip_filters and "answer_filters" in channel_conf:
reflexion = "well_answered_postfilter" in channel_conf["answer_filters"]
if (
"questionmark_prefilter" in channel_conf["answer_filters"]
and "?" not in msg
):
logger.info(
"Skipping message since it does not contain a question mark"
)
return
logger.info(
"Found slack bot config for channel. Restricting bot to use document "
f"sets: {document_set_names}, validity check enabled: {validity_check_enabled}"
f"sets: {document_set_names}, "
f"validity checks enabled: {channel_conf['answer_filters']}"
)
respond_sender_only = channel_conf.get("respond_sender_only") or False
respond_team_member_list = (
channel_conf.get("respond_team_member_list") or None
)
if sender_id and (respond_sender_only or is_bot_msg):
send_to = [sender_id]
elif respond_team_member_list:
send_to = fetch_userids_from_emails(respond_team_member_list, client)
# If configured to respond to team members only, then cannot be used with a /danswerbot command
# which would just respond to the sender
if respond_team_member_list and is_bot_msg:
if sender_id:
respond_in_thread(
client=client,
channel=channel,
receiver_ids=[sender_id],
text="The DanswerBot slash command is not enabled for this channel",
thread_ts=None,
)
@retry(
tries=num_retries,
delay=0.25,
@@ -70,7 +115,7 @@ def handle_message(
db_session=db_session,
answer_generation_timeout=answer_generation_timeout,
real_time_flow=False,
enable_reflexion=validity_check_enabled,
enable_reflexion=reflexion,
)
if not answer.error_msg:
return answer
@@ -100,6 +145,7 @@ def handle_message(
respond_in_thread(
client=client,
channel=channel,
receiver_ids=None,
text=f"Encountered exception when trying to answer: \n\n```{e}```",
thread_ts=message_ts_to_respond_to,
)
@@ -121,6 +167,7 @@ def handle_message(
respond_in_thread(
client=client,
channel=channel,
receiver_ids=None,
text="Found no documents when trying to answer. Did you index any documents?",
thread_ts=message_ts_to_respond_to,
)
@@ -134,6 +181,10 @@ def handle_message(
return
# convert raw response into "nicely" formatted Slack message
# If called with the DanswerBot slash command, the question is lost so we have to reshow it
restate_question_block = get_restate_blocks(msg, is_bot_msg)
answer_blocks = build_qa_response_blocks(
query_event_id=answer.query_event_id,
answer=answer.answer,
@@ -148,12 +199,33 @@ def handle_message(
respond_in_thread(
client=client,
channel=channel,
blocks=answer_blocks + document_blocks,
receiver_ids=send_to,
blocks=restate_question_block + answer_blocks + document_blocks,
thread_ts=message_ts_to_respond_to,
# don't unfurl, since otherwise we will have 5+ previews which makes the message very long
unfurl=False,
)
# For DM (ephemeral message), we need to create a thread via a normal message so the user can see
# the ephemeral message. This also will give the user a notification which ephemeral message does not.
if respond_sender_only:
respond_in_thread(
client=client,
channel=channel,
text="We've just DM-ed you the answer, hope you find it useful! 💃",
thread_ts=message_ts_to_respond_to,
)
elif respond_team_member_list:
respond_in_thread(
client=client,
channel=channel,
text=(
"👋 Hi, we've just gathered and forwarded the relevant "
+ "information to the team. They'll get back to you shortly!"
),
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"

View File

@@ -1,4 +1,5 @@
import logging
import re
from collections.abc import MutableMapping
from typing import Any
from typing import cast
@@ -51,37 +52,39 @@ def _get_socket_client() -> SocketModeClient:
def _process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> None:
logger.info(f"Received Slack request of type: '{req.type}'")
if req.type == "events_api":
# Acknowledge the request immediately
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
# Verify channel is valid
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("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"]:
event = cast(dict[str, Any], req.payload.get("event", {}))
# Ensure that the message is a new message + of expected type
event_type = event.get("type")
if event_type not in ["app_mention", "message"]:
channel_specific_logger.info(
f"Ignoring message with subtype '{message_subtype}' since is is a special message type"
f"Ignoring non-message event of type '{event_type}' for channel '{channel}'"
)
return
# Don't insert Danswer thoughts if there's already a long conversation
# Or if a bunch of blocks already came through from responding to the @DanswerBot tag
if len(event.get("blocks", [])) > 10:
channel_specific_logger.debug(
"Ignoring message because thread is already long or has been answered to."
)
return
@@ -89,6 +92,16 @@ def _process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> No
channel_specific_logger.info("Ignoring message from bot")
return
# 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
message_subtype = event.get("subtype")
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
message_ts = event.get("ts")
thread_ts = event.get("thread_ts")
# Pick the root of the thread (if a thread exists)
@@ -99,25 +112,68 @@ def _process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> No
)
return
msg = cast(str | None, event.get("text"))
msg = cast(str, event.get("text", ""))
if not msg:
channel_specific_logger.error("Unable to process empty message")
return
tagged = event_type == "app_mention"
if tagged:
logger.info("User tagged DanswerBot")
msg = re.sub(r"<@\w+>\s", "", msg)
# 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,
sender_id=event.get("user") or None,
client=client.web_client,
skip_filters=tagged,
logger=cast(logging.Logger, channel_specific_logger),
)
channel_specific_logger.info(
f"Successfully processed message with ts: '{message_ts}'"
)
if req.type == "slash_commands":
# Acknowledge the request immediately
response = SocketModeResponse(envelope_id=req.envelope_id)
client.send_socket_mode_response(response)
# Verify that there's an associated channel
channel = req.payload.get("channel_id")
channel_specific_logger = _ChannelIdAdapter(
logger, extra={_CHANNEL_ID: channel}
)
if not channel:
channel_specific_logger.error(
"Received DanswerBot command without channel - skipping"
)
return
msg = req.payload.get("text", "")
sender = req.payload.get("user_id")
if not sender:
raise ValueError(
"Cannot respond to DanswerBot command without sender to respond to."
)
handle_message(
msg=msg,
channel=channel,
message_ts_to_respond_to=None,
sender_id=sender,
client=client.web_client,
skip_filters=True,
is_bot_msg=True,
logger=cast(logging.Logger, channel_specific_logger),
)
channel_specific_logger.info(
f"Successfully processed DanswerBot request in channel: {req.payload.get('channel_name')}"
)
# Handle button clicks
if req.type == "interactive" and req.payload.get("type") == "block_actions":
# Acknowledge the request immediately

View File

@@ -35,27 +35,47 @@ def get_web_client() -> WebClient:
def respond_in_thread(
client: WebClient,
channel: str,
thread_ts: str,
thread_ts: str | None,
text: str | None = None,
blocks: list[Block] | None = None,
receiver_ids: list[str] | None = None,
metadata: Metadata | None = None,
unfurl: bool = True,
) -> None:
if not text and not blocks:
raise ValueError("One of `text` or `blocks` must be provided")
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,
unfurl_links=unfurl,
unfurl_media=unfurl,
)
if not response.get("ok"):
raise RuntimeError(f"Unable to post message: {response}")
if not receiver_ids:
slack_call = make_slack_api_rate_limited(client.chat_postMessage)
else:
slack_call = make_slack_api_rate_limited(client.chat_postEphemeral)
if not receiver_ids:
response = slack_call(
channel=channel,
text=text,
blocks=blocks,
thread_ts=thread_ts,
metadata=metadata,
unfurl_links=unfurl,
unfurl_media=unfurl,
)
if not response.get("ok"):
raise RuntimeError(f"Failed to post message: {response}")
else:
for receiver in receiver_ids:
response = slack_call(
channel=channel,
user=receiver,
text=text,
blocks=blocks,
thread_ts=thread_ts,
metadata=metadata,
unfurl_links=unfurl,
unfurl_media=unfurl,
)
if not response.get("ok"):
raise RuntimeError(f"Failed to post message: {response}")
def build_feedback_block_id(
@@ -136,3 +156,21 @@ def get_channel_from_id(client: WebClient, channel_id: str) -> dict[str, Any]:
def get_channel_name_from_id(client: WebClient, channel_id: str) -> str:
return get_channel_from_id(client, channel_id)["name"]
def fetch_userids_from_emails(user_emails: list[str], client: WebClient) -> list[str]:
user_ids: list[str] = []
for email in user_emails:
try:
user = client.users_lookupByEmail(email=email)
user_ids.append(user.data["user"]["id"]) # type: ignore
except Exception:
logger.error(f"Was not able to find slack user by email: {email}")
if not user_ids:
raise RuntimeError(
"Was not able to find any Slack users to respond to. "
"No email was parsed into a valid slack account."
)
return user_ids

View File

@@ -232,6 +232,7 @@ DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER = os.environ.get(
).lower() not in ["false", ""]
# Add a second LLM call post Answer to verify if the Answer is valid
# Throws out answers that don't directly or fully answer the user query
# This is the default for all DanswerBot channels unless the bot is configured individually
ENABLE_DANSWERBOT_REFLEXION = (
os.environ.get("ENABLE_DANSWERBOT_REFLEXION", "").lower() == "true"
)

View File

@@ -482,8 +482,9 @@ class ChannelConfig(TypedDict):
in Postgres"""
channel_names: list[str]
answer_validity_check_enabled: NotRequired[bool] # not specified => False
team_members: NotRequired[list[str]]
respond_sender_only: NotRequired[bool] # defaults to False
respond_team_member_list: NotRequired[list[str]]
answer_filters: NotRequired[list[str]]
class SlackBotConfig(Base):

View File

@@ -6,9 +6,11 @@ from typing import TypeVar
from uuid import UUID
from pydantic import BaseModel
from pydantic import validator
from pydantic.generics import GenericModel
from danswer.auth.schemas import UserRole
from danswer.bots.slack.config import VALID_SLACK_FILTERS
from danswer.configs.app_configs import MASK_CREDENTIAL_PREFIX
from danswer.configs.constants import AuthType
from danswer.configs.constants import DocumentSource
@@ -434,7 +436,18 @@ class SlackBotConfigCreationRequest(BaseModel):
# for now for simplicity / speed of development
document_sets: list[int]
channel_names: list[str]
answer_validity_check_enabled: bool
# If not responder_sender_only and no team members, assume respond in the channel to everyone
respond_sender_only: bool = False
respond_team_member_list: list[str] = []
answer_filters: list[str] = []
@validator("answer_filters", pre=True)
def validate_filters(cls, value: list[str]) -> list[str]:
if any(test not in VALID_SLACK_FILTERS for test in value):
raise ValueError(
f"Slack Answer filters must be one of {VALID_SLACK_FILTERS}"
)
return value
class SlackBotConfig(BaseModel):

View File

@@ -23,13 +23,19 @@ from danswer.server.models import SlackBotTokens
router = APIRouter(prefix="/manage")
@router.post("/admin/slack-bot/config")
def create_slack_bot_config(
def _form_channel_config(
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> SlackBotConfig:
if not slack_bot_config_creation_request.channel_names:
current_slack_bot_config_id: int | None,
db_session: Session,
) -> ChannelConfig:
raw_channel_names = slack_bot_config_creation_request.channel_names
respond_sender_only = slack_bot_config_creation_request.respond_sender_only
respond_team_member_list = (
slack_bot_config_creation_request.respond_team_member_list
)
answer_filters = slack_bot_config_creation_request.answer_filters
if not raw_channel_names:
raise HTTPException(
status_code=400,
detail="Must provide at least one channel name",
@@ -37,8 +43,8 @@ def create_slack_bot_config(
try:
cleaned_channel_names = validate_channel_names(
channel_names=slack_bot_config_creation_request.channel_names,
current_slack_bot_config_id=None,
channel_names=raw_channel_names,
current_slack_bot_config_id=current_slack_bot_config_id,
db_session=db_session,
)
except ValueError as e:
@@ -47,10 +53,35 @@ def create_slack_bot_config(
detail=str(e),
)
if respond_sender_only and respond_team_member_list:
raise ValueError(
"Cannot set DanswerBot to only respond to sender and "
"also respond to a predetermined set of users. This is not logically possible..."
)
channel_config: ChannelConfig = {
"channel_names": cleaned_channel_names,
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
}
if respond_sender_only is not None:
channel_config["respond_sender_only"] = respond_sender_only
if respond_team_member_list:
channel_config["respond_team_member_list"] = respond_team_member_list
if answer_filters:
channel_config["answer_filters"] = answer_filters
return channel_config
@router.post("/admin/slack-bot/config")
def create_slack_bot_config(
slack_bot_config_creation_request: SlackBotConfigCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> SlackBotConfig:
channel_config = _form_channel_config(
slack_bot_config_creation_request, None, db_session
)
slack_bot_config_model = insert_slack_bot_config(
document_sets=slack_bot_config_creation_request.document_sets,
channel_config=channel_config,
@@ -75,31 +106,14 @@ def patch_slack_bot_config(
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> SlackBotConfig:
if not slack_bot_config_creation_request.channel_names:
raise HTTPException(
status_code=400,
detail="Must provide at least one channel name",
)
try:
cleaned_channel_names = validate_channel_names(
channel_names=slack_bot_config_creation_request.channel_names,
current_slack_bot_config_id=slack_bot_config_id,
db_session=db_session,
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e),
)
channel_config = _form_channel_config(
slack_bot_config_creation_request, slack_bot_config_id, db_session
)
slack_bot_config_model = update_slack_bot_config(
slack_bot_config_id=slack_bot_config_id,
document_sets=slack_bot_config_creation_request.document_sets,
channel_config={
"channel_names": cleaned_channel_names,
"answer_validity_check_enabled": slack_bot_config_creation_request.answer_validity_check_enabled,
},
channel_config=channel_config,
db_session=db_session,
)
return SlackBotConfig(