mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-04-09 20:39:29 +02:00
Standard Answers (#1753)
--------- Co-authored-by: druhinsgoel <druhin@danswer.ai>
This commit is contained in:
parent
f0888f2f61
commit
e06f8a0a4b
@ -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")
|
@ -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,
|
||||
|
@ -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):
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
@ -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
|
19
backend/danswer/danswerbot/slack/handlers/utils.py
Normal file
19
backend/danswer/danswerbot/slack/handlers/utils.py
Normal 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,
|
||||
)
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
228
backend/danswer/db/standard_answer.py
Normal file
228
backend/danswer/db/standard_answer.py
Normal 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()
|
@ -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)
|
||||
|
@ -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
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
139
backend/danswer/server/manage/standard_answer.py
Normal file
139
backend/danswer/server/manage/standard_answer.py
Normal 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
251
web/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
149
web/src/app/admin/standard-answer/StandardAnswerCreationForm.tsx
Normal file
149
web/src/app/admin/standard-answer/StandardAnswerCreationForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
67
web/src/app/admin/standard-answer/[id]/page.tsx
Normal file
67
web/src/app/admin/standard-answer/[id]/page.tsx
Normal 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;
|
26
web/src/app/admin/standard-answer/hooks.ts
Normal file
26
web/src/app/admin/standard-answer/hooks.ts
Normal 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),
|
||||
};
|
||||
};
|
86
web/src/app/admin/standard-answer/lib.ts
Normal file
86
web/src/app/admin/standard-answer/lib.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
41
web/src/app/admin/standard-answer/new/page.tsx
Normal file
41
web/src/app/admin/standard-answer/new/page.tsx
Normal 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;
|
399
web/src/app/admin/standard-answer/page.tsx
Normal file
399
web/src/app/admin/standard-answer/page.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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;
|
113
web/src/components/MultiSelectDropdown.tsx
Normal file
113
web/src/components/MultiSelectDropdown.tsx
Normal 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;
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -4,6 +4,7 @@ import { CustomDropdown } from "../../Dropdown";
|
||||
interface Option {
|
||||
key: string;
|
||||
display: string | JSX.Element;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export function FilterDropdown({
|
||||
|
@ -167,7 +167,7 @@ export function SourceSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedBubble({
|
||||
export function SelectedBubble({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user