Standard Answers (#1753)

---------

Co-authored-by: druhinsgoel <druhin@danswer.ai>
This commit is contained in:
Chris Weaver 2024-07-06 16:11:11 -07:00 committed by GitHub
parent f0888f2f61
commit e06f8a0a4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 2806 additions and 448 deletions

View File

@ -0,0 +1,75 @@
"""Add standard_answer tables
Revision ID: c18cdf4b497e
Revises: 3a7802814195
Create Date: 2024-06-06 15:15:02.000648
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "c18cdf4b497e"
down_revision = "3a7802814195"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"standard_answer",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("keyword", sa.String(), nullable=False),
sa.Column("answer", sa.String(), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("keyword"),
)
op.create_table(
"standard_answer_category",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"standard_answer__standard_answer_category",
sa.Column("standard_answer_id", sa.Integer(), nullable=False),
sa.Column("standard_answer_category_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["standard_answer_category_id"],
["standard_answer_category.id"],
),
sa.ForeignKeyConstraint(
["standard_answer_id"],
["standard_answer.id"],
),
sa.PrimaryKeyConstraint("standard_answer_id", "standard_answer_category_id"),
)
op.create_table(
"slack_bot_config__standard_answer_category",
sa.Column("slack_bot_config_id", sa.Integer(), nullable=False),
sa.Column("standard_answer_category_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["slack_bot_config_id"],
["slack_bot_config.id"],
),
sa.ForeignKeyConstraint(
["standard_answer_category_id"],
["standard_answer_category.id"],
),
sa.PrimaryKeyConstraint("slack_bot_config_id", "standard_answer_category_id"),
)
op.add_column(
"chat_session", sa.Column("slack_thread_id", sa.String(), nullable=True)
)
def downgrade() -> None:
op.drop_column("chat_session", "slack_thread_id")
op.drop_table("slack_bot_config__standard_answer_category")
op.drop_table("standard_answer__standard_answer_category")
op.drop_table("standard_answer_category")
op.drop_table("standard_answer")

View File

@ -25,6 +25,7 @@ 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
@ -353,6 +354,22 @@ 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,

View File

@ -8,6 +8,7 @@ FOLLOWUP_BUTTON_ACTION_ID = "followup-button"
FOLLOWUP_BUTTON_RESOLVED_ACTION_ID = "followup-resolved-button"
SLACK_CHANNEL_ID = "channel_id"
VIEW_DOC_FEEDBACK_ID = "view-doc-feedback"
GENERATE_ANSWER_BUTTON_ACTION_ID = "generate-answer-button"
class FeedbackVisibility(str, Enum):

View File

@ -1,3 +1,4 @@
import logging
from typing import Any
from typing import cast
@ -8,6 +9,7 @@ from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from sqlalchemy.orm import Session
from danswer.configs.constants import MessageType
from danswer.configs.constants import SearchFeedbackType
from danswer.configs.danswerbot_configs import DANSWER_FOLLOWUP_EMOJI
from danswer.connectors.slack.utils import make_slack_api_rate_limited
@ -21,12 +23,17 @@ from danswer.danswerbot.slack.constants import VIEW_DOC_FEEDBACK_ID
from danswer.danswerbot.slack.handlers.handle_message import (
remove_scheduled_feedback_reminder,
)
from danswer.danswerbot.slack.handlers.handle_regular_answer import (
handle_regular_answer,
)
from danswer.danswerbot.slack.models import SlackMessageInfo
from danswer.danswerbot.slack.utils import build_feedback_id
from danswer.danswerbot.slack.utils import decompose_action_id
from danswer.danswerbot.slack.utils import fetch_groupids_from_names
from danswer.danswerbot.slack.utils import fetch_userids_from_emails
from danswer.danswerbot.slack.utils import get_channel_name_from_id
from danswer.danswerbot.slack.utils import get_feedback_visibility
from danswer.danswerbot.slack.utils import read_slack_thread
from danswer.danswerbot.slack.utils import respond_in_thread
from danswer.danswerbot.slack.utils import update_emote_react
from danswer.db.engine import get_sqlalchemy_engine
@ -72,6 +79,66 @@ def handle_doc_feedback_button(
)
def handle_generate_answer_button(
req: SocketModeRequest,
client: SocketModeClient,
) -> None:
channel_id = req.payload["channel"]["id"]
channel_name = req.payload["channel"]["name"]
message_ts = req.payload["message"]["ts"]
thread_ts = req.payload["container"]["thread_ts"]
user_id = req.payload["user"]["id"]
if not thread_ts:
raise ValueError("Missing thread_ts in the payload")
thread_messages = read_slack_thread(
channel=channel_id, thread=thread_ts, client=client.web_client
)
# remove all assistant messages till we get to the last user message
# we want the new answer to be generated off of the last "question" in
# the thread
for i in range(len(thread_messages) - 1, -1, -1):
if thread_messages[i].role == MessageType.USER:
break
if thread_messages[i].role == MessageType.ASSISTANT:
thread_messages.pop(i)
# tell the user that we're working on it
# Send an ephemeral message to the user that we're generating the answer
respond_in_thread(
client=client.web_client,
channel=channel_id,
receiver_ids=[user_id],
text="I'm working on generating a full answer for you. This may take a moment...",
thread_ts=thread_ts,
)
with Session(get_sqlalchemy_engine()) as db_session:
slack_bot_config = get_slack_bot_config_for_channel(
channel_name=channel_name, db_session=db_session
)
handle_regular_answer(
message_info=SlackMessageInfo(
thread_messages=thread_messages,
channel_to_respond=channel_id,
msg_to_respond=cast(str, message_ts or thread_ts),
thread_to_respond=cast(str, thread_ts or message_ts),
sender=user_id or None,
bypass_filters=True,
is_bot_msg=False,
is_bot_dm=False,
),
slack_bot_config=slack_bot_config,
receiver_ids=None,
client=client.web_client,
channel=channel_id,
logger=cast(logging.Logger, logger_base),
feedback_reminder_id=None,
)
def handle_slack_feedback(
feedback_id: str,
feedback_type: str,

View File

@ -1,92 +1,34 @@
import datetime
import functools
import logging
from collections.abc import Callable
from typing import Any
from typing import cast
from typing import Optional
from typing import TypeVar
from retry import retry
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from slack_sdk.models.blocks import DividerBlock
from slack_sdk.models.blocks import SectionBlock
from sqlalchemy.orm import Session
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
from danswer.configs.danswerbot_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISABLE_COT
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
from danswer.configs.danswerbot_configs import DANSWER_BOT_FEEDBACK_REMINDER
from danswer.configs.danswerbot_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.danswerbot_configs import DANSWER_BOT_TARGET_CHUNK_PERCENTAGE
from danswer.configs.danswerbot_configs import DANSWER_BOT_USE_QUOTES
from danswer.configs.danswerbot_configs import DANSWER_FOLLOWUP_EMOJI
from danswer.configs.danswerbot_configs import DANSWER_REACT_EMOJI
from danswer.configs.danswerbot_configs import DISABLE_DANSWER_BOT_FILTER_DETECT
from danswer.configs.danswerbot_configs import ENABLE_DANSWERBOT_REFLEXION
from danswer.danswerbot.slack.blocks import build_documents_blocks
from danswer.danswerbot.slack.blocks import build_follow_up_block
from danswer.danswerbot.slack.blocks import build_qa_response_blocks
from danswer.danswerbot.slack.blocks import build_sources_blocks
from danswer.danswerbot.slack.blocks import get_feedback_reminder_blocks
from danswer.danswerbot.slack.blocks import get_restate_blocks
from danswer.danswerbot.slack.constants import SLACK_CHANNEL_ID
from danswer.danswerbot.slack.handlers.handle_regular_answer import (
handle_regular_answer,
)
from danswer.danswerbot.slack.handlers.handle_standard_answers import (
handle_standard_answers,
)
from danswer.danswerbot.slack.models import SlackMessageInfo
from danswer.danswerbot.slack.utils import ChannelIdAdapter
from danswer.danswerbot.slack.utils import fetch_userids_from_emails
from danswer.danswerbot.slack.utils import fetch_userids_from_groups
from danswer.danswerbot.slack.utils import respond_in_thread
from danswer.danswerbot.slack.utils import slack_usage_report
from danswer.danswerbot.slack.utils import SlackRateLimiter
from danswer.danswerbot.slack.utils import update_emote_react
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.models import Persona
from danswer.db.models import SlackBotConfig
from danswer.db.models import SlackBotResponseType
from danswer.db.persona import fetch_persona_by_id
from danswer.llm.answering.prompts.citations_prompt import (
compute_max_document_tokens_for_persona,
)
from danswer.llm.factory import get_llms_for_persona
from danswer.llm.utils import check_number_of_tokens
from danswer.llm.utils import get_max_input_tokens
from danswer.one_shot_answer.answer_question import get_search_answer
from danswer.one_shot_answer.models import DirectQARequest
from danswer.one_shot_answer.models import OneShotQAResponse
from danswer.search.models import BaseFilters
from danswer.search.models import OptionalSearchSetting
from danswer.search.models import RetrievalDetails
from danswer.utils.logger import setup_logger
from shared_configs.configs import ENABLE_RERANKING_ASYNC_FLOW
logger_base = setup_logger()
srl = SlackRateLimiter()
RT = TypeVar("RT") # return type
def rate_limits(
client: WebClient, channel: str, thread_ts: Optional[str]
) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
def decorator(func: Callable[..., RT]) -> Callable[..., RT]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> RT:
if not srl.is_available():
func_randid, position = srl.init_waiter()
srl.notify(client, channel, position, thread_ts)
while not srl.is_available():
srl.waiter(func_randid)
srl.acquire_slot()
return func(*args, **kwargs)
return wrapper
return decorator
def send_msg_ack_to_user(details: SlackMessageInfo, client: WebClient) -> None:
if details.is_bot_msg and details.sender:
@ -174,17 +116,9 @@ def remove_scheduled_feedback_reminder(
def handle_message(
message_info: SlackMessageInfo,
channel_config: SlackBotConfig | None,
slack_bot_config: SlackBotConfig | None,
client: WebClient,
feedback_reminder_id: str | None,
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,
disable_auto_detect_filters: bool = DISABLE_DANSWER_BOT_FILTER_DETECT,
reflexion: bool = ENABLE_DANSWERBOT_REFLEXION,
disable_cot: bool = DANSWER_BOT_DISABLE_COT,
thread_context_percent: float = DANSWER_BOT_TARGET_CHUNK_PERCENTAGE,
) -> bool:
"""Potentially respond to the user message depending on filters and if an answer was generated
@ -201,14 +135,22 @@ def handle_message(
)
messages = message_info.thread_messages
message_ts_to_respond_to = message_info.msg_to_respond
sender_id = message_info.sender
bypass_filters = message_info.bypass_filters
is_bot_msg = message_info.is_bot_msg
is_bot_dm = message_info.is_bot_dm
action = "slack_message"
if is_bot_msg:
action = "slack_slash_message"
elif bypass_filters:
action = "slack_tag_message"
elif is_bot_dm:
action = "slack_dm_message"
slack_usage_report(action=action, sender_id=sender_id, client=client)
document_set_names: list[str] | None = None
persona = channel_config.persona if channel_config else None
persona = slack_bot_config.persona if slack_bot_config else None
prompt = None
if persona:
document_set_names = [
@ -216,36 +158,17 @@ def handle_message(
]
prompt = persona.prompts[0] if persona.prompts else None
should_respond_even_with_no_docs = persona.num_chunks == 0 if persona else False
# figure out if we want to use citations or quotes
use_citations = (
not DANSWER_BOT_USE_QUOTES
if channel_config is None
else channel_config.response_type == SlackBotResponseType.CITATIONS
)
# List of user id to send message to, if None, send to everyone in channel
send_to: list[str] | None = None
respond_tag_only = False
respond_team_member_list = None
respond_slack_group_list = None
bypass_acl = False
if (
channel_config
and channel_config.persona
and channel_config.persona.document_sets
):
# For Slack channels, use the full document set, admin will be warned when configuring it
# with non-public document sets
bypass_acl = True
channel_conf = None
if channel_config and channel_config.channel_config:
channel_conf = channel_config.channel_config
if slack_bot_config and slack_bot_config.channel_config:
channel_conf = slack_bot_config.channel_config
if not bypass_filters and "answer_filters" in channel_conf:
reflexion = "well_answered_postfilter" in channel_conf["answer_filters"]
"well_answered_postfilter" in channel_conf["answer_filters"]
if (
"questionmark_prefilter" in channel_conf["answer_filters"]
@ -298,324 +221,28 @@ def handle_message(
except SlackApiError as e:
logger.error(f"Was not able to react to user message due to: {e}")
@retry(
tries=num_retries,
delay=0.25,
backoff=2,
logger=logger,
)
@rate_limits(client=client, channel=channel, thread_ts=message_ts_to_respond_to)
def _get_answer(new_message_request: DirectQARequest) -> OneShotQAResponse | None:
action = "slack_message"
if is_bot_msg:
action = "slack_slash_message"
elif bypass_filters:
action = "slack_tag_message"
elif is_bot_dm:
action = "slack_dm_message"
slack_usage_report(action=action, sender_id=sender_id, client=client)
max_document_tokens: int | None = None
max_history_tokens: int | None = None
with Session(get_sqlalchemy_engine()) as db_session:
if len(new_message_request.messages) > 1:
persona = cast(
Persona,
fetch_persona_by_id(db_session, new_message_request.persona_id),
)
llm, _ = get_llms_for_persona(persona)
# In cases of threads, split the available tokens between docs and thread context
input_tokens = get_max_input_tokens(
model_name=llm.config.model_name,
model_provider=llm.config.model_provider,
)
max_history_tokens = int(input_tokens * thread_context_percent)
remaining_tokens = input_tokens - max_history_tokens
query_text = new_message_request.messages[0].message
if persona:
max_document_tokens = compute_max_document_tokens_for_persona(
persona=persona,
actual_user_input=query_text,
max_llm_token_override=remaining_tokens,
)
else:
max_document_tokens = (
remaining_tokens
- 512 # Needs to be more than any of the QA prompts
- check_number_of_tokens(query_text)
)
if DISABLE_GENERATIVE_AI:
return None
# This also handles creating the query event in postgres
answer = get_search_answer(
query_req=new_message_request,
user=None,
max_document_tokens=max_document_tokens,
max_history_tokens=max_history_tokens,
db_session=db_session,
answer_generation_timeout=answer_generation_timeout,
enable_reflexion=reflexion,
bypass_acl=bypass_acl,
use_citations=use_citations,
danswerbot_flow=True,
)
if not answer.error_msg:
return answer
else:
raise RuntimeError(answer.error_msg)
try:
# By leaving time_cutoff and favor_recent as None, and setting enable_auto_detect_filters
# it allows the slack flow to extract out filters from the user query
filters = BaseFilters(
source_type=None,
document_set=document_set_names,
time_cutoff=None,
with Session(get_sqlalchemy_engine()) as db_session:
# first check if we need to respond with a standard answer
used_standard_answer = handle_standard_answers(
message_info=message_info,
receiver_ids=send_to,
slack_bot_config=slack_bot_config,
prompt=prompt,
logger=logger,
client=client,
db_session=db_session,
)
# Default True because no other ways to apply filters in Slack (no nice UI)
auto_detect_filters = (
persona.llm_filter_extraction if persona is not None else True
)
if disable_auto_detect_filters:
auto_detect_filters = False
retrieval_details = RetrievalDetails(
run_search=OptionalSearchSetting.ALWAYS,
real_time=False,
filters=filters,
enable_auto_detect_filters=auto_detect_filters,
)
# This includes throwing out answer via reflexion
answer = _get_answer(
DirectQARequest(
messages=messages,
prompt_id=prompt.id if prompt else None,
persona_id=persona.id if persona is not None else 0,
retrieval_options=retrieval_details,
chain_of_thought=not disable_cot,
skip_rerank=not ENABLE_RERANKING_ASYNC_FLOW,
)
)
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,
receiver_ids=None,
text=f"Encountered exception when trying to answer: \n\n```{e}```",
thread_ts=message_ts_to_respond_to,
)
# In case of failures, don't keep the reaction there permanently
try:
update_emote_react(
emoji=DANSWER_REACT_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=True,
client=client,
)
except SlackApiError as e:
logger.error(f"Failed to remove Reaction due to: {e}")
return True
# Edge case handling, for tracking down the Slack usage issue
if answer is None:
assert DISABLE_GENERATIVE_AI is True
try:
respond_in_thread(
client=client,
channel=channel,
receiver_ids=send_to,
text="Hello! Danswer has some results for you!",
blocks=[
SectionBlock(
text="Danswer is down for maintenance.\nWe're working hard on recharging the AI!"
)
],
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_team_member_list or respond_slack_group_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,
)
if used_standard_answer:
return False
except Exception:
logger.exception(
f"Unable to process message - could not respond in slack in {num_retries} attempts"
)
return True
# Got an answer at this point, can remove reaction and give results
try:
update_emote_react(
emoji=DANSWER_REACT_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=True,
client=client,
)
except SlackApiError as e:
logger.error(f"Failed to remove Reaction due to: {e}")
if answer.answer_valid is False:
logger.info(
"Answer was evaluated to be invalid, throwing it away without responding."
)
update_emote_react(
emoji=DANSWER_FOLLOWUP_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=False,
client=client,
)
if answer.answer:
logger.debug(answer.answer)
return True
retrieval_info = answer.docs
if not retrieval_info:
# This should not happen, even with no docs retrieved, there is still info returned
raise RuntimeError("Failed to retrieve docs, cannot answer question.")
top_docs = retrieval_info.top_documents
if not top_docs and not should_respond_even_with_no_docs:
logger.error(
f"Unable to answer question: '{answer.rephrase}' - 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,
receiver_ids=None,
text="Found no documents when trying to answer. Did you index any documents?",
thread_ts=message_ts_to_respond_to,
)
return True
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 True
# If called with the DanswerBot slash command, the question is lost so we have to reshow it
restate_question_block = get_restate_blocks(messages[-1].message, is_bot_msg)
answer_blocks = build_qa_response_blocks(
message_id=answer.chat_message_id,
answer=answer.answer,
quotes=answer.quotes.quotes if answer.quotes else None,
source_filters=retrieval_info.applied_source_filters,
time_cutoff=retrieval_info.applied_time_cutoff,
favor_recent=retrieval_info.recency_bias_multiplier > 1,
# currently Personas don't support quotes
# if citations are enabled, also don't use quotes
skip_quotes=persona is not None or use_citations,
process_message_for_citations=use_citations,
feedback_reminder_id=feedback_reminder_id,
)
# Get the chunks fed to the LLM only, then fill with other docs
llm_doc_inds = answer.llm_chunks_indices or []
llm_docs = [top_docs[i] for i in llm_doc_inds]
remaining_docs = [
doc for idx, doc in enumerate(top_docs) if idx not in llm_doc_inds
]
priority_ordered_docs = llm_docs + remaining_docs
document_blocks = []
citations_block = []
# if citations are enabled, only show cited documents
if use_citations:
citations = answer.citations or []
cited_docs = []
for citation in citations:
matching_doc = next(
(d for d in top_docs if d.document_id == citation.document_id),
None,
)
if matching_doc:
cited_docs.append((citation.citation_num, matching_doc))
cited_docs.sort()
citations_block = build_sources_blocks(cited_documents=cited_docs)
elif priority_ordered_docs:
document_blocks = build_documents_blocks(
documents=priority_ordered_docs,
message_id=answer.chat_message_id,
)
document_blocks = [DividerBlock()] + document_blocks
all_blocks = (
restate_question_block + answer_blocks + citations_block + document_blocks
)
if channel_conf and channel_conf.get("follow_up_tags") is not None:
all_blocks.append(build_follow_up_block(message_id=answer.chat_message_id))
try:
respond_in_thread(
# if no standard answer applies, try a regular answer
issue_with_regular_answer = handle_regular_answer(
message_info=message_info,
slack_bot_config=slack_bot_config,
receiver_ids=send_to,
client=client,
channel=channel,
receiver_ids=send_to,
text="Hello! Danswer has some results for you!",
blocks=all_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,
logger=logger,
feedback_reminder_id=feedback_reminder_id,
)
# 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_team_member_list or respond_slack_group_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,
)
return False
except Exception:
logger.exception(
f"Unable to process message - could not respond in slack in {num_retries} attempts"
)
return True
return issue_with_regular_answer

View File

@ -0,0 +1,437 @@
import functools
import logging
from collections.abc import Callable
from typing import Any
from typing import cast
from typing import Optional
from typing import TypeVar
from retry import retry
from slack_sdk import WebClient
from slack_sdk.models.blocks import DividerBlock
from slack_sdk.models.blocks import SectionBlock
from sqlalchemy.orm import Session
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
from danswer.configs.danswerbot_configs import DANSWER_BOT_ANSWER_GENERATION_TIMEOUT
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISABLE_COT
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER
from danswer.configs.danswerbot_configs import DANSWER_BOT_DISPLAY_ERROR_MSGS
from danswer.configs.danswerbot_configs import DANSWER_BOT_NUM_RETRIES
from danswer.configs.danswerbot_configs import DANSWER_BOT_TARGET_CHUNK_PERCENTAGE
from danswer.configs.danswerbot_configs import DANSWER_BOT_USE_QUOTES
from danswer.configs.danswerbot_configs import DANSWER_FOLLOWUP_EMOJI
from danswer.configs.danswerbot_configs import DANSWER_REACT_EMOJI
from danswer.configs.danswerbot_configs import DISABLE_DANSWER_BOT_FILTER_DETECT
from danswer.configs.danswerbot_configs import ENABLE_DANSWERBOT_REFLEXION
from danswer.danswerbot.slack.blocks import build_documents_blocks
from danswer.danswerbot.slack.blocks import build_follow_up_block
from danswer.danswerbot.slack.blocks import build_qa_response_blocks
from danswer.danswerbot.slack.blocks import build_sources_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 SlackRateLimiter
from danswer.danswerbot.slack.utils import update_emote_react
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.models import Persona
from danswer.db.models import SlackBotConfig
from danswer.db.models import SlackBotResponseType
from danswer.db.persona import fetch_persona_by_id
from danswer.llm.answering.prompts.citations_prompt import (
compute_max_document_tokens_for_persona,
)
from danswer.llm.factory import get_llms_for_persona
from danswer.llm.utils import check_number_of_tokens
from danswer.llm.utils import get_max_input_tokens
from danswer.one_shot_answer.answer_question import get_search_answer
from danswer.one_shot_answer.models import DirectQARequest
from danswer.one_shot_answer.models import OneShotQAResponse
from danswer.search.enums import OptionalSearchSetting
from danswer.search.models import BaseFilters
from danswer.search.models import RetrievalDetails
from shared_configs.configs import ENABLE_RERANKING_ASYNC_FLOW
srl = SlackRateLimiter()
RT = TypeVar("RT") # return type
def rate_limits(
client: WebClient, channel: str, thread_ts: Optional[str]
) -> Callable[[Callable[..., RT]], Callable[..., RT]]:
def decorator(func: Callable[..., RT]) -> Callable[..., RT]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> RT:
if not srl.is_available():
func_randid, position = srl.init_waiter()
srl.notify(client, channel, position, thread_ts)
while not srl.is_available():
srl.waiter(func_randid)
srl.acquire_slot()
return func(*args, **kwargs)
return wrapper
return decorator
def handle_regular_answer(
message_info: SlackMessageInfo,
slack_bot_config: SlackBotConfig | None,
receiver_ids: list[str] | None,
client: WebClient,
channel: str,
logger: logging.Logger,
feedback_reminder_id: str | None,
num_retries: int = DANSWER_BOT_NUM_RETRIES,
answer_generation_timeout: int = DANSWER_BOT_ANSWER_GENERATION_TIMEOUT,
thread_context_percent: float = DANSWER_BOT_TARGET_CHUNK_PERCENTAGE,
should_respond_with_error_msgs: bool = DANSWER_BOT_DISPLAY_ERROR_MSGS,
disable_docs_only_answer: bool = DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER,
disable_auto_detect_filters: bool = DISABLE_DANSWER_BOT_FILTER_DETECT,
disable_cot: bool = DANSWER_BOT_DISABLE_COT,
reflexion: bool = ENABLE_DANSWERBOT_REFLEXION,
) -> bool:
channel_conf = slack_bot_config.channel_config if slack_bot_config else None
messages = message_info.thread_messages
message_ts_to_respond_to = message_info.msg_to_respond
is_bot_msg = message_info.is_bot_msg
document_set_names: list[str] | None = None
persona = slack_bot_config.persona if slack_bot_config else None
prompt = None
if persona:
document_set_names = [
document_set.name for document_set in persona.document_sets
]
prompt = persona.prompts[0] if persona.prompts else None
should_respond_even_with_no_docs = persona.num_chunks == 0 if persona else False
bypass_acl = False
if (
slack_bot_config
and slack_bot_config.persona
and slack_bot_config.persona.document_sets
):
# For Slack channels, use the full document set, admin will be warned when configuring it
# with non-public document sets
bypass_acl = True
# figure out if we want to use citations or quotes
use_citations = (
not DANSWER_BOT_USE_QUOTES
if slack_bot_config is None
else slack_bot_config.response_type == SlackBotResponseType.CITATIONS
)
if not message_ts_to_respond_to:
raise RuntimeError(
"No message timestamp to respond to in `handle_message`. This should never happen."
)
@retry(
tries=num_retries,
delay=0.25,
backoff=2,
logger=logger,
)
@rate_limits(client=client, channel=channel, thread_ts=message_ts_to_respond_to)
def _get_answer(new_message_request: DirectQARequest) -> OneShotQAResponse | None:
max_document_tokens: int | None = None
max_history_tokens: int | None = None
with Session(get_sqlalchemy_engine()) as db_session:
if len(new_message_request.messages) > 1:
persona = cast(
Persona,
fetch_persona_by_id(db_session, new_message_request.persona_id),
)
llm, _ = get_llms_for_persona(persona)
# In cases of threads, split the available tokens between docs and thread context
input_tokens = get_max_input_tokens(
model_name=llm.config.model_name,
model_provider=llm.config.model_provider,
)
max_history_tokens = int(input_tokens * thread_context_percent)
remaining_tokens = input_tokens - max_history_tokens
query_text = new_message_request.messages[0].message
if persona:
max_document_tokens = compute_max_document_tokens_for_persona(
persona=persona,
actual_user_input=query_text,
max_llm_token_override=remaining_tokens,
)
else:
max_document_tokens = (
remaining_tokens
- 512 # Needs to be more than any of the QA prompts
- check_number_of_tokens(query_text)
)
if DISABLE_GENERATIVE_AI:
return None
# This also handles creating the query event in postgres
answer = get_search_answer(
query_req=new_message_request,
user=None,
max_document_tokens=max_document_tokens,
max_history_tokens=max_history_tokens,
db_session=db_session,
answer_generation_timeout=answer_generation_timeout,
enable_reflexion=reflexion,
bypass_acl=bypass_acl,
use_citations=use_citations,
danswerbot_flow=True,
)
if not answer.error_msg:
return answer
else:
raise RuntimeError(answer.error_msg)
try:
# By leaving time_cutoff and favor_recent as None, and setting enable_auto_detect_filters
# it allows the slack flow to extract out filters from the user query
filters = BaseFilters(
source_type=None,
document_set=document_set_names,
time_cutoff=None,
)
# Default True because no other ways to apply filters in Slack (no nice UI)
auto_detect_filters = (
persona.llm_filter_extraction if persona is not None else True
)
if disable_auto_detect_filters:
auto_detect_filters = False
retrieval_details = RetrievalDetails(
run_search=OptionalSearchSetting.ALWAYS,
real_time=False,
filters=filters,
enable_auto_detect_filters=auto_detect_filters,
)
# This includes throwing out answer via reflexion
answer = _get_answer(
DirectQARequest(
messages=messages,
prompt_id=prompt.id if prompt else None,
persona_id=persona.id if persona is not None else 0,
retrieval_options=retrieval_details,
chain_of_thought=not disable_cot,
skip_rerank=not ENABLE_RERANKING_ASYNC_FLOW,
)
)
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,
receiver_ids=None,
text=f"Encountered exception when trying to answer: \n\n```{e}```",
thread_ts=message_ts_to_respond_to,
)
# In case of failures, don't keep the reaction there permanently
update_emote_react(
emoji=DANSWER_REACT_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=True,
client=client,
)
return True
# Edge case handling, for tracking down the Slack usage issue
if answer is None:
assert DISABLE_GENERATIVE_AI is True
try:
respond_in_thread(
client=client,
channel=channel,
receiver_ids=receiver_ids,
text="Hello! Danswer has some results for you!",
blocks=[
SectionBlock(
text="Danswer is down for maintenance.\nWe're working hard on recharging the AI!"
)
],
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 receiver_ids:
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,
)
return False
except Exception:
logger.exception(
f"Unable to process message - could not respond in slack in {num_retries} attempts"
)
return True
# Got an answer at this point, can remove reaction and give results
update_emote_react(
emoji=DANSWER_REACT_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=True,
client=client,
)
if answer.answer_valid is False:
logger.info(
"Answer was evaluated to be invalid, throwing it away without responding."
)
update_emote_react(
emoji=DANSWER_FOLLOWUP_EMOJI,
channel=message_info.channel_to_respond,
message_ts=message_info.msg_to_respond,
remove=False,
client=client,
)
if answer.answer:
logger.debug(answer.answer)
return True
retrieval_info = answer.docs
if not retrieval_info:
# This should not happen, even with no docs retrieved, there is still info returned
raise RuntimeError("Failed to retrieve docs, cannot answer question.")
top_docs = retrieval_info.top_documents
if not top_docs and not should_respond_even_with_no_docs:
logger.error(
f"Unable to answer question: '{answer.rephrase}' - 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,
receiver_ids=None,
text="Found no documents when trying to answer. Did you index any documents?",
thread_ts=message_ts_to_respond_to,
)
return True
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 True
# If called with the DanswerBot slash command, the question is lost so we have to reshow it
restate_question_block = get_restate_blocks(messages[-1].message, is_bot_msg)
answer_blocks = build_qa_response_blocks(
message_id=answer.chat_message_id,
answer=answer.answer,
quotes=answer.quotes.quotes if answer.quotes else None,
source_filters=retrieval_info.applied_source_filters,
time_cutoff=retrieval_info.applied_time_cutoff,
favor_recent=retrieval_info.recency_bias_multiplier > 1,
# currently Personas don't support quotes
# if citations are enabled, also don't use quotes
skip_quotes=persona is not None or use_citations,
process_message_for_citations=use_citations,
feedback_reminder_id=feedback_reminder_id,
)
# Get the chunks fed to the LLM only, then fill with other docs
llm_doc_inds = answer.llm_chunks_indices or []
llm_docs = [top_docs[i] for i in llm_doc_inds]
remaining_docs = [
doc for idx, doc in enumerate(top_docs) if idx not in llm_doc_inds
]
priority_ordered_docs = llm_docs + remaining_docs
document_blocks = []
citations_block = []
# if citations are enabled, only show cited documents
if use_citations:
citations = answer.citations or []
cited_docs = []
for citation in citations:
matching_doc = next(
(d for d in top_docs if d.document_id == citation.document_id),
None,
)
if matching_doc:
cited_docs.append((citation.citation_num, matching_doc))
cited_docs.sort()
citations_block = build_sources_blocks(cited_documents=cited_docs)
elif priority_ordered_docs:
document_blocks = build_documents_blocks(
documents=priority_ordered_docs,
message_id=answer.chat_message_id,
)
document_blocks = [DividerBlock()] + document_blocks
all_blocks = (
restate_question_block + answer_blocks + citations_block + document_blocks
)
if channel_conf and channel_conf.get("follow_up_tags") is not None:
all_blocks.append(build_follow_up_block(message_id=answer.chat_message_id))
try:
respond_in_thread(
client=client,
channel=channel,
receiver_ids=receiver_ids,
text="Hello! Danswer has some results for you!",
blocks=all_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 receiver_ids:
send_team_member_message(
client=client,
channel=channel,
thread_ts=message_ts_to_respond_to,
)
return False
except Exception:
logger.exception(
f"Unable to process message - could not respond in slack in {num_retries} attempts"
)
return True

View File

@ -0,0 +1,181 @@
import logging
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.standard_answer import find_matching_standard_answers
def handle_standard_answers(
message_info: SlackMessageInfo,
receiver_ids: list[str] | None,
slack_bot_config: SlackBotConfig | None,
prompt: Prompt | None,
logger: logging.Logger,
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
)
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,
)
else:
matching_standard_answers = []
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 in matching_standard_answers:
block_quotified_answer = ">" + standard_answer.answer.replace("\n", "\n> ")
formatted_answer = (
f'Since you mentioned _"{standard_answer.keyword}"_, '
f"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

View File

@ -0,0 +1,19 @@
from slack_sdk import WebClient
from danswer.danswerbot.slack.utils import respond_in_thread
def send_team_member_message(
client: WebClient,
channel: str,
thread_ts: str,
) -> None:
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=thread_ts,
)

View File

@ -18,6 +18,7 @@ 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.constants import SLACK_CHANNEL_ID
@ -27,6 +28,9 @@ from danswer.danswerbot.slack.handlers.handle_buttons import handle_followup_but
from danswer.danswerbot.slack.handlers.handle_buttons import (
handle_followup_resolved_button,
)
from danswer.danswerbot.slack.handlers.handle_buttons import (
handle_generate_answer_button,
)
from danswer.danswerbot.slack.handlers.handle_buttons import handle_slack_feedback
from danswer.danswerbot.slack.handlers.handle_message import handle_message
from danswer.danswerbot.slack.handlers.handle_message import (
@ -266,6 +270,7 @@ def build_request_details(
thread_messages=thread_messages,
channel_to_respond=channel,
msg_to_respond=cast(str, message_ts or thread_ts),
thread_to_respond=cast(str, thread_ts or message_ts),
sender=event.get("user") or None,
bypass_filters=tagged,
is_bot_msg=False,
@ -283,6 +288,7 @@ def build_request_details(
thread_messages=[single_msg],
channel_to_respond=channel,
msg_to_respond=None,
thread_to_respond=None,
sender=sender,
bypass_filters=True,
is_bot_msg=True,
@ -352,7 +358,7 @@ def process_message(
failed = handle_message(
message_info=details,
channel_config=slack_bot_config,
slack_bot_config=slack_bot_config,
client=client.web_client,
feedback_reminder_id=feedback_reminder_id,
)
@ -390,6 +396,8 @@ def action_routing(req: SocketModeRequest, client: SocketModeClient) -> None:
return handle_followup_resolved_button(req, client, immediate=True)
elif action["action_id"] == FOLLOWUP_BUTTON_RESOLVED_ACTION_ID:
return handle_followup_resolved_button(req, client, immediate=False)
elif action["action_id"] == GENERATE_ANSWER_BUTTON_ACTION_ID:
return handle_generate_answer_button(req, client)
def view_routing(req: SocketModeRequest, client: SocketModeClient) -> None:

View File

@ -7,6 +7,7 @@ class SlackMessageInfo(BaseModel):
thread_messages: list[ThreadMessage]
channel_to_respond: str
msg_to_respond: str | None
thread_to_respond: str | None
sender: str | None
bypass_filters: bool # User has tagged @DanswerBot
is_bot_msg: bool # User is using /DanswerBot

View File

@ -77,17 +77,25 @@ def update_emote_react(
remove: bool,
client: WebClient,
) -> None:
if not message_ts:
logger.error(f"Tried to remove a react in {channel} but no message specified")
return
try:
if not message_ts:
logger.error(
f"Tried to remove a react in {channel} but no message specified"
)
return
func = client.reactions_remove if remove else client.reactions_add
slack_call = make_slack_api_rate_limited(func) # type: ignore
slack_call(
name=emoji,
channel=channel,
timestamp=message_ts,
)
func = client.reactions_remove if remove else client.reactions_add
slack_call = make_slack_api_rate_limited(func) # type: ignore
slack_call(
name=emoji,
channel=channel,
timestamp=message_ts,
)
except SlackApiError as e:
if remove:
logger.error(f"Failed to remove Reaction due to: {e}")
else:
logger.error(f"Was not able to react to user message due to: {e}")
def get_danswer_bot_app_id(web_client: WebClient) -> Any:
@ -136,16 +144,13 @@ def respond_in_thread(
receiver_ids: list[str] | None = None,
metadata: Metadata | None = None,
unfurl: bool = True,
) -> None:
) -> list[str]:
if not text and not blocks:
raise ValueError("One of `text` or `blocks` must be provided")
message_ids: list[str] = []
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,
@ -157,7 +162,9 @@ def respond_in_thread(
)
if not response.get("ok"):
raise RuntimeError(f"Failed to post message: {response}")
message_ids.append(response["message_ts"])
else:
slack_call = make_slack_api_rate_limited(client.chat_postEphemeral)
for receiver in receiver_ids:
response = slack_call(
channel=channel,
@ -171,6 +178,9 @@ def respond_in_thread(
)
if not response.get("ok"):
raise RuntimeError(f"Failed to post message: {response}")
message_ids.append(response["message_ts"])
return message_ids
def build_feedback_id(

View File

@ -1,3 +1,4 @@
from collections.abc import Sequence
from datetime import datetime
from datetime import timedelta
from uuid import UUID
@ -67,6 +68,19 @@ def get_chat_session_by_id(
return chat_session
def get_chat_sessions_by_slack_thread_id(
slack_thread_id: str,
user_id: UUID | None,
db_session: Session,
) -> Sequence[ChatSession]:
stmt = select(ChatSession).where(ChatSession.slack_thread_id == slack_thread_id)
if user_id is not None:
stmt = stmt.where(
or_(ChatSession.user_id == user_id, ChatSession.user_id.is_(None))
)
return db_session.scalars(stmt).all()
def get_chat_sessions_by_user(
user_id: UUID | None,
deleted: bool | None,
@ -139,11 +153,12 @@ def create_chat_session(
db_session: Session,
description: str,
user_id: UUID | None,
persona_id: int | None = None,
persona_id: int,
llm_override: LLMOverride | None = None,
prompt_override: PromptOverride | None = None,
one_shot: bool = False,
danswerbot_flow: bool = False,
slack_thread_id: str | None = None,
) -> ChatSession:
chat_session = ChatSession(
user_id=user_id,
@ -153,6 +168,7 @@ def create_chat_session(
prompt_override=prompt_override,
one_shot=one_shot,
danswerbot_flow=danswerbot_flow,
slack_thread_id=slack_thread_id,
)
db_session.add(chat_session)
@ -240,6 +256,25 @@ def get_chat_message(
return chat_message
def get_chat_messages_by_sessions(
chat_session_ids: list[int],
user_id: UUID | None,
db_session: Session,
skip_permission_check: bool = False,
) -> Sequence[ChatMessage]:
if not skip_permission_check:
for chat_session_id in chat_session_ids:
get_chat_session_by_id(
chat_session_id=chat_session_id, user_id=user_id, db_session=db_session
)
stmt = (
select(ChatMessage)
.where(ChatMessage.chat_session_id.in_(chat_session_ids))
.order_by(nullsfirst(ChatMessage.parent_message))
)
return db_session.execute(stmt).scalars().all()
def get_chat_messages_by_session(
chat_session_id: int,
user_id: UUID | None,

View File

@ -246,6 +246,39 @@ class Persona__Tool(Base):
tool_id: Mapped[int] = mapped_column(ForeignKey("tool.id"), primary_key=True)
class StandardAnswer__StandardAnswerCategory(Base):
__tablename__ = "standard_answer__standard_answer_category"
standard_answer_id: Mapped[int] = mapped_column(
ForeignKey("standard_answer.id"), primary_key=True
)
standard_answer_category_id: Mapped[int] = mapped_column(
ForeignKey("standard_answer_category.id"), primary_key=True
)
class SlackBotConfig__StandardAnswerCategory(Base):
__tablename__ = "slack_bot_config__standard_answer_category"
slack_bot_config_id: Mapped[int] = mapped_column(
ForeignKey("slack_bot_config.id"), primary_key=True
)
standard_answer_category_id: Mapped[int] = mapped_column(
ForeignKey("standard_answer_category.id"), primary_key=True
)
class ChatMessage__StandardAnswer(Base):
__tablename__ = "chat_message__standard_answer"
chat_message_id: Mapped[int] = mapped_column(
ForeignKey("chat_message.id"), primary_key=True
)
standard_answer_id: Mapped[int] = mapped_column(
ForeignKey("standard_answer.id"), primary_key=True
)
"""
Documents/Indexing Tables
"""
@ -663,6 +696,10 @@ class ChatSession(Base):
current_alternate_model: Mapped[str | None] = mapped_column(String, default=None)
slack_thread_id: Mapped[str | None] = mapped_column(
String, nullable=True, default=None
)
# the latest "overrides" specified by the user. These take precedence over
# the attached persona. However, overrides specified directly in the
# `send-message` call will take precedence over these.
@ -760,6 +797,11 @@ class ChatMessage(Base):
"ToolCall",
back_populates="message",
)
standard_answers: Mapped[list["StandardAnswer"]] = relationship(
"StandardAnswer",
secondary=ChatMessage__StandardAnswer.__table__,
back_populates="chat_messages",
)
class ChatFolder(Base):
@ -1085,6 +1127,53 @@ class ChannelConfig(TypedDict):
follow_up_tags: NotRequired[list[str]]
class StandardAnswerCategory(Base):
__tablename__ = "standard_answer_category"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
standard_answers: Mapped[list["StandardAnswer"]] = relationship(
"StandardAnswer",
secondary=StandardAnswer__StandardAnswerCategory.__table__,
back_populates="categories",
)
slack_bot_configs: Mapped[list["SlackBotConfig"]] = relationship(
"SlackBotConfig",
secondary=SlackBotConfig__StandardAnswerCategory.__table__,
back_populates="standard_answer_categories",
)
class StandardAnswer(Base):
__tablename__ = "standard_answer"
id: Mapped[int] = mapped_column(primary_key=True)
keyword: Mapped[str] = mapped_column(String)
answer: Mapped[str] = mapped_column(String)
active: Mapped[bool] = mapped_column(Boolean)
__table_args__ = (
Index(
"unique_keyword_active",
keyword,
active,
unique=True,
postgresql_where=(active == True), # noqa: E712
),
)
categories: Mapped[list[StandardAnswerCategory]] = relationship(
"StandardAnswerCategory",
secondary=StandardAnswer__StandardAnswerCategory.__table__,
back_populates="standard_answers",
)
chat_messages: Mapped[list[ChatMessage]] = relationship(
"ChatMessage",
secondary=ChatMessage__StandardAnswer.__table__,
back_populates="standard_answers",
)
class SlackBotResponseType(str, PyEnum):
QUOTES = "quotes"
CITATIONS = "citations"
@ -1106,6 +1195,11 @@ class SlackBotConfig(Base):
)
persona: Mapped[Persona | None] = relationship("Persona")
standard_answer_categories: Mapped[list[StandardAnswerCategory]] = relationship(
"StandardAnswerCategory",
secondary=SlackBotConfig__StandardAnswerCategory.__table__,
back_populates="slack_bot_configs",
)
class TaskQueueState(Base):

View File

@ -14,6 +14,7 @@ from danswer.db.models import User
from danswer.db.persona import get_default_prompt
from danswer.db.persona import mark_persona_as_deleted
from danswer.db.persona import upsert_persona
from danswer.db.standard_answer import fetch_standard_answer_categories_by_ids
from danswer.search.enums import RecencyBiasSetting
@ -72,12 +73,23 @@ def insert_slack_bot_config(
persona_id: int | None,
channel_config: ChannelConfig,
response_type: SlackBotResponseType,
standard_answer_category_ids: list[int],
db_session: Session,
) -> SlackBotConfig:
existing_standard_answer_categories = fetch_standard_answer_categories_by_ids(
standard_answer_category_ids=standard_answer_category_ids,
db_session=db_session,
)
if len(existing_standard_answer_categories) != len(standard_answer_category_ids):
raise ValueError(
f"Some or all categories with ids {standard_answer_category_ids} do not exist"
)
slack_bot_config = SlackBotConfig(
persona_id=persona_id,
channel_config=channel_config,
response_type=response_type,
standard_answer_categories=existing_standard_answer_categories,
)
db_session.add(slack_bot_config)
db_session.commit()
@ -90,6 +102,7 @@ def update_slack_bot_config(
persona_id: int | None,
channel_config: ChannelConfig,
response_type: SlackBotResponseType,
standard_answer_category_ids: list[int],
db_session: Session,
) -> SlackBotConfig:
slack_bot_config = db_session.scalar(
@ -99,6 +112,16 @@ def update_slack_bot_config(
raise ValueError(
f"Unable to find slack bot config with ID {slack_bot_config_id}"
)
existing_standard_answer_categories = fetch_standard_answer_categories_by_ids(
standard_answer_category_ids=standard_answer_category_ids,
db_session=db_session,
)
if len(existing_standard_answer_categories) != len(standard_answer_category_ids):
raise ValueError(
f"Some or all categories with ids {standard_answer_category_ids} do not exist"
)
# get the existing persona id before updating the object
existing_persona_id = slack_bot_config.persona_id
@ -108,6 +131,9 @@ def update_slack_bot_config(
slack_bot_config.persona_id = persona_id
slack_bot_config.channel_config = channel_config
slack_bot_config.response_type = response_type
slack_bot_config.standard_answer_categories = list(
existing_standard_answer_categories
)
# if the persona has changed, then clean up the old persona
if persona_id != existing_persona_id and existing_persona_id:

View File

@ -0,0 +1,228 @@
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 check_category_validity(category_name: str) -> bool:
"""If a category name is too long, it should not be used (it will cause an error in Postgres
as the unique constraint can only apply to entries that are less than 2704 bytes).
Additionally, extremely long categories are not really usable / useful."""
if len(category_name) > 255:
logger.error(
f"Category with name '{category_name}' is too long, cannot be used"
)
return False
return True
def insert_standard_answer_category(
category_name: str, db_session: Session
) -> StandardAnswerCategory:
if not check_category_validity(category_name):
raise ValueError(f"Invalid category name: {category_name}")
standard_answer_category = StandardAnswerCategory(name=category_name)
db_session.add(standard_answer_category)
db_session.commit()
return standard_answer_category
def insert_standard_answer(
keyword: str,
answer: str,
category_ids: list[int],
db_session: Session,
) -> StandardAnswer:
existing_categories = fetch_standard_answer_categories_by_ids(
standard_answer_category_ids=category_ids,
db_session=db_session,
)
if len(existing_categories) != len(category_ids):
raise ValueError(f"Some or all categories with ids {category_ids} do not exist")
standard_answer = StandardAnswer(
keyword=keyword,
answer=answer,
categories=existing_categories,
active=True,
)
db_session.add(standard_answer)
db_session.commit()
return standard_answer
def update_standard_answer(
standard_answer_id: int,
keyword: str,
answer: str,
category_ids: list[int],
db_session: Session,
) -> StandardAnswer:
standard_answer = db_session.scalar(
select(StandardAnswer).where(StandardAnswer.id == standard_answer_id)
)
if standard_answer is None:
raise ValueError(f"No standard answer with id {standard_answer_id}")
existing_categories = fetch_standard_answer_categories_by_ids(
standard_answer_category_ids=category_ids,
db_session=db_session,
)
if len(existing_categories) != len(category_ids):
raise ValueError(f"Some or all categories with ids {category_ids} do not exist")
standard_answer.keyword = keyword
standard_answer.answer = answer
standard_answer.categories = list(existing_categories)
db_session.commit()
return standard_answer
def remove_standard_answer(
standard_answer_id: int,
db_session: Session,
) -> None:
standard_answer = db_session.scalar(
select(StandardAnswer).where(StandardAnswer.id == standard_answer_id)
)
if standard_answer is None:
raise ValueError(f"No standard answer with id {standard_answer_id}")
standard_answer.active = False
db_session.commit()
def update_standard_answer_category(
standard_answer_category_id: int,
category_name: str,
db_session: Session,
) -> StandardAnswerCategory:
standard_answer_category = db_session.scalar(
select(StandardAnswerCategory).where(
StandardAnswerCategory.id == standard_answer_category_id
)
)
if standard_answer_category is None:
raise ValueError(
f"No standard answer category with id {standard_answer_category_id}"
)
if not check_category_validity(category_name):
raise ValueError(f"Invalid category name: {category_name}")
standard_answer_category.name = category_name
db_session.commit()
return standard_answer_category
def fetch_standard_answer_category(
standard_answer_category_id: int,
db_session: Session,
) -> StandardAnswerCategory | None:
return db_session.scalar(
select(StandardAnswerCategory).where(
StandardAnswerCategory.id == standard_answer_category_id
)
)
def fetch_standard_answer_categories_by_ids(
standard_answer_category_ids: list[int],
db_session: Session,
) -> Sequence[StandardAnswerCategory]:
return db_session.scalars(
select(StandardAnswerCategory).where(
StandardAnswerCategory.id.in_(standard_answer_category_ids)
)
).all()
def fetch_standard_answer_categories(
db_session: Session,
) -> Sequence[StandardAnswerCategory]:
return db_session.scalars(select(StandardAnswerCategory)).all()
def fetch_standard_answer(
standard_answer_id: int,
db_session: Session,
) -> StandardAnswer | None:
return db_session.scalar(
select(StandardAnswer).where(StandardAnswer.id == standard_answer_id)
)
def find_matching_standard_answers(
id_in: list[int],
query: str,
db_session: Session,
) -> list[StandardAnswer]:
stmt = (
select(StandardAnswer)
.where(StandardAnswer.active.is_(True))
.where(StandardAnswer.id.in_(id_in))
)
possible_standard_answers = db_session.scalars(stmt).all()
matching_standard_answers: list[StandardAnswer] = []
for standard_answer in possible_standard_answers:
# 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)
return matching_standard_answers
def fetch_standard_answers(db_session: Session) -> Sequence[StandardAnswer]:
return db_session.scalars(
select(StandardAnswer).where(StandardAnswer.active.is_(True))
).all()
def create_initial_default_standard_answer_category(db_session: Session) -> None:
default_category_id = 0
default_category_name = "General"
default_category = fetch_standard_answer_category(
standard_answer_category_id=default_category_id,
db_session=db_session,
)
if default_category is not None:
if default_category.name != default_category_name:
raise ValueError(
"DB is not in a valid initial state. "
"Default standard answer category does not have expected name."
)
return
standard_answer_category = StandardAnswerCategory(
id=default_category_id,
name=default_category_name,
)
db_session.add(standard_answer_category)
db_session.commit()

View File

@ -46,6 +46,7 @@ from danswer.db.engine import warm_up_connections
from danswer.db.index_attempt import cancel_indexing_attempts_past_model
from danswer.db.index_attempt import expire_index_attempts
from danswer.db.persona import delete_old_default_personas
from danswer.db.standard_answer import create_initial_default_standard_answer_category
from danswer.db.swap_index import check_index_swap
from danswer.document_index.factory import get_default_document_index
from danswer.llm.llm_initialization import load_llm_providers
@ -71,6 +72,7 @@ from danswer.server.manage.llm.api import admin_router as llm_admin_router
from danswer.server.manage.llm.api import basic_router as llm_router
from danswer.server.manage.secondary_index import router as secondary_index_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
@ -207,6 +209,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
create_initial_default_connector(db_session)
associate_default_cc_pair(db_session)
logger.info("Verifying default standard answer category exists.")
create_initial_default_standard_answer_category(db_session)
logger.info("Loading LLM providers from env variables")
load_llm_providers(db_session)
@ -273,6 +278,7 @@ 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, prompt_router)

View File

@ -12,6 +12,8 @@ from danswer.db.models import AllowedAnswerFilters
from danswer.db.models import ChannelConfig
from danswer.db.models import SlackBotConfig as SlackBotConfigModel
from danswer.db.models import SlackBotResponseType
from danswer.db.models import StandardAnswer as StandardAnswerModel
from danswer.db.models import StandardAnswerCategory as StandardAnswerCategoryModel
from danswer.indexing.models import EmbeddingModelDetail
from danswer.server.features.persona.models import PersonaSnapshot
from danswer.server.models import FullUserSnapshot
@ -84,6 +86,57 @@ class HiddenUpdateRequest(BaseModel):
hidden: bool
class StandardAnswerCategoryCreationRequest(BaseModel):
name: str
class StandardAnswerCategory(BaseModel):
id: int
name: str
@classmethod
def from_model(
cls, standard_answer_category: StandardAnswerCategoryModel
) -> "StandardAnswerCategory":
return cls(
id=standard_answer_category.id,
name=standard_answer_category.name,
)
class StandardAnswer(BaseModel):
id: int
keyword: str
answer: str
categories: list[StandardAnswerCategory]
@classmethod
def from_model(cls, standard_answer_model: StandardAnswerModel) -> "StandardAnswer":
return cls(
id=standard_answer_model.id,
keyword=standard_answer_model.keyword,
answer=standard_answer_model.answer,
categories=[
StandardAnswerCategory.from_model(standard_answer_category_model)
for standard_answer_category_model in standard_answer_model.categories
],
)
class StandardAnswerCreationRequest(BaseModel):
keyword: str
answer: str
categories: list[int]
@validator("categories", pre=True)
def validate_categories(cls, value: list[int]) -> list[int]:
if len(value) < 1:
raise ValueError(
"At least one category must be attached to a standard answer"
)
return value
class SlackBotTokens(BaseModel):
bot_token: str
app_token: str
@ -109,6 +162,7 @@ class SlackBotConfigCreationRequest(BaseModel):
# list of user emails
follow_up_tags: list[str] | None = None
response_type: SlackBotResponseType
standard_answer_categories: list[int] = []
@validator("answer_filters", pre=True)
def validate_filters(cls, value: list[str]) -> list[str]:
@ -133,6 +187,7 @@ class SlackBotConfig(BaseModel):
persona: PersonaSnapshot | None
channel_config: ChannelConfig
response_type: SlackBotResponseType
standard_answer_categories: list[StandardAnswerCategory]
@classmethod
def from_model(
@ -149,6 +204,10 @@ class SlackBotConfig(BaseModel):
),
channel_config=slack_bot_config_model.channel_config,
response_type=slack_bot_config_model.response_type,
standard_answer_categories=[
StandardAnswerCategory.from_model(standard_answer_category_model)
for standard_answer_category_model in slack_bot_config_model.standard_answer_categories
],
)

View File

@ -113,6 +113,7 @@ def create_slack_bot_config(
persona_id=persona_id,
channel_config=channel_config,
response_type=slack_bot_config_creation_request.response_type,
standard_answer_category_ids=slack_bot_config_creation_request.standard_answer_categories,
db_session=db_session,
)
return SlackBotConfig.from_model(slack_bot_config_model)
@ -171,6 +172,7 @@ def patch_slack_bot_config(
persona_id=persona_id,
channel_config=channel_config,
response_type=slack_bot_config_creation_request.response_type,
standard_answer_category_ids=slack_bot_config_creation_request.standard_answer_categories,
db_session=db_session,
)
return SlackBotConfig.from_model(slack_bot_config_model)

View File

@ -0,0 +1,139 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.orm import Session
from danswer.auth.users import current_admin_user
from danswer.db.engine import get_session
from danswer.db.models import User
from danswer.db.standard_answer import fetch_standard_answer
from danswer.db.standard_answer import fetch_standard_answer_categories
from danswer.db.standard_answer import fetch_standard_answer_category
from danswer.db.standard_answer import fetch_standard_answers
from danswer.db.standard_answer import insert_standard_answer
from danswer.db.standard_answer import insert_standard_answer_category
from danswer.db.standard_answer import remove_standard_answer
from danswer.db.standard_answer import update_standard_answer
from danswer.db.standard_answer import update_standard_answer_category
from danswer.server.manage.models import StandardAnswer
from danswer.server.manage.models import StandardAnswerCategory
from danswer.server.manage.models import StandardAnswerCategoryCreationRequest
from danswer.server.manage.models import StandardAnswerCreationRequest
router = APIRouter(prefix="/manage")
@router.post("/admin/standard-answer")
def create_standard_answer(
standard_answer_creation_request: StandardAnswerCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> StandardAnswer:
standard_answer_model = insert_standard_answer(
keyword=standard_answer_creation_request.keyword,
answer=standard_answer_creation_request.answer,
category_ids=standard_answer_creation_request.categories,
db_session=db_session,
)
return StandardAnswer.from_model(standard_answer_model)
@router.get("/admin/standard-answer")
def list_standard_answers(
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> list[StandardAnswer]:
standard_answer_models = fetch_standard_answers(db_session=db_session)
return [
StandardAnswer.from_model(standard_answer_model)
for standard_answer_model in standard_answer_models
]
@router.patch("/admin/standard-answer/{standard_answer_id}")
def patch_standard_answer(
standard_answer_id: int,
standard_answer_creation_request: StandardAnswerCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> StandardAnswer:
existing_standard_answer = fetch_standard_answer(
standard_answer_id=standard_answer_id,
db_session=db_session,
)
if existing_standard_answer is None:
raise HTTPException(status_code=404, detail="Standard answer not found")
standard_answer_model = update_standard_answer(
standard_answer_id=standard_answer_id,
keyword=standard_answer_creation_request.keyword,
answer=standard_answer_creation_request.answer,
category_ids=standard_answer_creation_request.categories,
db_session=db_session,
)
return StandardAnswer.from_model(standard_answer_model)
@router.delete("/admin/standard-answer/{standard_answer_id}")
def delete_standard_answer(
standard_answer_id: int,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> None:
return remove_standard_answer(
standard_answer_id=standard_answer_id,
db_session=db_session,
)
@router.post("/admin/standard-answer/category")
def create_standard_answer_category(
standard_answer_category_creation_request: StandardAnswerCategoryCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> StandardAnswerCategory:
standard_answer_category_model = insert_standard_answer_category(
category_name=standard_answer_category_creation_request.name,
db_session=db_session,
)
return StandardAnswerCategory.from_model(standard_answer_category_model)
@router.get("/admin/standard-answer/category")
def list_standard_answer_categories(
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> list[StandardAnswerCategory]:
standard_answer_category_models = fetch_standard_answer_categories(
db_session=db_session
)
return [
StandardAnswerCategory.from_model(standard_answer_category_model)
for standard_answer_category_model in standard_answer_category_models
]
@router.patch("/admin/standard-answer/category/{standard_answer_category_id}")
def patch_standard_answer_category(
standard_answer_category_id: int,
standard_answer_category_creation_request: StandardAnswerCategoryCreationRequest,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> StandardAnswerCategory:
existing_standard_answer_category = fetch_standard_answer_category(
standard_answer_category_id=standard_answer_category_id,
db_session=db_session,
)
if existing_standard_answer_category is None:
raise HTTPException(
status_code=404, detail="Standard answer category not found"
)
standard_answer_category_model = update_standard_answer_category(
standard_answer_category_id=standard_answer_category_id,
category_name=standard_answer_category_creation_request.name,
db_session=db_session,
)
return StandardAnswerCategory.from_model(standard_answer_category_model)

251
web/package-lock.json generated
View File

@ -38,6 +38,7 @@
"react-icons": "^4.8.0",
"react-loader-spinner": "^5.4.5",
"react-markdown": "^9.0.1",
"react-select": "^5.8.0",
"rehype-prism-plus": "^2.0.0",
"remark-gfm": "^4.0.0",
"semver": "^7.5.4",
@ -559,6 +560,46 @@
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
"integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.1",
"@emotion/memoize": "^0.8.1",
"@emotion/serialize": "^1.1.2",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
"find-root": "^1.1.0",
"source-map": "^0.5.7",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
"node_modules/@emotion/cache": {
"version": "11.11.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz",
"integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==",
"dependencies": {
"@emotion/memoize": "^0.8.1",
"@emotion/sheet": "^1.2.2",
"@emotion/utils": "^1.2.1",
"@emotion/weak-memoize": "^0.3.1",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
"integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ=="
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
@ -572,6 +613,51 @@
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
"integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
},
"node_modules/@emotion/react": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz",
"integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.11.0",
"@emotion/cache": "^11.11.0",
"@emotion/serialize": "^1.1.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.0.1",
"@emotion/utils": "^1.2.1",
"@emotion/weak-memoize": "^0.3.1",
"hoist-non-react-statics": "^3.3.1"
},
"peerDependencies": {
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@emotion/serialize": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.4.tgz",
"integrity": "sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==",
"dependencies": {
"@emotion/hash": "^0.9.1",
"@emotion/memoize": "^0.8.1",
"@emotion/unitless": "^0.8.1",
"@emotion/utils": "^1.2.1",
"csstype": "^3.0.2"
}
},
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
},
"node_modules/@emotion/sheet": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz",
"integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA=="
},
"node_modules/@emotion/stylis": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz",
@ -582,6 +668,24 @@
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz",
"integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/utils": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz",
"integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg=="
},
"node_modules/@emotion/weak-memoize": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz",
"integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww=="
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
@ -1765,6 +1869,11 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q=="
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="
},
"node_modules/@types/prismjs": {
"version": "1.26.4",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.4.tgz",
@ -1793,6 +1902,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz",
@ -2303,6 +2420,20 @@
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz",
"integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg=="
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/babel-plugin-styled-components": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.4.tgz",
@ -2522,7 +2653,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -2741,6 +2871,29 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"peer": true
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"engines": {
"node": ">= 6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -3190,6 +3343,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/error-ex/node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
},
"node_modules/es-abstract": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz",
@ -3360,7 +3526,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": {
"node": ">=10"
},
@ -3906,6 +4071,11 @@
"node": ">=8"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -4556,7 +4726,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
"dev": true,
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@ -5140,6 +5309,11 @@
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
"dev": true
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -5578,6 +5752,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -8918,7 +9097,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"dependencies": {
"callsites": "^3.0.0"
},
@ -8950,6 +9128,23 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-numeric-range": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz",
@ -9016,7 +9211,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -9546,6 +9740,26 @@
}
}
},
"node_modules/react-select": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.0.tgz",
"integrity": "sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.1.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-smooth": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz",
@ -9849,7 +10063,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"engines": {
"node": ">=4"
}
@ -10169,6 +10382,14 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@ -10489,6 +10710,11 @@
"resolved": "https://registry.npmjs.org/styled-tools/-/styled-tools-1.7.2.tgz",
"integrity": "sha512-IjLxzM20RMwAsx8M1QoRlCG/Kmq8lKzCGyospjtSXt/BTIIcvgTonaxQAsKnBrsZNwhpHzO9ADx5te0h76ILVg=="
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -11064,6 +11290,19 @@
}
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
"integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",

View File

@ -39,6 +39,7 @@
"react-icons": "^4.8.0",
"react-loader-spinner": "^5.4.5",
"react-markdown": "^9.0.1",
"react-select": "^5.8.0",
"rehype-prism-plus": "^2.0.0",
"remark-gfm": "^4.0.0",
"semver": "^7.5.4",

View File

@ -3,7 +3,11 @@
import { ArrayHelpers, FieldArray, Form, Formik } from "formik";
import * as Yup from "yup";
import { usePopup } from "@/components/admin/connectors/Popup";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import {
DocumentSet,
SlackBotConfig,
StandardAnswerCategory,
} from "@/lib/types";
import {
BooleanFormField,
SectionHeader,
@ -31,20 +35,22 @@ import { useRouter } from "next/navigation";
import { Persona } from "../assistants/interfaces";
import { useState } from "react";
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";
import MultiSelectDropdown from "@/components/MultiSelectDropdown";
export const SlackBotCreationForm = ({
documentSets,
personas,
standardAnswerCategories,
existingSlackBotConfig,
}: {
documentSets: DocumentSet[];
personas: Persona[];
standardAnswerCategories: StandardAnswerCategory[];
existingSlackBotConfig?: SlackBotConfig;
}) => {
const isUpdate = existingSlackBotConfig !== undefined;
const { popup, setPopup } = usePopup();
const router = useRouter();
const existingSlackBotUsesPersona = existingSlackBotConfig?.persona
? !isPersonaASlackBotPersona(existingSlackBotConfig.persona)
: false;
@ -95,6 +101,9 @@ export const SlackBotCreationForm = ({
? existingSlackBotConfig.persona.id
: null,
response_type: existingSlackBotConfig?.response_type || "citations",
standard_answer_categories: existingSlackBotConfig
? existingSlackBotConfig.standard_answer_categories
: [],
}}
validationSchema={Yup.object().shape({
channel_names: Yup.array().of(Yup.string()),
@ -110,6 +119,7 @@ export const SlackBotCreationForm = ({
follow_up_tags: Yup.array().of(Yup.string()),
document_sets: Yup.array().of(Yup.number()),
persona_id: Yup.number().nullable(),
standard_answer_categories: Yup.array(),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
@ -129,6 +139,9 @@ export const SlackBotCreationForm = ({
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(slackGroupName)
),
usePersona: usingPersonas,
standard_answer_categories: values.standard_answer_categories.map(
(category) => category.id
),
};
if (!cleanedValues.still_need_help_enabled) {
cleanedValues.follow_up_tags = undefined;
@ -137,7 +150,6 @@ export const SlackBotCreationForm = ({
cleanedValues.follow_up_tags = [];
}
}
let response;
if (isUpdate) {
response = await updateSlackBotConfig(
@ -162,7 +174,7 @@ export const SlackBotCreationForm = ({
}
}}
>
{({ isSubmitting, values }) => (
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<div className="px-6 pb-6">
<SectionHeader>The Basics</SectionHeader>
@ -392,6 +404,45 @@ export const SlackBotCreationForm = ({
<Divider />
<div>
<SectionHeader>
[Optional] Standard Answer Categories
</SectionHeader>
<div className="w-4/12">
<MultiSelectDropdown
name="standard_answer_categories"
label=""
onChange={(selected_options) => {
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(),
})
)}
/>
</div>
</div>
<Divider />
<div className="flex">
<Button
type="submit"

View File

@ -3,7 +3,11 @@ import { CPUIcon } from "@/components/icons/icons";
import { SlackBotCreationForm } from "../SlackBotConfigCreationForm";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import {
DocumentSet,
SlackBotConfig,
StandardAnswerCategory,
} from "@/lib/types";
import { Text } from "@tremor/react";
import { BackButton } from "@/components/BackButton";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
@ -17,16 +21,19 @@ async function Page({ params }: { params: { id: string } }) {
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,
];
if (!slackBotsResponse.ok) {
@ -70,6 +77,18 @@ async function Page({ params }: { params: { id: string } }) {
);
}
if (!standardAnswerCategoriesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch standard answer categories - ${await standardAnswerCategoriesResponse.text()}`}
/>
);
}
const standardAnswerCategories =
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<div className="container mx-auto">
<InstantSSRAutoRefresh />
@ -88,6 +107,7 @@ async function Page({ params }: { params: { id: string } }) {
<SlackBotCreationForm
documentSets={documentSets}
personas={assistants}
standardAnswerCategories={standardAnswerCategories}
existingSlackBotConfig={slackBotConfig}
/>
</div>

View File

@ -18,6 +18,7 @@ interface SlackBotConfigCreationRequest {
follow_up_tags?: string[];
usePersona: boolean;
response_type: SlackBotResponseType;
standard_answer_categories: number[];
}
const buildFiltersFromCreationRequest = (
@ -48,6 +49,7 @@ const buildRequestBodyFromCreationRequest = (
? { persona_id: creationRequest.persona_id }
: { document_sets: creationRequest.document_sets }),
response_type: creationRequest.response_type,
standard_answer_categories: creationRequest.standard_answer_categories,
});
};

View File

@ -3,7 +3,7 @@ import { CPUIcon } from "@/components/icons/icons";
import { SlackBotCreationForm } from "../SlackBotConfigCreationForm";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet } from "@/lib/types";
import { DocumentSet, StandardAnswerCategory } from "@/lib/types";
import { BackButton } from "@/components/BackButton";
import { Text } from "@tremor/react";
import {
@ -12,9 +12,20 @@ import {
} from "@/lib/assistants/fetchAssistantsSS";
async function Page() {
const tasks = [fetchSS("/manage/document-set"), fetchAssistantsSS()];
const [documentSetsResponse, [assistants, assistantsFetchError]] =
(await Promise.all(tasks)) as [Response, FetchAssistantsResponse];
const tasks = [
fetchSS("/manage/document-set"),
fetchAssistantsSS(),
fetchSS("/manage/admin/standard-answer/category"),
];
const [
documentSetsResponse,
[assistants, assistantsFetchError],
standardAnswerCategoriesResponse,
] = (await Promise.all(tasks)) as [
Response,
FetchAssistantsResponse,
Response,
];
if (!documentSetsResponse.ok) {
return (
@ -35,6 +46,18 @@ async function Page() {
);
}
if (!standardAnswerCategoriesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch standard answer categories - ${await standardAnswerCategoriesResponse.text()}`}
/>
);
}
const standardAnswerCategories =
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<div className="container mx-auto">
<BackButton />
@ -48,7 +71,11 @@ async function Page() {
DanswerBot behaves in the specified channels.
</Text>
<SlackBotCreationForm documentSets={documentSets} personas={assistants} />
<SlackBotCreationForm
documentSets={documentSets}
personas={assistants}
standardAnswerCategories={standardAnswerCategories}
/>
</div>
);
}

View File

@ -276,7 +276,7 @@ const Main = () => {
<div className="mb-2"></div>
<Link className="flex mb-3" href="/admin/bot/new">
<Link className="flex mb-3 w-fit" href="/admin/bot/new">
<Button className="my-auto" color="green" size="xs">
New Slack Bot Configuration
</Button>

View File

@ -0,0 +1,149 @@
"use client";
import { usePopup } from "@/components/admin/connectors/Popup";
import { StandardAnswerCategory, StandardAnswer } from "@/lib/types";
import { Button, Card } from "@tremor/react";
import { Form, Formik } from "formik";
import { useRouter } from "next/navigation";
import * as Yup from "yup";
import {
createStandardAnswer,
createStandardAnswerCategory,
updateStandardAnswer,
} from "./lib";
import {
TextFormField,
MarkdownFormField,
} from "@/components/admin/connectors/Field";
import MultiSelectDropdown from "@/components/MultiSelectDropdown";
export const StandardAnswerCreationForm = ({
standardAnswerCategories,
existingStandardAnswer,
}: {
standardAnswerCategories: StandardAnswerCategory[];
existingStandardAnswer?: StandardAnswer;
}) => {
const isUpdate = existingStandardAnswer !== undefined;
const { popup, setPopup } = usePopup();
const router = useRouter();
return (
<div>
<Card>
{popup}
<Formik
initialValues={{
keyword: existingStandardAnswer
? existingStandardAnswer.keyword
: "",
answer: existingStandardAnswer ? existingStandardAnswer.answer : "",
categories: existingStandardAnswer
? existingStandardAnswer.categories
: [],
}}
validationSchema={Yup.object().shape({
keyword: Yup.string()
.required("Keyword or phrase is required")
.max(255)
.min(1),
answer: Yup.string().required("Answer is required").min(1),
categories: Yup.array()
.required()
.min(1, "At least one category is required"),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
const cleanedValues = {
...values,
categories: values.categories.map((category) => category.id),
};
let response;
if (isUpdate) {
response = await updateStandardAnswer(
existingStandardAnswer.id,
cleanedValues
);
} else {
response = await createStandardAnswer(cleanedValues);
}
formikHelpers.setSubmitting(false);
if (response.ok) {
router.push(`/admin/standard-answer?u=${Date.now()}`);
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: isUpdate
? `Error updating Standard Answer - ${errorMsg}`
: `Error creating Standard Answer - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<TextFormField
name="keyword"
label="Keywords"
tooltip="If all specified keywords are in the question, then we will respond with the answer below"
placeholder="e.g. Wifi Password"
autoCompleteDisabled={true}
/>
<MarkdownFormField
name="answer"
label="Answer"
placeholder="The answer in markdown"
/>
<div className="w-4/12">
<MultiSelectDropdown
name="categories"
label="Categories:"
onChange={(selected_options) => {
const selected_categories = selected_options.map(
(option) => {
return { id: Number(option.value), name: option.label };
}
);
setFieldValue("categories", selected_categories);
}}
creatable={true}
onCreate={async (created_name) => {
const response = await createStandardAnswerCategory({
name: created_name,
});
const newCategory = await response.json();
return {
label: newCategory.name,
value: newCategory.id.toString(),
};
}}
options={standardAnswerCategories.map((category) => ({
label: category.name,
value: category.id.toString(),
}))}
initialSelectedOptions={values.categories.map((category) => ({
label: category.name,
value: category.id.toString(),
}))}
/>
</div>
<div className="p-4 flex">
<Button
type="submit"
disabled={isSubmitting}
className="mx-auto w-64"
>
{isUpdate ? "Update!" : "Create!"}
</Button>
</div>
</Form>
)}
</Formik>
</Card>
</div>
);
};

View File

@ -0,0 +1,67 @@
import { AdminPageTitle } from "@/components/admin/Title";
import { StandardAnswerCreationForm } from "@/app/admin/standard-answer/StandardAnswerCreationForm";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { BackButton } from "@/components/BackButton";
import { Text } from "@tremor/react";
import { ClipboardIcon } from "@/components/icons/icons";
import { StandardAnswer, StandardAnswerCategory } from "@/lib/types";
async function Page({ params }: { params: { id: string } }) {
const tasks = [
fetchSS("/manage/admin/standard-answer"),
fetchSS(`/manage/admin/standard-answer/category`),
];
const [standardAnswersResponse, standardAnswerCategoriesResponse] =
await Promise.all(tasks);
if (!standardAnswersResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch standard answers - ${await standardAnswersResponse.text()}`}
/>
);
}
const allStandardAnswers =
(await standardAnswersResponse.json()) as StandardAnswer[];
const standardAnswer = allStandardAnswers.find(
(answer) => answer.id.toString() === params.id
);
if (!standardAnswer) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Did not find standard answer with ID: ${params.id}`}
/>
);
}
if (!standardAnswerCategoriesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch standard answer categories - ${await standardAnswerCategoriesResponse.text()}`}
/>
);
}
const standardAnswerCategories =
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<div className="container mx-auto">
<BackButton />
<AdminPageTitle
title="Edit Standard Answer"
icon={<ClipboardIcon size={32} />}
/>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
existingStandardAnswer={standardAnswer}
/>
</div>
);
}
export default Page;

View File

@ -0,0 +1,26 @@
import { errorHandlingFetcher } from "@/lib/fetcher";
import { StandardAnswerCategory, StandardAnswer } from "@/lib/types";
import useSWR, { mutate } from "swr";
export const useStandardAnswerCategories = () => {
const url = "/api/manage/admin/standard-answer/category";
const swrResponse = useSWR<StandardAnswerCategory[]>(
url,
errorHandlingFetcher
);
return {
...swrResponse,
refreshStandardAnswerCategories: () => mutate(url),
};
};
export const useStandardAnswers = () => {
const url = "/api/manage/admin/standard-answer";
const swrResponse = useSWR<StandardAnswer[]>(url, errorHandlingFetcher);
return {
...swrResponse,
refreshStandardAnswers: () => mutate(url),
};
};

View File

@ -0,0 +1,86 @@
export interface StandardAnswerCategoryCreationRequest {
name: string;
}
export interface StandardAnswerCreationRequest {
keyword: string;
answer: string;
categories: number[];
}
const buildRequestBodyFromStandardAnswerCategoryCreationRequest = (
request: StandardAnswerCategoryCreationRequest
) => {
return JSON.stringify({
name: request.name,
});
};
export const createStandardAnswerCategory = async (
request: StandardAnswerCategoryCreationRequest
) => {
return fetch("/api/manage/admin/standard-answer/category", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: buildRequestBodyFromStandardAnswerCategoryCreationRequest(request),
});
};
export const updateStandardAnswerCategory = async (
id: number,
request: StandardAnswerCategoryCreationRequest
) => {
return fetch(`/api/manage/admin/standard-answer/category/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: buildRequestBodyFromStandardAnswerCategoryCreationRequest(request),
});
};
const buildRequestBodyFromStandardAnswerCreationRequest = (
request: StandardAnswerCreationRequest
) => {
return JSON.stringify({
keyword: request.keyword,
answer: request.answer,
categories: request.categories,
});
};
export const createStandardAnswer = async (
request: StandardAnswerCreationRequest
) => {
return fetch("/api/manage/admin/standard-answer", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: buildRequestBodyFromStandardAnswerCreationRequest(request),
});
};
export const updateStandardAnswer = async (
id: number,
request: StandardAnswerCreationRequest
) => {
return fetch(`/api/manage/admin/standard-answer/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: buildRequestBodyFromStandardAnswerCreationRequest(request),
});
};
export const deleteStandardAnswer = async (id: number) => {
return fetch(`/api/manage/admin/standard-answer/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
};

View File

@ -0,0 +1,41 @@
import { AdminPageTitle } from "@/components/admin/Title";
import { StandardAnswerCreationForm } from "@/app/admin/standard-answer/StandardAnswerCreationForm";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { BackButton } from "@/components/BackButton";
import { Text } from "@tremor/react";
import { ClipboardIcon } from "@/components/icons/icons";
import { StandardAnswerCategory } from "@/lib/types";
async function Page() {
const standardAnswerCategoriesResponse = await fetchSS(
"/manage/admin/standard-answer/category"
);
if (!standardAnswerCategoriesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch standard answer categories - ${await standardAnswerCategoriesResponse.text()}`}
/>
);
}
const standardAnswerCategories =
(await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[];
return (
<div className="container mx-auto">
<BackButton />
<AdminPageTitle
title="New Standard Answer"
icon={<ClipboardIcon size={32} />}
/>
<StandardAnswerCreationForm
standardAnswerCategories={standardAnswerCategories}
/>
</div>
);
}
export default Page;

View File

@ -0,0 +1,399 @@
"use client";
import { AdminPageTitle } from "@/components/admin/Title";
import { ClipboardIcon, EditIcon, TrashIcon } from "@/components/icons/icons";
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { useStandardAnswers, useStandardAnswerCategories } from "./hooks";
import { ThreeDotsLoader } from "@/components/Loading";
import { ErrorCallout } from "@/components/ErrorCallout";
import { Button, Divider, Text } from "@tremor/react";
import Link from "next/link";
import { StandardAnswer, StandardAnswerCategory } from "@/lib/types";
import { MagnifyingGlass } from "@phosphor-icons/react";
import { useState } from "react";
import {
Table,
TableHead,
TableRow,
TableHeaderCell,
TableBody,
TableCell,
} from "@tremor/react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { deleteStandardAnswer } from "./lib";
import { FilterDropdown } from "@/components/search/filtering/FilterDropdown";
import { FiTag } from "react-icons/fi";
import { SelectedBubble } from "@/components/search/filtering/Filters";
import { PageSelector } from "@/components/PageSelector";
const NUM_RESULTS_PER_PAGE = 10;
type Displayable = JSX.Element | string;
const RowTemplate = ({
id,
entries,
}: {
id: number;
entries: [Displayable, Displayable, Displayable, Displayable, Displayable];
}) => {
return (
<TableRow key={id}>
<TableCell className="w-1/24">{entries[0]}</TableCell>
<TableCell className="w-2/12">{entries[1]}</TableCell>
<TableCell className="w-2/12">{entries[2]}</TableCell>
<TableCell className="w-7/12 overflow-auto">{entries[3]}</TableCell>
<TableCell className="w-1/24">{entries[4]}</TableCell>
</TableRow>
);
};
const CategoryBubble = ({
name,
onDelete,
}: {
name: string;
onDelete?: () => void;
}) => (
<span
className={`
inline-block
px-2
py-1
mr-1
mb-1
text-xs
font-semibold
text-emphasis
bg-hover
rounded-full
items-center
w-fit
${onDelete ? "cursor-pointer" : ""}
`}
onClick={onDelete}
>
{name}
{onDelete && (
<button
className="ml-1 text-subtle hover:text-emphasis"
aria-label="Remove category"
>
&times;
</button>
)}
</span>
);
const StandardAnswersTableRow = ({
standardAnswer,
handleDelete,
}: {
standardAnswer: StandardAnswer;
handleDelete: (id: number) => void;
}) => {
return (
<RowTemplate
id={standardAnswer.id}
entries={[
<Link
key={`edit-${standardAnswer.id}`}
href={`/admin/standard-answer/${standardAnswer.id}`}
>
<EditIcon />
</Link>,
<div key={`categories-${standardAnswer.id}`}>
{standardAnswer.categories.map((category) => (
<CategoryBubble key={category.id} name={category.name} />
))}
</div>,
standardAnswer.keyword,
<ReactMarkdown
key={`answer-${standardAnswer.id}`}
className="prose"
remarkPlugins={[remarkGfm]}
>
{standardAnswer.answer}
</ReactMarkdown>,
<div
key={`delete-${standardAnswer.id}`}
className="cursor-pointer"
onClick={() => handleDelete(standardAnswer.id)}
>
<TrashIcon />
</div>,
]}
/>
);
};
const StandardAnswersTable = ({
standardAnswers,
standardAnswerCategories,
refresh,
setPopup,
}: {
standardAnswers: StandardAnswer[];
standardAnswerCategories: StandardAnswerCategory[];
refresh: () => void;
setPopup: (popup: PopupSpec | null) => void;
}) => {
const [query, setQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [selectedCategories, setSelectedCategories] = useState<
StandardAnswerCategory[]
>([]);
const columns = [
{ name: "", key: "edit" },
{ name: "Categories", key: "category" },
{ name: "Keyword/Phrase", key: "keyword" },
{ name: "Answer", key: "answer" },
{ name: "", key: "delete" },
];
const filteredStandardAnswers = standardAnswers.filter((standardAnswer) => {
const { answer, id, categories, ...fieldsToSearch } = standardAnswer;
const cleanedQuery = query.toLowerCase();
const searchMatch = Object.values(fieldsToSearch).some((value) => {
return value.toLowerCase().includes(cleanedQuery);
});
const categoryMatch =
selectedCategories.length == 0 ||
selectedCategories.some((category) =>
categories.map((c) => c.id).includes(category.id)
);
return searchMatch && categoryMatch;
});
const totalPages = Math.ceil(
filteredStandardAnswers.length / NUM_RESULTS_PER_PAGE
);
const startIndex = (currentPage - 1) * NUM_RESULTS_PER_PAGE;
const endIndex = startIndex + NUM_RESULTS_PER_PAGE;
const paginatedStandardAnswers = filteredStandardAnswers.slice(
startIndex,
endIndex
);
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const handleDelete = async (id: number) => {
const response = await deleteStandardAnswer(id);
if (response.ok) {
setPopup({
message: `Standard answer ${id} deleted`,
type: "success",
});
} else {
const errorMsg = await response.text();
setPopup({
message: `Failed to delete standard answer - ${errorMsg}`,
type: "error",
});
}
refresh();
};
const handleCategorySelect = (category: StandardAnswerCategory) => {
setSelectedCategories((prev: StandardAnswerCategory[]) => {
const prevCategoryIds = prev.map((category) => category.id);
if (prevCategoryIds.includes(category.id)) {
return prev.filter((c) => c.id !== category.id);
}
return [...prev, category];
});
};
return (
<div className="justify-center py-2">
<div className="flex items-center w-full border-2 border-border rounded-lg px-4 py-2 focus-within:border-accent">
<MagnifyingGlass />
<textarea
autoFocus
className="flex-grow ml-2 h-6 bg-transparent outline-none placeholder-subtle overflow-hidden whitespace-normal resize-none"
role="textarea"
aria-multiline
placeholder="Find standard answers by keyword/phrase..."
value={query}
onChange={(event) => {
setQuery(event.target.value);
setCurrentPage(1);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
</div>
<div className="my-4 border-b border-border">
<FilterDropdown
options={standardAnswerCategories.map((category) => {
return {
key: category.name,
display: category.name,
};
})}
selected={selectedCategories.map((category) => category.name)}
handleSelect={(option) => {
handleCategorySelect(
standardAnswerCategories.find(
(category) => category.name === option.key
)!
);
}}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiTag size={16} />
</div>
}
defaultDisplay="All Categories"
/>
<div className="flex flex-wrap pb-4 mt-3">
{selectedCategories.map((category) => (
<CategoryBubble
key={category.id}
name={category.name}
onDelete={() => handleCategorySelect(category)}
/>
))}
</div>
</div>
<div className="mx-auto">
<Table>
<TableHead>
<TableRow>
{columns.map((column) => (
<TableHeaderCell key={column.key}>
{column.name}
</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{paginatedStandardAnswers.length > 0 ? (
paginatedStandardAnswers.map((item) => (
<StandardAnswersTableRow
key={item.id}
standardAnswer={item}
handleDelete={handleDelete}
/>
))
) : (
<RowTemplate id={0} entries={["", "", "", "", ""]} />
)}
</TableBody>
</Table>
{paginatedStandardAnswers.length === 0 && (
<div className="flex justify-center">
<Text>No matching standard answers found...</Text>
</div>
)}
{paginatedStandardAnswers.length > 0 && (
<div className="mt-4 flex justify-center">
<PageSelector
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
shouldScroll={true}
/>
</div>
)}
</div>
</div>
);
};
const Main = () => {
const { popup, setPopup } = usePopup();
const {
data: standardAnswers,
error: standardAnswersError,
isLoading: standardAnswersIsLoading,
refreshStandardAnswers,
} = useStandardAnswers();
const {
data: standardAnswerCategories,
error: standardAnswerCategoriesError,
isLoading: standardAnswerCategoriesIsLoading,
} = useStandardAnswerCategories();
if (standardAnswersIsLoading || standardAnswerCategoriesIsLoading) {
return <ThreeDotsLoader />;
}
if (standardAnswersError || !standardAnswers) {
return (
<ErrorCallout
errorTitle="Error loading standard answers"
errorMsg={
standardAnswersError.info?.message ||
standardAnswersError.message.info?.detail
}
/>
);
}
if (standardAnswerCategoriesError || !standardAnswerCategories) {
return (
<ErrorCallout
errorTitle="Error loading standard answer categories"
errorMsg={
standardAnswerCategoriesError.info?.message ||
standardAnswerCategoriesError.message.info?.detail
}
/>
);
}
return (
<div className="mb-8">
{popup}
<Text className="mb-2">
Here you can manage the standard answers that are used to answer
questions based on keywords or phrases.
</Text>
{standardAnswers.length == 0 && (
<Text className="mb-2">Add your first standard answer below!</Text>
)}
<div className="mb-2"></div>
<Link className="flex mb-3 mt-2 w-fit" href="/admin/standard-answer/new">
<Button className="my-auto" color="green" size="xs">
New Standard Answer
</Button>
</Link>
<Divider />
<div>
<StandardAnswersTable
standardAnswers={standardAnswers}
standardAnswerCategories={standardAnswerCategories}
refresh={refreshStandardAnswers}
setPopup={setPopup}
/>
</div>
</div>
);
};
const Page = () => {
return (
<div className="container mx-auto">
<AdminPageTitle
icon={<ClipboardIcon size={32} />}
title="Standard Answers"
/>
<Main />
</div>
);
};
export default Page;

View File

@ -0,0 +1,113 @@
import { useState } from "react";
import { Label, ManualErrorMessage } from "./admin/connectors/Field";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { ErrorMessage } from "formik";
interface Option {
value: string;
label: string;
}
interface MultiSelectDropdownProps {
name: string;
label: string;
options: Option[];
creatable: boolean;
initialSelectedOptions?: Option[];
direction?: "top" | "bottom";
onChange: (selected: Option[]) => void;
onCreate?: (created_name: string) => Promise<Option>;
error?: string;
}
const MultiSelectDropdown = ({
name,
label,
options,
creatable,
onChange,
onCreate,
error,
direction = "bottom",
initialSelectedOptions = [],
}: MultiSelectDropdownProps) => {
const [selectedOptions, setSelectedOptions] = useState<Option[]>(
initialSelectedOptions
);
const [allOptions, setAllOptions] = useState<Option[]>(options);
const [inputValue, setInputValue] = useState("");
const handleInputChange = (input: string) => {
setInputValue(input);
};
const handleChange = (selected: any) => {
setSelectedOptions(selected || []);
onChange(selected || []);
};
const handleCreateOption = async (inputValue: string) => {
if (creatable) {
if (!onCreate) {
console.error("onCreate is required for creatable");
return;
}
try {
const newOption = await onCreate(inputValue);
if (newOption) {
setAllOptions([...options, newOption]);
setSelectedOptions([...selectedOptions, newOption]);
onChange([...selectedOptions, newOption]);
}
} catch (error) {
console.error("Error creating option:", error);
}
} else {
return;
}
};
return (
<div className="flex flex-col space-y-4 mb-4">
<Label>{label}</Label>
{creatable ? (
<CreatableSelect
isMulti
options={allOptions}
value={selectedOptions}
onChange={handleChange}
onCreateOption={handleCreateOption}
onInputChange={handleInputChange}
inputValue={inputValue}
className="react-select-container"
classNamePrefix="react-select"
menuPlacement={direction}
/>
) : (
<Select
isMulti
options={allOptions}
value={selectedOptions}
onChange={handleChange}
onInputChange={handleInputChange}
inputValue={inputValue}
className="react-select-container"
classNamePrefix="react-select"
menuPlacement={direction}
/>
)}
{error ? (
<ManualErrorMessage>{error}</ManualErrorMessage>
) : (
<ErrorMessage
name={name}
component="div"
className="text-red-500 text-sm mt-1"
/>
)}
</div>
);
};
export default MultiSelectDropdown;

View File

@ -9,9 +9,9 @@ import {
RobotIcon,
ConnectorIcon,
GroupsIcon,
BarChartIcon,
DatabaseIcon,
KeyIcon,
ClipboardIcon,
} from "@/components/icons/icons";
import { User } from "@/lib/types";
import {
@ -157,6 +157,15 @@ export async function Layout({ children }: { children: React.ReactNode }) {
),
link: "/admin/tools",
},
{
name: (
<div className="flex">
<ClipboardIcon size={18} />
<div className="ml-1">Standard Answers</div>
</div>
),
link: "/admin/standard-answer",
},
],
},
{

View File

@ -17,6 +17,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@radix-ui/react-tooltip";
import ReactMarkdown from "react-markdown";
import { FaMarkdown } from "react-icons/fa";
import { useState } from "react";
import remarkGfm from "remark-gfm";
export function SectionHeader({
children,
@ -187,6 +191,75 @@ export function TextFormField({
);
}
interface MarkdownPreviewProps {
name: string;
label: string;
placeholder?: string;
error?: string;
}
export const MarkdownFormField = ({
name,
label,
error,
placeholder = "Enter your markdown here...",
}: MarkdownPreviewProps) => {
const [field, _] = useField(name);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const togglePreview = () => {
setIsPreviewOpen(!isPreviewOpen);
};
return (
<div className="flex flex-col space-y-4 mb-4">
<Label>{label}</Label>
<div className="border border-gray-300 rounded-md">
<div className="flex items-center justify-between px-4 py-2 bg-gray-100 rounded-t-md">
<div className="flex items-center space-x-2">
<FaMarkdown className="text-gray-500" />
<span className="text-sm font-semibold text-gray-600">
Markdown
</span>
</div>
<button
type="button"
onClick={togglePreview}
className="text-sm font-semibold text-gray-600 hover:text-gray-800 focus:outline-none"
>
{isPreviewOpen ? "Write" : "Preview"}
</button>
</div>
{isPreviewOpen ? (
<div className="p-4 border-t border-gray-300">
<ReactMarkdown className="prose" remarkPlugins={[remarkGfm]}>
{field.value}
</ReactMarkdown>
</div>
) : (
<div className="pt-2 px-2">
<textarea
{...field}
rows={2}
placeholder={placeholder}
className={`w-full p-2 border border-border rounded-md border-gray-300`}
/>
</div>
)}
</div>
{error ? (
<ManualErrorMessage>{error}</ManualErrorMessage>
) : (
<ErrorMessage
name={name}
component="div"
className="text-red-500 text-sm mt-1"
/>
)}
</div>
);
};
interface BooleanFormFieldProps {
name: string;
label: string;

View File

@ -19,6 +19,7 @@ import {
FiChevronsDown,
FiChevronsUp,
FiEdit2,
FiClipboard,
FiFile,
FiGlobe,
FiThumbsDown,
@ -314,6 +315,13 @@ export const CheckmarkIcon = ({
return <FiCheck size={size} className={className} />;
};
export const ClipboardIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <FiClipboard size={size} className={className} />;
};
export const AlertIcon = ({
size = 16,
className = defaultTailwindCSS,

View File

@ -4,6 +4,7 @@ import { CustomDropdown } from "../../Dropdown";
interface Option {
key: string;
display: string | JSX.Element;
displayName?: string;
}
export function FilterDropdown({

View File

@ -167,7 +167,7 @@ export function SourceSelector({
);
}
function SelectedBubble({
export function SelectedBubble({
children,
onClick,
}: {

View File

@ -514,6 +514,19 @@ export interface Tag {
source: ValidSources;
}
// STANDARD ANSWERS
export interface StandardAnswerCategory {
id: number;
name: string;
}
export interface StandardAnswer {
id: number;
keyword: string;
answer: string;
categories: StandardAnswerCategory[];
}
// SLACK BOT CONFIGS
export type AnswerFilterOption =
@ -537,6 +550,7 @@ export interface SlackBotConfig {
persona: Persona | null;
channel_config: ChannelConfig;
response_type: SlackBotResponseType;
standard_answer_categories: StandardAnswerCategory[];
}
export interface SlackBotTokens {