From 974f85da66811c04da3fdeab39b8a5fe3867ea9d Mon Sep 17 00:00:00 2001 From: hj-danswer Date: Fri, 13 Sep 2024 18:57:03 -0700 Subject: [PATCH] Migrate standard answers implementations to ee/ (#2378) * Migrate standard answers implementations to ee/ * renaming * Clean up slackbot non-ee standard answers import * Move backend api/manage/standard_answer route to ee * Move standard answers web UI to ee * Hide standard answer controls in bot edit page * Kwargs for fetch_versioned_implementation * Add docstring explaining return types for handle_standard_answers * Consolidate blocks into ee/handle_standard_answers --------- Co-authored-by: Hyeong Joon Suh Co-authored-by: danswer-trial --- backend/danswer/danswerbot/slack/blocks.py | 17 -- .../slack/handlers/handle_standard_answers.py | 226 +++-------------- backend/danswer/danswerbot/slack/listener.py | 3 + backend/danswer/db/standard_answer.py | 66 ----- backend/danswer/main.py | 2 - .../danswerbot/slack/handlers/__init__.py | 0 .../slack/handlers/handle_standard_answers.py | 238 ++++++++++++++++++ backend/ee/danswer/db/standard_answer.py | 76 ++++++ backend/ee/danswer/main.py | 2 + .../danswer/server/manage/standard_answer.py | 0 .../server/query_and_chat/query_backend.py | 6 +- .../admin/bot/SlackBotConfigCreationForm.tsx | 53 ++-- web/src/app/admin/bot/[id]/page.tsx | 27 +- web/src/app/admin/bot/new/page.tsx | 24 +- .../StandardAnswerCreationForm.tsx | 0 .../admin/standard-answer/[id]/page.tsx | 2 +- .../{ => ee}/admin/standard-answer/hooks.ts | 0 .../app/{ => ee}/admin/standard-answer/lib.ts | 0 .../admin/standard-answer/new/page.tsx | 2 +- .../{ => ee}/admin/standard-answer/page.tsx | 0 web/src/components/admin/ClientLayout.tsx | 22 +- .../StandardAnswerCategoryDropdown.tsx | 71 ++++++ .../getStandardAnswerCategoriesIfEE.tsx | 47 ++++ web/src/middleware.ts | 1 + 24 files changed, 514 insertions(+), 371 deletions(-) create mode 100644 backend/ee/danswer/danswerbot/slack/handlers/__init__.py create mode 100644 backend/ee/danswer/danswerbot/slack/handlers/handle_standard_answers.py create mode 100644 backend/ee/danswer/db/standard_answer.py rename backend/{ => ee}/danswer/server/manage/standard_answer.py (100%) rename web/src/app/{ => ee}/admin/standard-answer/StandardAnswerCreationForm.tsx (100%) rename web/src/app/{ => ee}/admin/standard-answer/[id]/page.tsx (95%) rename web/src/app/{ => ee}/admin/standard-answer/hooks.ts (100%) rename web/src/app/{ => ee}/admin/standard-answer/lib.ts (100%) rename web/src/app/{ => ee}/admin/standard-answer/new/page.tsx (92%) rename web/src/app/{ => ee}/admin/standard-answer/page.tsx (100%) create mode 100644 web/src/components/standardAnswers/StandardAnswerCategoryDropdown.tsx create mode 100644 web/src/components/standardAnswers/getStandardAnswerCategoriesIfEE.tsx diff --git a/backend/danswer/danswerbot/slack/blocks.py b/backend/danswer/danswerbot/slack/blocks.py index da4a867e2339..4107a3815548 100644 --- a/backend/danswer/danswerbot/slack/blocks.py +++ b/backend/danswer/danswerbot/slack/blocks.py @@ -25,7 +25,6 @@ from danswer.danswerbot.slack.constants import DISLIKE_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID from danswer.danswerbot.slack.constants import FOLLOWUP_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import FOLLOWUP_BUTTON_RESOLVED_ACTION_ID -from danswer.danswerbot.slack.constants import GENERATE_ANSWER_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import IMMEDIATE_RESOLVED_BUTTON_ACTION_ID from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID from danswer.danswerbot.slack.icons import source_to_github_img_link @@ -360,22 +359,6 @@ def build_quotes_block( return [SectionBlock(text="*Relevant Snippets*\n" + "\n".join(quote_lines))] -def build_standard_answer_blocks( - answer_message: str, -) -> list[Block]: - generate_button_block = ButtonElement( - action_id=GENERATE_ANSWER_BUTTON_ACTION_ID, - text="Generate Full Answer", - ) - answer_block = SectionBlock(text=answer_message) - return [ - answer_block, - ActionsBlock( - elements=[generate_button_block], - ), - ] - - def build_qa_response_blocks( message_id: int | None, answer: str | None, diff --git a/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py b/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py index 29d4d30759c2..58a2101588d8 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py @@ -1,62 +1,16 @@ from slack_sdk import WebClient from sqlalchemy.orm import Session -from danswer.configs.constants import MessageType -from danswer.configs.danswerbot_configs import DANSWER_REACT_EMOJI -from danswer.danswerbot.slack.blocks import build_standard_answer_blocks -from danswer.danswerbot.slack.blocks import get_restate_blocks -from danswer.danswerbot.slack.handlers.utils import send_team_member_message from danswer.danswerbot.slack.models import SlackMessageInfo -from danswer.danswerbot.slack.utils import respond_in_thread -from danswer.danswerbot.slack.utils import update_emote_react -from danswer.db.chat import create_chat_session -from danswer.db.chat import create_new_chat_message -from danswer.db.chat import get_chat_messages_by_sessions -from danswer.db.chat import get_chat_sessions_by_slack_thread_id -from danswer.db.chat import get_or_create_root_message from danswer.db.models import Prompt from danswer.db.models import SlackBotConfig -from danswer.db.models import StandardAnswer as StandardAnswerModel -from danswer.db.standard_answer import fetch_standard_answer_categories_by_names -from danswer.db.standard_answer import find_matching_standard_answers -from danswer.server.manage.models import StandardAnswer as PydanticStandardAnswer from danswer.utils.logger import DanswerLoggingAdapter from danswer.utils.logger import setup_logger +from danswer.utils.variable_functionality import fetch_versioned_implementation logger = setup_logger() -def oneoff_standard_answers( - message: str, - slack_bot_categories: list[str], - db_session: Session, -) -> list[PydanticStandardAnswer]: - """ - Respond to the user message if it matches any configured standard answers. - - Returns a list of matching StandardAnswers if found, otherwise None. - """ - configured_standard_answers = { - standard_answer - for category in fetch_standard_answer_categories_by_names( - slack_bot_categories, db_session=db_session - ) - for standard_answer in category.standard_answers - } - - matching_standard_answers = find_matching_standard_answers( - query=message, - id_in=[answer.id for answer in configured_standard_answers], - db_session=db_session, - ) - - server_standard_answers = [ - PydanticStandardAnswer.from_model(standard_answer_model) - for (standard_answer_model, _) in matching_standard_answers - ] - return server_standard_answers - - def handle_standard_answers( message_info: SlackMessageInfo, receiver_ids: list[str] | None, @@ -65,154 +19,38 @@ def handle_standard_answers( logger: DanswerLoggingAdapter, client: WebClient, db_session: Session, +) -> bool: + """Returns whether one or more Standard Answer message blocks were + emitted by the Slack bot""" + versioned_handle_standard_answers = fetch_versioned_implementation( + "danswer.danswerbot.slack.handlers.handle_standard_answers", + "_handle_standard_answers", + ) + return versioned_handle_standard_answers( + message_info=message_info, + receiver_ids=receiver_ids, + slack_bot_config=slack_bot_config, + prompt=prompt, + logger=logger, + client=client, + db_session=db_session, + ) + + +def _handle_standard_answers( + message_info: SlackMessageInfo, + receiver_ids: list[str] | None, + slack_bot_config: SlackBotConfig | None, + prompt: Prompt | None, + logger: DanswerLoggingAdapter, + client: WebClient, + db_session: Session, ) -> bool: """ - Potentially respond to the user message depending on whether the user's message matches - any of the configured standard answers and also whether those answers have already been - provided in the current thread. + Standard Answers are a paid Enterprise Edition feature. This is the fallback + function handling the case where EE features are not enabled. - Returns True if standard answers are found to match the user's message and therefore, - we still need to respond to the users. + Always returns false i.e. since EE features are not enabled, we NEVER create any + Slack message blocks. """ - # if no channel config, then no standard answers are configured - if not slack_bot_config: - return False - - slack_thread_id = message_info.thread_to_respond - configured_standard_answer_categories = ( - slack_bot_config.standard_answer_categories if slack_bot_config else [] - ) - configured_standard_answers = set( - [ - standard_answer - for standard_answer_category in configured_standard_answer_categories - for standard_answer in standard_answer_category.standard_answers - ] - ) - query_msg = message_info.thread_messages[-1] - - if slack_thread_id is None: - used_standard_answer_ids = set([]) - else: - chat_sessions = get_chat_sessions_by_slack_thread_id( - slack_thread_id=slack_thread_id, - user_id=None, - db_session=db_session, - ) - chat_messages = get_chat_messages_by_sessions( - chat_session_ids=[chat_session.id for chat_session in chat_sessions], - user_id=None, - db_session=db_session, - skip_permission_check=True, - ) - used_standard_answer_ids = set( - [ - standard_answer.id - for chat_message in chat_messages - for standard_answer in chat_message.standard_answers - ] - ) - - usable_standard_answers = configured_standard_answers.difference( - used_standard_answer_ids - ) - - matching_standard_answers: list[tuple[StandardAnswerModel, str]] = [] - if usable_standard_answers: - matching_standard_answers = find_matching_standard_answers( - query=query_msg.message, - id_in=[standard_answer.id for standard_answer in usable_standard_answers], - db_session=db_session, - ) - - if matching_standard_answers: - chat_session = create_chat_session( - db_session=db_session, - description="", - user_id=None, - persona_id=slack_bot_config.persona.id if slack_bot_config.persona else 0, - danswerbot_flow=True, - slack_thread_id=slack_thread_id, - one_shot=True, - ) - - root_message = get_or_create_root_message( - chat_session_id=chat_session.id, db_session=db_session - ) - - new_user_message = create_new_chat_message( - chat_session_id=chat_session.id, - parent_message=root_message, - prompt_id=prompt.id if prompt else None, - message=query_msg.message, - token_count=0, - message_type=MessageType.USER, - db_session=db_session, - commit=True, - ) - - formatted_answers = [] - for standard_answer, match_str in matching_standard_answers: - since_you_mentioned_pretext = ( - f'Since your question contained "_{match_str}_"' - ) - block_quotified_answer = ">" + standard_answer.answer.replace("\n", "\n> ") - formatted_answer = f"{since_you_mentioned_pretext}, I thought this might be useful: \n\n{block_quotified_answer}" - formatted_answers.append(formatted_answer) - answer_message = "\n\n".join(formatted_answers) - - _ = create_new_chat_message( - chat_session_id=chat_session.id, - parent_message=new_user_message, - prompt_id=prompt.id if prompt else None, - message=answer_message, - token_count=0, - message_type=MessageType.ASSISTANT, - error=None, - db_session=db_session, - commit=True, - ) - - update_emote_react( - emoji=DANSWER_REACT_EMOJI, - channel=message_info.channel_to_respond, - message_ts=message_info.msg_to_respond, - remove=True, - client=client, - ) - - restate_question_blocks = get_restate_blocks( - msg=query_msg.message, - is_bot_msg=message_info.is_bot_msg, - ) - - answer_blocks = build_standard_answer_blocks( - answer_message=answer_message, - ) - - all_blocks = restate_question_blocks + answer_blocks - - try: - respond_in_thread( - client=client, - channel=message_info.channel_to_respond, - receiver_ids=receiver_ids, - text="Hello! Danswer has some results for you!", - blocks=all_blocks, - thread_ts=message_info.msg_to_respond, - unfurl=False, - ) - - if receiver_ids and slack_thread_id: - send_team_member_message( - client=client, - channel=message_info.channel_to_respond, - thread_ts=slack_thread_id, - ) - - return True - except Exception as e: - logger.exception(f"Unable to send standard answer message: {e}") - return False - else: - return False + return False diff --git a/backend/danswer/danswerbot/slack/listener.py b/backend/danswer/danswerbot/slack/listener.py index 63f8bcfcd9c7..c430f1b31b74 100644 --- a/backend/danswer/danswerbot/slack/listener.py +++ b/backend/danswer/danswerbot/slack/listener.py @@ -56,6 +56,7 @@ from danswer.one_shot_answer.models import ThreadMessage from danswer.search.retrieval.search_runner import download_nltk_data from danswer.server.manage.models import SlackBotTokens from danswer.utils.logger import setup_logger +from danswer.utils.variable_functionality import set_is_ee_based_on_env_variable from shared_configs.configs import MODEL_SERVER_HOST from shared_configs.configs import MODEL_SERVER_PORT from shared_configs.configs import SLACK_CHANNEL_ID @@ -481,6 +482,8 @@ if __name__ == "__main__": slack_bot_tokens: SlackBotTokens | None = None socket_client: SocketModeClient | None = None + set_is_ee_based_on_env_variable() + logger.notice("Verifying query preprocessing (NLTK) data is downloaded") download_nltk_data() diff --git a/backend/danswer/db/standard_answer.py b/backend/danswer/db/standard_answer.py index d04da95ebad3..d7f1346c3f99 100644 --- a/backend/danswer/db/standard_answer.py +++ b/backend/danswer/db/standard_answer.py @@ -1,5 +1,3 @@ -import re -import string from collections.abc import Sequence from sqlalchemy import select @@ -145,17 +143,6 @@ def fetch_standard_answer_category( ) -def fetch_standard_answer_categories_by_names( - standard_answer_category_names: list[str], - db_session: Session, -) -> Sequence[StandardAnswerCategory]: - return db_session.scalars( - select(StandardAnswerCategory).where( - StandardAnswerCategory.name.in_(standard_answer_category_names) - ) - ).all() - - def fetch_standard_answer_categories_by_ids( standard_answer_category_ids: list[int], db_session: Session, @@ -182,59 +169,6 @@ def fetch_standard_answer( ) -def find_matching_standard_answers( - id_in: list[int], - query: str, - db_session: Session, -) -> list[tuple[StandardAnswer, str]]: - """ - Returns a list of tuples, where each tuple is a StandardAnswer definition matching - the query and a string representing the match (either the regex match group or the - set of keywords). - - If `answer_instance.match_regex` is true, the definition is considered "matched" - if the query matches the `answer_instance.keyword` using `re.search`. - - Otherwise, the definition is considered "matched" if each space-delimited token - in `keyword` exists in `query`. - """ - stmt = ( - select(StandardAnswer) - .where(StandardAnswer.active.is_(True)) - .where(StandardAnswer.id.in_(id_in)) - ) - possible_standard_answers: Sequence[StandardAnswer] = db_session.scalars(stmt).all() - - matching_standard_answers: list[tuple[StandardAnswer, str]] = [] - for standard_answer in possible_standard_answers: - if standard_answer.match_regex: - maybe_matches = re.search(standard_answer.keyword, query, re.IGNORECASE) - if maybe_matches is not None: - match_group = maybe_matches.group(0) - matching_standard_answers.append((standard_answer, match_group)) - - else: - # Remove punctuation and split the keyword into individual words - keyword_words = "".join( - char - for char in standard_answer.keyword.lower() - if char not in string.punctuation - ).split() - - # Remove punctuation and split the query into individual words - query_words = "".join( - char for char in query.lower() if char not in string.punctuation - ).split() - - # Check if all of the keyword words are in the query words - if all(word in query_words for word in keyword_words): - matching_standard_answers.append( - (standard_answer, standard_answer.keyword) - ) - - return matching_standard_answers - - def fetch_standard_answers(db_session: Session) -> Sequence[StandardAnswer]: return db_session.scalars( select(StandardAnswer).where(StandardAnswer.active.is_(True)) diff --git a/backend/danswer/main.py b/backend/danswer/main.py index a00826f11c82..24360a71f115 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -102,7 +102,6 @@ from danswer.server.manage.llm.api import basic_router as llm_router from danswer.server.manage.llm.models import LLMProviderUpsertRequest from danswer.server.manage.search_settings import router as search_settings_router from danswer.server.manage.slack_bot import router as slack_bot_management_router -from danswer.server.manage.standard_answer import router as standard_answer_router from danswer.server.manage.users import router as user_router from danswer.server.middleware.latency_logging import add_latency_logging_middleware from danswer.server.query_and_chat.chat_backend import router as chat_router @@ -503,7 +502,6 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended( application, slack_bot_management_router ) - include_router_with_global_prefix_prepended(application, standard_answer_router) include_router_with_global_prefix_prepended(application, persona_router) include_router_with_global_prefix_prepended(application, admin_persona_router) include_router_with_global_prefix_prepended(application, input_prompt_router) diff --git a/backend/ee/danswer/danswerbot/slack/handlers/__init__.py b/backend/ee/danswer/danswerbot/slack/handlers/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/backend/ee/danswer/danswerbot/slack/handlers/handle_standard_answers.py b/backend/ee/danswer/danswerbot/slack/handlers/handle_standard_answers.py new file mode 100644 index 000000000000..e01d3cba266b --- /dev/null +++ b/backend/ee/danswer/danswerbot/slack/handlers/handle_standard_answers.py @@ -0,0 +1,238 @@ +from slack_sdk import WebClient +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 sqlalchemy.orm import Session + +from danswer.configs.constants import MessageType +from danswer.configs.danswerbot_configs import DANSWER_REACT_EMOJI +from danswer.danswerbot.slack.blocks import get_restate_blocks +from danswer.danswerbot.slack.constants import GENERATE_ANSWER_BUTTON_ACTION_ID +from danswer.danswerbot.slack.handlers.utils import send_team_member_message +from danswer.danswerbot.slack.models import SlackMessageInfo +from danswer.danswerbot.slack.utils import respond_in_thread +from danswer.danswerbot.slack.utils import update_emote_react +from danswer.db.chat import create_chat_session +from danswer.db.chat import create_new_chat_message +from danswer.db.chat import get_chat_messages_by_sessions +from danswer.db.chat import get_chat_sessions_by_slack_thread_id +from danswer.db.chat import get_or_create_root_message +from danswer.db.models import Prompt +from danswer.db.models import SlackBotConfig +from danswer.db.models import StandardAnswer as StandardAnswerModel +from danswer.server.manage.models import StandardAnswer as PydanticStandardAnswer +from danswer.utils.logger import DanswerLoggingAdapter +from danswer.utils.logger import setup_logger +from ee.danswer.db.standard_answer import fetch_standard_answer_categories_by_names +from ee.danswer.db.standard_answer import find_matching_standard_answers + +logger = setup_logger() + + +def build_standard_answer_blocks( + answer_message: str, +) -> list[Block]: + generate_button_block = ButtonElement( + action_id=GENERATE_ANSWER_BUTTON_ACTION_ID, + text="Generate Full Answer", + ) + answer_block = SectionBlock(text=answer_message) + return [ + answer_block, + ActionsBlock( + elements=[generate_button_block], + ), + ] + + +def oneoff_standard_answers( + message: str, + slack_bot_categories: list[str], + db_session: Session, +) -> list[PydanticStandardAnswer]: + """ + Respond to the user message if it matches any configured standard answers. + + Returns a list of matching StandardAnswers if found, otherwise None. + """ + configured_standard_answers = { + standard_answer + for category in fetch_standard_answer_categories_by_names( + slack_bot_categories, db_session=db_session + ) + for standard_answer in category.standard_answers + } + + matching_standard_answers = find_matching_standard_answers( + query=message, + id_in=[answer.id for answer in configured_standard_answers], + db_session=db_session, + ) + + server_standard_answers = [ + PydanticStandardAnswer.from_model(standard_answer_model) + for (standard_answer_model, _) in matching_standard_answers + ] + return server_standard_answers + + +def _handle_standard_answers( + message_info: SlackMessageInfo, + receiver_ids: list[str] | None, + slack_bot_config: SlackBotConfig | None, + prompt: Prompt | None, + logger: DanswerLoggingAdapter, + client: WebClient, + db_session: Session, +) -> bool: + """ + Potentially respond to the user message depending on whether the user's message matches + any of the configured standard answers and also whether those answers have already been + provided in the current thread. + + Returns True if standard answers are found to match the user's message and therefore, + we still need to respond to the users. + """ + # if no channel config, then no standard answers are configured + if not slack_bot_config: + return False + + slack_thread_id = message_info.thread_to_respond + configured_standard_answer_categories = ( + slack_bot_config.standard_answer_categories if slack_bot_config else [] + ) + configured_standard_answers = set( + [ + standard_answer + for standard_answer_category in configured_standard_answer_categories + for standard_answer in standard_answer_category.standard_answers + ] + ) + query_msg = message_info.thread_messages[-1] + + if slack_thread_id is None: + used_standard_answer_ids = set([]) + else: + chat_sessions = get_chat_sessions_by_slack_thread_id( + slack_thread_id=slack_thread_id, + user_id=None, + db_session=db_session, + ) + chat_messages = get_chat_messages_by_sessions( + chat_session_ids=[chat_session.id for chat_session in chat_sessions], + user_id=None, + db_session=db_session, + skip_permission_check=True, + ) + used_standard_answer_ids = set( + [ + standard_answer.id + for chat_message in chat_messages + for standard_answer in chat_message.standard_answers + ] + ) + + usable_standard_answers = configured_standard_answers.difference( + used_standard_answer_ids + ) + + matching_standard_answers: list[tuple[StandardAnswerModel, str]] = [] + if usable_standard_answers: + matching_standard_answers = find_matching_standard_answers( + query=query_msg.message, + id_in=[standard_answer.id for standard_answer in usable_standard_answers], + db_session=db_session, + ) + + if matching_standard_answers: + chat_session = create_chat_session( + db_session=db_session, + description="", + user_id=None, + persona_id=slack_bot_config.persona.id if slack_bot_config.persona else 0, + danswerbot_flow=True, + slack_thread_id=slack_thread_id, + one_shot=True, + ) + + root_message = get_or_create_root_message( + chat_session_id=chat_session.id, db_session=db_session + ) + + new_user_message = create_new_chat_message( + chat_session_id=chat_session.id, + parent_message=root_message, + prompt_id=prompt.id if prompt else None, + message=query_msg.message, + token_count=0, + message_type=MessageType.USER, + db_session=db_session, + commit=True, + ) + + formatted_answers = [] + for standard_answer, match_str in matching_standard_answers: + since_you_mentioned_pretext = ( + f'Since your question contained "_{match_str}_"' + ) + block_quotified_answer = ">" + standard_answer.answer.replace("\n", "\n> ") + formatted_answer = f"{since_you_mentioned_pretext}, I thought this might be useful: \n\n{block_quotified_answer}" + formatted_answers.append(formatted_answer) + answer_message = "\n\n".join(formatted_answers) + + _ = create_new_chat_message( + chat_session_id=chat_session.id, + parent_message=new_user_message, + prompt_id=prompt.id if prompt else None, + message=answer_message, + token_count=0, + message_type=MessageType.ASSISTANT, + error=None, + db_session=db_session, + commit=True, + ) + + update_emote_react( + emoji=DANSWER_REACT_EMOJI, + channel=message_info.channel_to_respond, + message_ts=message_info.msg_to_respond, + remove=True, + client=client, + ) + + restate_question_blocks = get_restate_blocks( + msg=query_msg.message, + is_bot_msg=message_info.is_bot_msg, + ) + + answer_blocks = build_standard_answer_blocks( + answer_message=answer_message, + ) + + all_blocks = restate_question_blocks + answer_blocks + + try: + respond_in_thread( + client=client, + channel=message_info.channel_to_respond, + receiver_ids=receiver_ids, + text="Hello! Danswer has some results for you!", + blocks=all_blocks, + thread_ts=message_info.msg_to_respond, + unfurl=False, + ) + + if receiver_ids and slack_thread_id: + send_team_member_message( + client=client, + channel=message_info.channel_to_respond, + thread_ts=slack_thread_id, + ) + + return True + except Exception as e: + logger.exception(f"Unable to send standard answer message: {e}") + return False + else: + return False diff --git a/backend/ee/danswer/db/standard_answer.py b/backend/ee/danswer/db/standard_answer.py new file mode 100644 index 000000000000..7b2bf431c597 --- /dev/null +++ b/backend/ee/danswer/db/standard_answer.py @@ -0,0 +1,76 @@ +import re +import string +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from danswer.db.models import StandardAnswer +from danswer.db.models import StandardAnswerCategory +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +def fetch_standard_answer_categories_by_names( + standard_answer_category_names: list[str], + db_session: Session, +) -> Sequence[StandardAnswerCategory]: + return db_session.scalars( + select(StandardAnswerCategory).where( + StandardAnswerCategory.name.in_(standard_answer_category_names) + ) + ).all() + + +def find_matching_standard_answers( + id_in: list[int], + query: str, + db_session: Session, +) -> list[tuple[StandardAnswer, str]]: + """ + Returns a list of tuples, where each tuple is a StandardAnswer definition matching + the query and a string representing the match (either the regex match group or the + set of keywords). + + If `answer_instance.match_regex` is true, the definition is considered "matched" + if the query matches the `answer_instance.keyword` using `re.search`. + + Otherwise, the definition is considered "matched" if each space-delimited token + in `keyword` exists in `query`. + """ + stmt = ( + select(StandardAnswer) + .where(StandardAnswer.active.is_(True)) + .where(StandardAnswer.id.in_(id_in)) + ) + possible_standard_answers: Sequence[StandardAnswer] = db_session.scalars(stmt).all() + + matching_standard_answers: list[tuple[StandardAnswer, str]] = [] + for standard_answer in possible_standard_answers: + if standard_answer.match_regex: + maybe_matches = re.search(standard_answer.keyword, query, re.IGNORECASE) + if maybe_matches is not None: + match_group = maybe_matches.group(0) + matching_standard_answers.append((standard_answer, match_group)) + + else: + # Remove punctuation and split the keyword into individual words + keyword_words = "".join( + char + for char in standard_answer.keyword.lower() + if char not in string.punctuation + ).split() + + # Remove punctuation and split the query into individual words + query_words = "".join( + char for char in query.lower() if char not in string.punctuation + ).split() + + # Check if all of the keyword words are in the query words + if all(word in query_words for word in keyword_words): + matching_standard_answers.append( + (standard_answer, standard_answer.keyword) + ) + + return matching_standard_answers diff --git a/backend/ee/danswer/main.py b/backend/ee/danswer/main.py index d7d1d6406a39..7d150107c756 100644 --- a/backend/ee/danswer/main.py +++ b/backend/ee/danswer/main.py @@ -23,6 +23,7 @@ from ee.danswer.server.enterprise_settings.api import ( from ee.danswer.server.enterprise_settings.api import ( basic_router as enterprise_settings_router, ) +from ee.danswer.server.manage.standard_answer import router as standard_answer_router from ee.danswer.server.query_and_chat.chat_backend import ( router as chat_router, ) @@ -86,6 +87,7 @@ def get_application() -> FastAPI: # EE only backend APIs include_router_with_global_prefix_prepended(application, query_router) include_router_with_global_prefix_prepended(application, chat_router) + include_router_with_global_prefix_prepended(application, standard_answer_router) # Enterprise-only global settings include_router_with_global_prefix_prepended( application, enterprise_settings_admin_router diff --git a/backend/danswer/server/manage/standard_answer.py b/backend/ee/danswer/server/manage/standard_answer.py similarity index 100% rename from backend/danswer/server/manage/standard_answer.py rename to backend/ee/danswer/server/manage/standard_answer.py diff --git a/backend/ee/danswer/server/query_and_chat/query_backend.py b/backend/ee/danswer/server/query_and_chat/query_backend.py index aef3648220e4..2213bfca61ff 100644 --- a/backend/ee/danswer/server/query_and_chat/query_backend.py +++ b/backend/ee/danswer/server/query_and_chat/query_backend.py @@ -6,9 +6,6 @@ from sqlalchemy.orm import Session from danswer.auth.users import current_user from danswer.configs.danswerbot_configs import DANSWER_BOT_TARGET_CHUNK_PERCENTAGE -from danswer.danswerbot.slack.handlers.handle_standard_answers import ( - oneoff_standard_answers, -) from danswer.db.engine import get_session from danswer.db.models import User from danswer.db.persona import get_persona_by_id @@ -29,6 +26,9 @@ from danswer.search.utils import dedupe_documents from danswer.search.utils import drop_llm_indices from danswer.search.utils import relevant_sections_to_indices from danswer.utils.logger import setup_logger +from ee.danswer.danswerbot.slack.handlers.handle_standard_answers import ( + oneoff_standard_answers, +) from ee.danswer.server.query_and_chat.models import DocumentSearchRequest from ee.danswer.server.query_and_chat.models import StandardAnswerRequest from ee.danswer.server.query_and_chat.models import StandardAnswerResponse diff --git a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx index 83577793b991..4f79c79936a6 100644 --- a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx +++ b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx @@ -3,11 +3,7 @@ import { ArrayHelpers, FieldArray, Form, Formik } from "formik"; import * as Yup from "yup"; import { usePopup } from "@/components/admin/connectors/Popup"; -import { - DocumentSet, - SlackBotConfig, - StandardAnswerCategory, -} from "@/lib/types"; +import { DocumentSet, SlackBotConfig } from "@/lib/types"; import { BooleanFormField, Label, @@ -28,16 +24,18 @@ import MultiSelectDropdown from "@/components/MultiSelectDropdown"; import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable"; import CollapsibleSection from "../assistants/CollapsibleSection"; +import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; +import { StandardAnswerCategoryDropdownField } from "@/components/standardAnswers/StandardAnswerCategoryDropdown"; export const SlackBotCreationForm = ({ documentSets, personas, - standardAnswerCategories, + standardAnswerCategoryResponse, existingSlackBotConfig, }: { documentSets: DocumentSet[]; personas: Persona[]; - standardAnswerCategories: StandardAnswerCategory[]; + standardAnswerCategoryResponse: StandardAnswerCategoryResponse; existingSlackBotConfig?: SlackBotConfig; }) => { const isUpdate = existingSlackBotConfig !== undefined; @@ -356,38 +354,15 @@ export const SlackBotCreationForm = ({ - -
- { - const selected_categories = selected_options.map( - (option) => { - return { - id: Number(option.value), - name: option.label, - }; - } - ); - setFieldValue( - "standard_answer_categories", - selected_categories - ); - }} - creatable={false} - options={standardAnswerCategories.map((category) => ({ - label: category.name, - value: category.id.toString(), - }))} - initialSelectedOptions={values.standard_answer_categories.map( - (category) => ({ - label: category.name, - value: category.id.toString(), - }) - )} - /> -
+ + setFieldValue("standard_answer_categories", categories) + } + /> )} diff --git a/web/src/app/admin/bot/[id]/page.tsx b/web/src/app/admin/bot/[id]/page.tsx index 035c5a0a510c..61fb6ee2e0d8 100644 --- a/web/src/app/admin/bot/[id]/page.tsx +++ b/web/src/app/admin/bot/[id]/page.tsx @@ -3,11 +3,7 @@ import { CPUIcon } from "@/components/icons/icons"; import { SlackBotCreationForm } from "../SlackBotConfigCreationForm"; import { fetchSS } from "@/lib/utilsSS"; import { ErrorCallout } from "@/components/ErrorCallout"; -import { - DocumentSet, - SlackBotConfig, - StandardAnswerCategory, -} from "@/lib/types"; +import { DocumentSet, SlackBotConfig } from "@/lib/types"; import { Text } from "@tremor/react"; import { BackButton } from "@/components/BackButton"; import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; @@ -15,27 +11,28 @@ import { FetchAssistantsResponse, fetchAssistantsSS, } from "@/lib/assistants/fetchAssistantsSS"; +import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; async function Page({ params }: { params: { id: string } }) { const tasks = [ fetchSS("/manage/admin/slack-bot/config"), fetchSS("/manage/document-set"), fetchAssistantsSS(), - fetchSS("/manage/admin/standard-answer/category"), ]; const [ slackBotsResponse, documentSetsResponse, [assistants, assistantsFetchError], - standardAnswerCategoriesResponse, ] = (await Promise.all(tasks)) as [ Response, Response, FetchAssistantsResponse, - Response, ]; + const eeStandardAnswerCategoryResponse = + await getStandardAnswerCategoriesIfEE(); + if (!slackBotsResponse.ok) { return ( - ); - } - - const standardAnswerCategories = - (await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[]; - return (
@@ -107,7 +92,7 @@ async function Page({ params }: { params: { id: string } }) {
diff --git a/web/src/app/admin/bot/new/page.tsx b/web/src/app/admin/bot/new/page.tsx index f3be35836f5a..094682c36968 100644 --- a/web/src/app/admin/bot/new/page.tsx +++ b/web/src/app/admin/bot/new/page.tsx @@ -10,13 +10,10 @@ import { FetchAssistantsResponse, fetchAssistantsSS, } from "@/lib/assistants/fetchAssistantsSS"; +import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE"; async function Page() { - const tasks = [ - fetchSS("/manage/document-set"), - fetchAssistantsSS(), - fetchSS("/manage/admin/standard-answer/category"), - ]; + const tasks = [fetchSS("/manage/document-set"), fetchAssistantsSS()]; const [ documentSetsResponse, [assistants, assistantsFetchError], @@ -27,6 +24,9 @@ async function Page() { Response, ]; + const eeStandardAnswerCategoryResponse = + await getStandardAnswerCategoriesIfEE(); + if (!documentSetsResponse.ok) { return ( - ); - } - - const standardAnswerCategories = - (await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[]; - return (
@@ -69,7 +57,7 @@ async function Page() {
); diff --git a/web/src/app/admin/standard-answer/StandardAnswerCreationForm.tsx b/web/src/app/ee/admin/standard-answer/StandardAnswerCreationForm.tsx similarity index 100% rename from web/src/app/admin/standard-answer/StandardAnswerCreationForm.tsx rename to web/src/app/ee/admin/standard-answer/StandardAnswerCreationForm.tsx diff --git a/web/src/app/admin/standard-answer/[id]/page.tsx b/web/src/app/ee/admin/standard-answer/[id]/page.tsx similarity index 95% rename from web/src/app/admin/standard-answer/[id]/page.tsx rename to web/src/app/ee/admin/standard-answer/[id]/page.tsx index 94cd826824fa..6d949331b19a 100644 --- a/web/src/app/admin/standard-answer/[id]/page.tsx +++ b/web/src/app/ee/admin/standard-answer/[id]/page.tsx @@ -1,5 +1,5 @@ import { AdminPageTitle } from "@/components/admin/Title"; -import { StandardAnswerCreationForm } from "@/app/admin/standard-answer/StandardAnswerCreationForm"; +import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/StandardAnswerCreationForm"; import { fetchSS } from "@/lib/utilsSS"; import { ErrorCallout } from "@/components/ErrorCallout"; import { BackButton } from "@/components/BackButton"; diff --git a/web/src/app/admin/standard-answer/hooks.ts b/web/src/app/ee/admin/standard-answer/hooks.ts similarity index 100% rename from web/src/app/admin/standard-answer/hooks.ts rename to web/src/app/ee/admin/standard-answer/hooks.ts diff --git a/web/src/app/admin/standard-answer/lib.ts b/web/src/app/ee/admin/standard-answer/lib.ts similarity index 100% rename from web/src/app/admin/standard-answer/lib.ts rename to web/src/app/ee/admin/standard-answer/lib.ts diff --git a/web/src/app/admin/standard-answer/new/page.tsx b/web/src/app/ee/admin/standard-answer/new/page.tsx similarity index 92% rename from web/src/app/admin/standard-answer/new/page.tsx rename to web/src/app/ee/admin/standard-answer/new/page.tsx index 06bdc50f0b13..e671f5e1ae9a 100644 --- a/web/src/app/admin/standard-answer/new/page.tsx +++ b/web/src/app/ee/admin/standard-answer/new/page.tsx @@ -1,5 +1,5 @@ import { AdminPageTitle } from "@/components/admin/Title"; -import { StandardAnswerCreationForm } from "@/app/admin/standard-answer/StandardAnswerCreationForm"; +import { StandardAnswerCreationForm } from "@/app/ee/admin/standard-answer/StandardAnswerCreationForm"; import { fetchSS } from "@/lib/utilsSS"; import { ErrorCallout } from "@/components/ErrorCallout"; import { BackButton } from "@/components/BackButton"; diff --git a/web/src/app/admin/standard-answer/page.tsx b/web/src/app/ee/admin/standard-answer/page.tsx similarity index 100% rename from web/src/app/admin/standard-answer/page.tsx rename to web/src/app/ee/admin/standard-answer/page.tsx diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx index f36fc8f7ae60..c50e08716de4 100644 --- a/web/src/components/admin/ClientLayout.tsx +++ b/web/src/components/admin/ClientLayout.tsx @@ -145,15 +145,6 @@ export function ClientLayout({ ), link: "/admin/tools", }, - { - name: ( -
- -
Standard Answers
-
- ), - link: "/admin/standard-answer", - }, { name: (
@@ -165,6 +156,19 @@ export function ClientLayout({ }, ] : []), + ...(enableEnterprise + ? [ + { + name: ( +
+ +
Standard Answers
+
+ ), + link: "/admin/standard-answer", + }, + ] + : []), ], }, ...(isCurator diff --git a/web/src/components/standardAnswers/StandardAnswerCategoryDropdown.tsx b/web/src/components/standardAnswers/StandardAnswerCategoryDropdown.tsx new file mode 100644 index 000000000000..329da59762b6 --- /dev/null +++ b/web/src/components/standardAnswers/StandardAnswerCategoryDropdown.tsx @@ -0,0 +1,71 @@ +import { FC } from "react"; +import { StandardAnswerCategoryResponse } from "./getStandardAnswerCategoriesIfEE"; +import { Label } from "../admin/connectors/Field"; +import MultiSelectDropdown from "../MultiSelectDropdown"; +import { StandardAnswerCategory } from "@/lib/types"; +import { ErrorCallout } from "../ErrorCallout"; +import { LoadingAnimation } from "../Loading"; +import { Divider } from "@tremor/react"; + +interface StandardAnswerCategoryDropdownFieldProps { + standardAnswerCategoryResponse: StandardAnswerCategoryResponse; + categories: StandardAnswerCategory[]; + setCategories: (categories: StandardAnswerCategory[]) => void; +} + +export const StandardAnswerCategoryDropdownField: FC< + StandardAnswerCategoryDropdownFieldProps +> = ({ standardAnswerCategoryResponse, categories, setCategories }) => { + if (!standardAnswerCategoryResponse.paidEnterpriseFeaturesEnabled) { + return null; + } + + if (standardAnswerCategoryResponse.error != null) { + return ( + + ); + } + + if (standardAnswerCategoryResponse.categories == null) { + return ; + } + + return ( + <> +
+ +
+ { + const selectedCategories = selectedOptions.map((option) => { + return { + id: Number(option.value), + name: option.label, + }; + }); + setCategories(selectedCategories); + }} + creatable={false} + options={standardAnswerCategoryResponse.categories.map( + (category) => ({ + label: category.name, + value: category.id.toString(), + }) + )} + initialSelectedOptions={categories.map((category) => ({ + label: category.name, + value: category.id.toString(), + }))} + /> +
+
+ + + + ); +}; diff --git a/web/src/components/standardAnswers/getStandardAnswerCategoriesIfEE.tsx b/web/src/components/standardAnswers/getStandardAnswerCategoriesIfEE.tsx new file mode 100644 index 000000000000..689b0aae143a --- /dev/null +++ b/web/src/components/standardAnswers/getStandardAnswerCategoriesIfEE.tsx @@ -0,0 +1,47 @@ +import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants"; +import { StandardAnswerCategory } from "@/lib/types"; +import { fetchSS } from "@/lib/utilsSS"; + +export type StandardAnswerCategoryResponse = + | EEStandardAnswerCategoryResponse + | NoEEAvailable; + +interface NoEEAvailable { + paidEnterpriseFeaturesEnabled: false; +} + +interface EEStandardAnswerCategoryResponse { + paidEnterpriseFeaturesEnabled: true; + error?: { + message: string; + }; + categories?: StandardAnswerCategory[]; +} + +export async function getStandardAnswerCategoriesIfEE(): Promise { + if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) { + return { + paidEnterpriseFeaturesEnabled: false, + }; + } + + const standardAnswerCategoriesResponse = await fetchSS( + "/manage/admin/standard-answer/category" + ); + if (!standardAnswerCategoriesResponse.ok) { + return { + paidEnterpriseFeaturesEnabled: true, + error: { + message: await standardAnswerCategoriesResponse.text(), + }, + }; + } + + const categories = + (await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[]; + + return { + paidEnterpriseFeaturesEnabled: true, + categories, + }; +} diff --git a/web/src/middleware.ts b/web/src/middleware.ts index 706e6ee0f4bc..ff18bb0f6b72 100644 --- a/web/src/middleware.ts +++ b/web/src/middleware.ts @@ -9,6 +9,7 @@ const eePaths = [ "/admin/performance/query-history", "/admin/whitelabeling", "/admin/performance/custom-analytics", + "/admin/standard-answer", ]; const eePathsForMatcher = eePaths.map((path) => `${path}/:path*`);