From fa448ce97658b022725714de255129ff4711b861 Mon Sep 17 00:00:00 2001 From: sd109 Date: Fri, 30 Aug 2024 14:25:45 +0100 Subject: [PATCH] Remove unused files from helm-publish branch --- .github/pull_request_template.md | 25 - ...er-build-push-backend-container-on-tag.yml | 51 - ...ild-push-model-server-container-on-tag.yml | 43 - ...docker-build-push-web-container-on-tag.yml | 119 - .github/workflows/docker-tag-latest.yml | 32 - .github/workflows/helm-build-push.yml | 44 - .github/workflows/pr-python-checks.yml | 49 - .github/workflows/pr-python-tests.yml | 36 - .github/workflows/pr-quality-checks.yml | 22 - .github/workflows/run-it.yml | 172 - .gitignore | 9 - .pre-commit-config.yaml | 65 - .vscode/env_template.txt | 52 - .vscode/launch.template.jsonc | 145 - CONTRIBUTING.md | 191 - README.md | 130 +- backend/.dockerignore | 17 - backend/.gitignore | 11 - backend/.trivyignore | 46 - backend/Dockerfile | 108 - backend/Dockerfile.model_server | 54 - backend/alembic.ini | 108 - backend/alembic/README.md | 19 - backend/alembic/env.py | 109 - backend/alembic/script.py.mako | 24 - ...a6b_add_thread_specific_model_selection.py | 27 - ...f07c00_add_search_doc_relevance_details.py | 32 - ...0fe1_add_earliest_indexing_to_connector.py | 26 - .../0a2b51deb0b8_add_starter_prompts.py | 31 - .../0a98909f2757_enable_encrypted_fields.py | 113 - .../15326fcec57e_introduce_danswer_apis.py | 37 - .../173cae5bba26_port_config_store.py | 29 - ...60c3401_embedding_model_search_settings.py | 135 - .../versions/213fd978c6d8_notifications.py | 44 - ...5_remove_feedback_foreignkey_constraint.py | 86 - .../versions/2666d766cb9b_google_oauth2.py | 55 - .../27c6ecc08586_permission_framework.py | 189 - ...2d2304e27d8c_add_above_below_to_persona.py | 32 - .../30c1d5744104_persona_datetime_aware.py | 37 - ...dd_icon_color_and_icon_shape_to_persona.py | 70 - .../351faebd379d_add_curator_fields.py | 90 - .../versions/3879338f8ba1_add_tool_table.py | 45 - .../38eda64af7fe_add_chat_session_sharing.py | 41 - ...add_alternate_assistant_to_chat_message.py | 35 - .../3b25685ff73c_move_is_public_to_cc_pair.py | 49 - .../3c5e35aa9af0_polling_document_count.py | 52 - ...1c1ac29467_add_tables_for_ui_based_llm_.py | 49 - ...ename_index_origin_to_index_recursively.py | 42 - .../44f856ae2a4a_add_cloud_embedding_model.py | 65 - ...4505fd7302e1_added_is_internet_to_dbdoc.py | 23 - ...78d9b7f9_larger_access_tokens_for_oauth.py | 24 - .../46625e4745d4_remove_native_enum.py | 31 - .../versions/4738e4b3bae1_pg_file_store.py | 28 - ...add_display_model_names_to_llm_provider.py | 49 - .../47433d30de82_create_indexattempt_table.py | 73 - .../475fcefe8826_add_name_to_api_key.py | 23 - ...d14957fe80_add_support_for_custom_tools.py | 61 - ...1_moved_status_to_connector_credential_.py | 80 - .../4b08d97e175a_change_default_prune_freq.py | 34 - .../4ea2c93919c1_add_type_to_credentials.py | 72 - ...c_add_additional_retrieval_controls_to_.py | 28 - ...70282d33c49_track_danswerbot_explicitly.py | 27 - .../57b53544726e_add_document_set_tables.py | 59 - .../5809c0787398_add_chat_sessions.py | 85 - ...c8be3_add_docs_indexed_column_to_index_.py | 36 - ..._add_removed_documents_to_index_attempt.py | 27 - .../versions/5fc1f54cc252_hybrid_enum.py | 25 - ...dd_user_configured_names_to_llmprovider.py | 45 - .../versions/6d387b3196c2_basic_auth.py | 92 - .../703313b75876_add_tokenratelimit_tables.py | 83 - ...70f00c45c0f2_more_descriptive_filestore.py | 68 - ...c9929a46_permission_auto_sync_framework.py | 81 - ...a5f5d728_added_model_defaults_for_users.py | 24 - .../versions/7547d982db8f_chat_folders.py | 51 - .../767f1c2a00eb_count_chat_tokens.py | 26 - .../76b60d407dfb_cc_pair_name_not_unique.py | 36 - .../776b3bbe9092_remove_remaining_enums.py | 71 - ...4_forcibly_remove_more_enum_types_from_.py | 35 - .../versions/78dbe7e38469_task_tracking.py | 48 - ...85b4b_add_llm_group_permissions_control.py | 41 - .../79acd316403a_add_api_key_table.py | 48 - .../7aea705850d5_added_slack_auto_filter.py | 35 - .../7ccea01261f6_store_chat_retrieval_docs.py | 31 - ...7da0ae5ad583_add_description_to_persona.py | 23 - .../7da543f5672f_add_slackbotconfig_table.py | 38 - .../versions/7f726bad5367_slack_followup.py | 26 - ...dd_index_for_getting_documents_just_by_.py | 35 - ...24ae9_add_id_to_connectorcredentialpair.py | 60 - ...cf850ae_add_chat_session_to_query_event.py | 36 - .../891cd83c87a8_add_is_visible_to_persona.py | 34 - ...770549c0_add_full_exception_stack_trace.py | 25 - ...50_associate_index_attempts_with_ccpair.py | 107 - ...abb57f3b49_restructure_document_indices.py | 39 - .../8e26726b7683_chat_context_addition.py | 40 - .../904451035c9b_store_tool_details.py | 32 - backend/alembic/versions/904e5138fffb_tags.py | 61 - ...3b470d1a_remove_documentsource_from_tag.py | 36 - .../versions/91ffac7e65b3_add_expiry_time.py | 26 - ...b7f_added_retrieved_docs_to_query_event.py | 31 - ...902_add_chosen_assistants_to_user_table.py | 27 - .../versions/a570b80a5f20_usergroup_tables.py | 67 - .../ae62505e3acc_add_saml_accounts.py | 47 - ...533f0_make_last_attempt_status_nullable.py | 45 - .../versions/b156fa702355_chat_reworked.py | 520 - .../b85f02ec1308_fix_file_type_migration.py | 28 - ...d5a7_backfill_is_internet_data_to_false.py | 23 - ...1b9e_add_llm_model_version_override_to_.py | 26 - ...bc9771dccadf_create_usage_reports_table.py | 51 - ...c18cdf4b497e_add_standard_answer_tables.py | 75 - ...92fa265c_add_index_attempt_errors_table.py | 57 - ...45c915d0e_remove_deletion_attempt_table.py | 76 - ...3bef0a_add_total_docs_for_index_attempt.py | 32 - .../d7111c1238cd_remove_document_ids.py | 32 - .../d716b0791ddd_combined_slack_id_fields.py | 45 - .../versions/d929f0c1c6af_feedback_feature.py | 94 - ...5951_remove__dim_suffix_from_model_name.py | 31 - ...9164_chosen_assistants_changed_to_jsonb.py | 65 - .../dba7f71618f5_danswer_custom_tool_flow.py | 29 - .../versions/dbaa756c2ccf_embedding_models.py | 139 - ...0c7ad8a076_added_deletion_attempt_table.py | 111 - .../e0a68a81d434_add_chat_feedback.py | 44 - .../e1392f05e840_added_input_prompts.py | 58 - .../e209dc5a8156_added_prune_frequency.py | 22 - .../versions/e50154680a5c_no_source_enum.py | 38 - ..._add_index_for_retrieving_latest_index_.py | 31 - ...86866a9c78a_add_persona_to_chat_session.py | 27 - ...df4e935ef_private_personas_documentsets.py | 118 - .../ec3ec2eabf7b_index_from_beginning.py | 27 - ...remove_last_attempt_status_from_cc_pair.py | 31 - ...f1a3b_add_overrides_to_the_chat_session.py | 40 - ...5_added_alternate_model_to_chat_message.py | 28 - .../ef7da92f7213_add_files_to_chatmessage.py | 27 - ...9f1_embedding_provider_by_provider_type.py | 172 - .../f1c6478c3fd8_add_pre_defined_feedback.py | 25 - ...ad14119fb92_delete_tags_with_wrong_enum.py | 39 - ...fcd135795f21_add_slack_bot_display_type.py | 39 - ..._add_document_set_persona_relationship_.py | 37 - .../ffc707a226b4_basic_document_metadata.py | 37 - backend/assets/.gitignore | 2 - backend/danswer/__init__.py | 3 - backend/danswer/access/__init__.py | 0 backend/danswer/access/access.py | 53 - backend/danswer/access/models.py | 30 - backend/danswer/access/utils.py | 10 - backend/danswer/auth/__init__.py | 0 backend/danswer/auth/invited_users.py | 20 - backend/danswer/auth/noauth_user.py | 38 - backend/danswer/auth/schemas.py | 39 - backend/danswer/auth/users.py | 452 - .../danswer/background/celery/celery_app.py | 472 - .../danswer/background/celery/celery_run.py | 9 - .../danswer/background/celery/celery_utils.py | 170 - .../danswer/background/connector_deletion.py | 196 - .../background/indexing/checkpointing.py | 80 - .../danswer/background/indexing/dask_utils.py | 33 - .../danswer/background/indexing/job_client.py | 128 - .../background/indexing/run_indexing.py | 420 - backend/danswer/background/indexing/tracer.py | 77 - backend/danswer/background/task_utils.py | 126 - backend/danswer/background/update.py | 469 - backend/danswer/chat/__init__.py | 0 backend/danswer/chat/chat_utils.py | 168 - backend/danswer/chat/input_prompts.yaml | 24 - backend/danswer/chat/load_yamls.py | 165 - backend/danswer/chat/models.py | 155 - backend/danswer/chat/personas.yaml | 93 - backend/danswer/chat/process_message.py | 816 -- backend/danswer/chat/prompts.yaml | 105 - backend/danswer/chat/tools.py | 115 - backend/danswer/configs/__init__.py | 0 backend/danswer/configs/app_configs.py | 368 - backend/danswer/configs/chat_configs.py | 90 - backend/danswer/configs/constants.py | 168 - backend/danswer/configs/danswerbot_configs.py | 87 - backend/danswer/configs/model_configs.py | 141 - backend/danswer/connectors/README.md | 84 - backend/danswer/connectors/__init__.py | 0 backend/danswer/connectors/axero/__init__.py | 0 backend/danswer/connectors/axero/connector.py | 363 - backend/danswer/connectors/blob/__init__.py | 0 backend/danswer/connectors/blob/connector.py | 277 - .../danswer/connectors/bookstack/__init__.py | 0 .../danswer/connectors/bookstack/client.py | 56 - .../danswer/connectors/bookstack/connector.py | 219 - .../danswer/connectors/clickup/__init__.py | 0 .../danswer/connectors/clickup/connector.py | 216 - .../danswer/connectors/confluence/__init__.py | 0 .../connectors/confluence/connector.py | 877 -- .../confluence/rate_limit_handler.py | 69 - .../danswer/connectors/connector_runner.py | 70 - .../cross_connector_utils/__init__.py | 0 .../miscellaneous_utils.py | 64 - .../rate_limit_wrapper.py | 130 - .../cross_connector_utils/retry_wrapper.py | 42 - .../connectors/danswer_jira/__init__.py | 0 .../connectors/danswer_jira/connector.py | 303 - .../danswer/connectors/danswer_jira/utils.py | 92 - .../danswer/connectors/discourse/__init__.py | 0 .../danswer/connectors/discourse/connector.py | 244 - .../connectors/document360/__init__.py | 0 .../connectors/document360/connector.py | 209 - .../danswer/connectors/document360/utils.py | 8 - .../danswer/connectors/dropbox/__init__.py | 0 .../danswer/connectors/dropbox/connector.py | 155 - backend/danswer/connectors/factory.py | 140 - backend/danswer/connectors/file/__init__.py | 0 backend/danswer/connectors/file/connector.py | 201 - backend/danswer/connectors/github/__init__.py | 0 .../danswer/connectors/github/connector.py | 241 - backend/danswer/connectors/gitlab/__init__.py | 0 .../danswer/connectors/gitlab/connector.py | 255 - backend/danswer/connectors/gmail/__init__.py | 0 backend/danswer/connectors/gmail/connector.py | 221 - .../connectors/gmail/connector_auth.py | 199 - backend/danswer/connectors/gmail/constants.py | 4 - backend/danswer/connectors/gong/__init__.py | 0 backend/danswer/connectors/gong/connector.py | 316 - .../connectors/google_drive/__init__.py | 0 .../connectors/google_drive/connector.py | 555 - .../connectors/google_drive/connector_auth.py | 171 - .../connectors/google_drive/constants.py | 7 - .../connectors/google_site/__init__.py | 0 .../connectors/google_site/connector.py | 147 - backend/danswer/connectors/guru/__init__.py | 0 backend/danswer/connectors/guru/connector.py | 167 - .../danswer/connectors/hubspot/__init__.py | 0 .../danswer/connectors/hubspot/connector.py | 145 - backend/danswer/connectors/interfaces.py | 63 - backend/danswer/connectors/linear/__init__.py | 0 .../danswer/connectors/linear/connector.py | 218 - backend/danswer/connectors/loopio/__init__.py | 0 .../danswer/connectors/loopio/connector.py | 216 - .../danswer/connectors/mediawiki/__init__.py | 0 .../danswer/connectors/mediawiki/family.py | 166 - backend/danswer/connectors/mediawiki/wiki.py | 220 - backend/danswer/connectors/models.py | 201 - backend/danswer/connectors/notion/__init__.py | 0 .../danswer/connectors/notion/connector.py | 465 - .../connectors/productboard/__init__.py | 0 .../connectors/productboard/connector.py | 265 - .../connectors/requesttracker/.gitignore | 1 - .../connectors/requesttracker/__init__.py | 0 .../connectors/requesttracker/connector.py | 153 - .../danswer/connectors/salesforce/__init__.py | 0 .../connectors/salesforce/connector.py | 274 - .../danswer/connectors/salesforce/utils.py | 66 - .../danswer/connectors/sharepoint/__init__.py | 0 .../connectors/sharepoint/connector.py | 211 - backend/danswer/connectors/slab/__init__.py | 0 backend/danswer/connectors/slab/connector.py | 226 - backend/danswer/connectors/slack/__init__.py | 0 backend/danswer/connectors/slack/connector.py | 393 - .../connectors/slack/load_connector.py | 139 - backend/danswer/connectors/slack/utils.py | 274 - backend/danswer/connectors/teams/__init__.py | 0 backend/danswer/connectors/teams/connector.py | 278 - backend/danswer/connectors/web/__init__.py | 0 backend/danswer/connectors/web/connector.py | 369 - .../danswer/connectors/wikipedia/__init__.py | 0 .../danswer/connectors/wikipedia/connector.py | 28 - .../danswer/connectors/zendesk/__init__.py | 0 .../danswer/connectors/zendesk/connector.py | 176 - backend/danswer/connectors/zulip/__init__.py | 0 backend/danswer/connectors/zulip/connector.py | 140 - backend/danswer/connectors/zulip/schemas.py | 43 - backend/danswer/connectors/zulip/utils.py | 102 - backend/danswer/danswerbot/slack/blocks.py | 502 - backend/danswer/danswerbot/slack/config.py | 50 - backend/danswer/danswerbot/slack/constants.py | 16 - .../danswerbot/slack/handlers/__init__.py | 0 .../slack/handlers/handle_buttons.py | 361 - .../slack/handlers/handle_message.py | 235 - .../slack/handlers/handle_regular_answer.py | 479 - .../slack/handlers/handle_standard_answers.py | 215 - .../danswerbot/slack/handlers/utils.py | 19 - backend/danswer/danswerbot/slack/icons.py | 58 - backend/danswer/danswerbot/slack/listener.py | 519 - backend/danswer/danswerbot/slack/models.py | 14 - backend/danswer/danswerbot/slack/tokens.py | 28 - backend/danswer/danswerbot/slack/utils.py | 556 - backend/danswer/db/__init__.py | 0 backend/danswer/db/auth.py | 66 - backend/danswer/db/chat.py | 728 - backend/danswer/db/connector.py | 270 - .../danswer/db/connector_credential_pair.py | 474 - backend/danswer/db/constants.py | 1 - backend/danswer/db/credentials.py | 430 - backend/danswer/db/deletion_attempt.py | 52 - backend/danswer/db/document.py | 381 - backend/danswer/db/document_set.py | 617 - backend/danswer/db/engine.py | 213 - backend/danswer/db/enums.py | 53 - backend/danswer/db/feedback.py | 268 - backend/danswer/db/folder.py | 132 - backend/danswer/db/index_attempt.py | 434 - backend/danswer/db/input_prompt.py | 202 - backend/danswer/db/llm.py | 199 - backend/danswer/db/models.py | 1735 --- backend/danswer/db/notification.py | 76 - backend/danswer/db/persona.py | 723 - backend/danswer/db/pg_file_store.py | 150 - backend/danswer/db/pydantic_type.py | 32 - backend/danswer/db/search_settings.py | 249 - backend/danswer/db/slack_bot_config.py | 205 - backend/danswer/db/standard_answer.py | 239 - backend/danswer/db/swap_index.py | 65 - backend/danswer/db/tag.py | 156 - backend/danswer/db/tasks.py | 102 - backend/danswer/db/tools.py | 74 - backend/danswer/db/users.py | 32 - backend/danswer/db/utils.py | 9 - backend/danswer/document_index/__init__.py | 0 .../document_index/document_index_utils.py | 60 - backend/danswer/document_index/factory.py | 15 - backend/danswer/document_index/interfaces.py | 339 - .../danswer/document_index/vespa/__init__.py | 0 .../vespa/app_config/schemas/danswer_chunk.sd | 218 - .../vespa/app_config/services.xml | 36 - .../vespa/app_config/validation-overrides.xml | 8 - .../document_index/vespa/chunk_retrieval.py | 424 - .../danswer/document_index/vespa/deletion.py | 65 - backend/danswer/document_index/vespa/index.py | 484 - .../document_index/vespa/indexing_utils.py | 227 - .../vespa/shared_utils/utils.py | 47 - .../shared_utils/vespa_request_builders.py | 96 - .../danswer/document_index/vespa_constants.py | 85 - backend/danswer/dynamic_configs/__init__.py | 0 backend/danswer/dynamic_configs/factory.py | 15 - backend/danswer/dynamic_configs/interface.py | 27 - backend/danswer/dynamic_configs/store.py | 102 - backend/danswer/file_processing/__init__.py | 0 backend/danswer/file_processing/enums.py | 8 - .../file_processing/extract_file_text.py | 303 - backend/danswer/file_processing/html_utils.py | 189 - backend/danswer/file_store/constants.py | 2 - backend/danswer/file_store/file_store.py | 140 - backend/danswer/file_store/models.py | 46 - backend/danswer/file_store/utils.py | 77 - backend/danswer/indexing/__init__.py | 0 backend/danswer/indexing/chunker.py | 304 - backend/danswer/indexing/embedder.py | 205 - backend/danswer/indexing/indexing_pipeline.py | 396 - backend/danswer/indexing/models.py | 143 - backend/danswer/llm/__init__.py | 0 backend/danswer/llm/answering/answer.py | 569 - backend/danswer/llm/answering/models.py | 160 - .../danswer/llm/answering/prompts/build.py | 138 - .../llm/answering/prompts/citations_prompt.py | 166 - .../llm/answering/prompts/quotes_prompt.py | 114 - .../danswer/llm/answering/prompts/utils.py | 20 - .../danswer/llm/answering/prune_and_merge.py | 384 - .../stream_processing/citation_processing.py | 214 - .../stream_processing/quotes_processing.py | 295 - .../llm/answering/stream_processing/utils.py | 23 - backend/danswer/llm/chat_llm.py | 393 - backend/danswer/llm/custom_llm.py | 93 - backend/danswer/llm/exceptions.py | 4 - backend/danswer/llm/factory.py | 124 - backend/danswer/llm/headers.py | 34 - backend/danswer/llm/interfaces.py | 124 - backend/danswer/llm/llm_initialization.py | 113 - backend/danswer/llm/llm_provider_options.py | 138 - backend/danswer/llm/override_models.py | 20 - backend/danswer/llm/utils.py | 434 - backend/danswer/main.py | 516 - .../natural_language_processing/__init__.py | 0 .../search_nlp_models.py | 385 - .../natural_language_processing/utils.py | 150 - backend/danswer/one_shot_answer/__init__.py | 0 .../one_shot_answer/answer_question.py | 406 - backend/danswer/one_shot_answer/models.py | 69 - backend/danswer/one_shot_answer/qa_utils.py | 53 - backend/danswer/prompts/__init__.py | 0 backend/danswer/prompts/agentic_evaluation.py | 44 - backend/danswer/prompts/answer_validation.py | 61 - backend/danswer/prompts/chat_prompts.py | 217 - backend/danswer/prompts/chat_tools.py | 100 - backend/danswer/prompts/constants.py | 15 - backend/danswer/prompts/direct_qa_prompts.py | 184 - backend/danswer/prompts/filter_extration.py | 66 - backend/danswer/prompts/llm_chunk_filter.py | 33 - .../danswer/prompts/miscellaneous_prompts.py | 29 - backend/danswer/prompts/prompt_utils.py | 190 - backend/danswer/prompts/query_validation.py | 58 - backend/danswer/prompts/token_counts.py | 29 - backend/danswer/search/__init__.py | 0 backend/danswer/search/enums.py | 36 - backend/danswer/search/models.py | 365 - backend/danswer/search/pipeline.py | 394 - .../search/postprocessing/postprocessing.py | 338 - .../danswer/search/postprocessing/reranker.py | 0 .../search/preprocessing/access_filters.py | 21 - .../search/preprocessing/preprocessing.py | 240 - .../danswer/search/retrieval/search_runner.py | 331 - backend/danswer/search/search_settings.py | 30 - backend/danswer/search/utils.py | 138 - .../danswer/secondary_llm_flows/__init__.py | 0 .../secondary_llm_flows/agentic_evaluation.py | 86 - .../secondary_llm_flows/answer_validation.py | 61 - .../chat_session_naming.py | 45 - .../secondary_llm_flows/choose_search.py | 87 - .../secondary_llm_flows/chunk_usefulness.py | 97 - .../secondary_llm_flows/query_expansion.py | 166 - .../secondary_llm_flows/query_validation.py | 136 - .../secondary_llm_flows/source_filter.py | 171 - .../secondary_llm_flows/time_filter.py | 167 - backend/danswer/server/__init__.py | 0 backend/danswer/server/auth_check.py | 109 - .../danswer/server/danswer_api/__init__.py | 0 .../danswer/server/danswer_api/ingestion.py | 147 - backend/danswer/server/danswer_api/models.py | 19 - backend/danswer/server/documents/__init__.py | 0 backend/danswer/server/documents/cc_pair.py | 191 - backend/danswer/server/documents/connector.py | 904 -- .../danswer/server/documents/credential.py | 256 - backend/danswer/server/documents/document.py | 109 - backend/danswer/server/documents/indexing.py | 23 - backend/danswer/server/documents/models.py | 338 - backend/danswer/server/features/__init__.py | 0 .../server/features/document_set/__init__.py | 0 .../server/features/document_set/api.py | 115 - .../server/features/document_set/models.py | 85 - .../server/features/folder/__init__.py | 0 backend/danswer/server/features/folder/api.py | 176 - .../danswer/server/features/folder/models.py | 30 - .../server/features/input_prompt/__init__.py | 0 .../server/features/input_prompt/api.py | 134 - .../server/features/input_prompt/models.py | 47 - .../server/features/persona/__init__.py | 0 .../danswer/server/features/persona/api.py | 236 - .../danswer/server/features/persona/models.py | 115 - .../server/features/prompt/__init__.py | 0 backend/danswer/server/features/prompt/api.py | 152 - .../danswer/server/features/prompt/models.py | 41 - backend/danswer/server/features/tool/api.py | 138 - .../danswer/server/features/tool/models.py | 25 - backend/danswer/server/gpts/api.py | 98 - backend/danswer/server/manage/__init__.py | 0 .../danswer/server/manage/administrative.py | 205 - .../danswer/server/manage/embedding/api.py | 94 - .../danswer/server/manage/embedding/models.py | 32 - backend/danswer/server/manage/get_state.py | 27 - backend/danswer/server/manage/llm/api.py | 156 - backend/danswer/server/manage/llm/models.py | 103 - backend/danswer/server/manage/models.py | 256 - .../danswer/server/manage/search_settings.py | 180 - backend/danswer/server/manage/slack_bot.py | 215 - .../danswer/server/manage/standard_answer.py | 139 - backend/danswer/server/manage/users.py | 379 - .../server/middleware/latency_logging.py | 23 - backend/danswer/server/models.py | 46 - .../danswer/server/query_and_chat/__init__.py | 0 .../server/query_and_chat/chat_backend.py | 622 - .../danswer/server/query_and_chat/models.py | 218 - .../server/query_and_chat/query_backend.py | 256 - .../server/query_and_chat/token_limit.py | 135 - backend/danswer/server/settings/api.py | 145 - backend/danswer/server/settings/models.py | 64 - backend/danswer/server/settings/store.py | 21 - .../danswer/server/token_rate_limits/api.py | 79 - .../server/token_rate_limits/models.py | 25 - backend/danswer/server/utils.py | 23 - backend/danswer/tools/built_in_tools.py | 191 - .../danswer/tools/custom/base_tool_types.py | 2 - backend/danswer/tools/custom/custom_tool.py | 243 - .../custom/custom_tool_prompt_builder.py | 21 - .../tools/custom/custom_tool_prompts.py | 57 - .../danswer/tools/custom/openapi_parsing.py | 225 - backend/danswer/tools/force.py | 27 - .../tools/images/image_generation_tool.py | 259 - backend/danswer/tools/images/prompt.py | 21 - .../internet_search/internet_search_tool.py | 233 - .../danswer/tools/internet_search/models.py | 12 - backend/danswer/tools/message.py | 41 - backend/danswer/tools/models.py | 39 - backend/danswer/tools/search/search_tool.py | 356 - backend/danswer/tools/search/search_utils.py | 31 - backend/danswer/tools/tool.py | 63 - backend/danswer/tools/tool_runner.py | 54 - backend/danswer/tools/tool_selection.py | 78 - backend/danswer/tools/utils.py | 28 - backend/danswer/utils/__init__.py | 0 backend/danswer/utils/batching.py | 23 - backend/danswer/utils/callbacks.py | 12 - backend/danswer/utils/encryption.py | 31 - backend/danswer/utils/logger.py | 154 - backend/danswer/utils/sitemap.py | 39 - backend/danswer/utils/telemetry.py | 66 - backend/danswer/utils/text_processing.py | 98 - .../danswer/utils/threadpool_concurrency.py | 108 - backend/danswer/utils/timing.py | 86 - .../danswer/utils/variable_functionality.py | 76 - backend/ee/LICENSE | 36 - backend/ee/__init__.py | 0 backend/ee/danswer/__init__.py | 0 backend/ee/danswer/access/access.py | 51 - backend/ee/danswer/auth/__init__.py | 0 backend/ee/danswer/auth/api_key.py | 53 - backend/ee/danswer/auth/users.py | 70 - .../danswer/background/celery/celery_app.py | 131 - backend/ee/danswer/background/celery_utils.py | 40 - .../ee/danswer/background/permission_sync.py | 224 - .../danswer/background/task_name_builders.py | 6 - backend/ee/danswer/configs/__init__.py | 0 backend/ee/danswer/configs/app_configs.py | 23 - .../saml_config/template.settings.json | 20 - backend/ee/danswer/connectors/__init__.py | 0 .../danswer/connectors/confluence/__init__.py | 0 .../connectors/confluence/perm_sync.py | 12 - backend/ee/danswer/connectors/factory.py | 8 - backend/ee/danswer/db/__init__.py | 0 backend/ee/danswer/db/analytics.py | 172 - backend/ee/danswer/db/api_key.py | 169 - backend/ee/danswer/db/connector.py | 16 - .../danswer/db/connector_credential_pair.py | 45 - backend/ee/danswer/db/document.py | 14 - backend/ee/danswer/db/document_set.py | 123 - backend/ee/danswer/db/permission_sync.py | 72 - backend/ee/danswer/db/persona.py | 32 - backend/ee/danswer/db/query_history.py | 59 - backend/ee/danswer/db/saml.py | 65 - backend/ee/danswer/db/token_limit.py | 226 - backend/ee/danswer/db/usage_export.py | 108 - backend/ee/danswer/db/user_group.py | 482 - backend/ee/danswer/main.py | 107 - backend/ee/danswer/server/__init__.py | 0 backend/ee/danswer/server/analytics/api.py | 117 - backend/ee/danswer/server/api_key/api.py | 62 - backend/ee/danswer/server/api_key/models.py | 8 - backend/ee/danswer/server/auth_check.py | 29 - .../danswer/server/enterprise_settings/api.py | 91 - .../server/enterprise_settings/models.py | 25 - .../server/enterprise_settings/store.py | 120 - .../danswer/server/query_and_chat/__init__.py | 0 .../server/query_and_chat/chat_backend.py | 267 - .../danswer/server/query_and_chat/models.py | 77 - .../server/query_and_chat/query_backend.py | 185 - .../server/query_and_chat/token_limit.py | 184 - .../ee/danswer/server/query_history/api.py | 394 - .../server/reporting/usage_export_api.py | 82 - .../reporting/usage_export_generation.py | 165 - .../server/reporting/usage_export_models.py | 33 - backend/ee/danswer/server/saml.py | 185 - backend/ee/danswer/server/seeding.py | 146 - .../danswer/server/token_rate_limits/api.py | 106 - backend/ee/danswer/server/user_group/api.py | 105 - .../ee/danswer/server/user_group/models.py | 91 - backend/ee/danswer/user_groups/sync.py | 87 - backend/ee/danswer/utils/__init__.py | 0 backend/ee/danswer/utils/encryption.py | 85 - backend/ee/danswer/utils/secrets.py | 14 - backend/model_server/__init__.py | 0 backend/model_server/constants.py | 30 - backend/model_server/custom_models.py | 200 - backend/model_server/danswer_torch_model.py | 74 - backend/model_server/encoders.py | 393 - backend/model_server/main.py | 100 - backend/model_server/management_endpoints.py | 20 - backend/model_server/utils.py | 41 - backend/pyproject.toml | 14 - backend/pytest.ini | 4 - backend/requirements/cdk.txt | 2 - backend/requirements/default.txt | 76 - backend/requirements/dev.txt | 23 - backend/requirements/ee.txt | 1 - backend/requirements/model_server.txt | 14 - backend/scripts/api_inference_sample.py | 87 - backend/scripts/dev_run_background_jobs.py | 105 - .../scripts/force_delete_connector_by_id.py | 218 - backend/scripts/reset_indexes.py | 36 - backend/scripts/reset_postgres.py | 72 - backend/scripts/restart_containers.sh | 42 - backend/scripts/save_load_state.py | 138 - backend/scripts/sources_selection_analysis.py | 733 - backend/scripts/test-openapi-key.py | 58 - backend/shared_configs/__init__.py | 0 backend/shared_configs/configs.py | 70 - backend/shared_configs/enums.py | 17 - backend/shared_configs/model_server_models.py | 55 - backend/shared_configs/utils.py | 11 - backend/slackbot_images/Confluence.png | Bin 1013 -> 0 bytes backend/slackbot_images/File.png | Bin 1732 -> 0 bytes backend/slackbot_images/Guru.png | Bin 5076 -> 0 bytes backend/slackbot_images/Jira.png | Bin 829 -> 0 bytes backend/slackbot_images/README.md | 3 - backend/slackbot_images/Web.png | Bin 2885 -> 0 bytes backend/slackbot_images/Zendesk.png | Bin 17978 -> 0 bytes backend/supervisord.conf | 64 - backend/tests/api/test_api.py | 104 - .../confluence/test_confluence_basic.py | 42 - .../tests/daily/embedding/test_embeddings.py | 78 - backend/tests/integration/Dockerfile | 83 - .../tests/integration/common_utils/chat.py | 66 - .../integration/common_utils/connectors.py | 114 - .../integration/common_utils/constants.py | 7 - .../integration/common_utils/document_sets.py | 30 - backend/tests/integration/common_utils/llm.py | 62 - .../tests/integration/common_utils/reset.py | 172 - .../common_utils/seed_documents.py | 72 - .../integration/common_utils/user_groups.py | 24 - .../tests/integration/common_utils/vespa.py | 27 - backend/tests/integration/conftest.py | 26 - .../tests/connector/test_deletion.py | 190 - .../tests/dev_apis/test_simple_chat_api.py | 36 - .../tests/document_set/test_syncing.py | 78 - .../tests/regression/answer_quality/README.md | 106 - .../regression/answer_quality/__init__.py | 0 .../regression/answer_quality/api_utils.py | 207 - .../regression/answer_quality/cli_utils.py | 321 - .../answer_quality/file_uploader.py | 108 - .../answer_quality/launch_eval_env.py | 48 - .../tests/regression/answer_quality/run_qa.py | 196 - .../search_test_config.yaml.template | 52 - .../confluence/test_rate_limit_handler.py | 59 - .../cross_connector_utils/test_html_utils.py | 13 - .../cross_connector_utils/test_rate_limit.py | 29 - .../cross_connector_utils/test_table.html | 43 - .../connectors/gmail/test_connector.py | 225 - .../danswer/connectors/mediawiki/__init__.py | 0 .../mediawiki/test_mediawiki_family.py | 73 - .../danswer/connectors/mediawiki/test_wiki.py | 139 - .../unit/danswer/direct_qa/test_qa_utils.py | 194 - .../unit/danswer/indexing/test_chunker.py | 51 - .../test_citation_processing.py | 306 - .../test_quote_processing.py | 351 - .../llm/answering/test_prune_and_merge.py | 230 - backend/throttle.ctrl | 0 deployment/.gitignore | 2 - deployment/README.md | 80 - deployment/data/nginx/app.conf.template | 90 - deployment/data/nginx/app.conf.template.dev | 69 - .../nginx/app.conf.template.no-letsencrypt | 84 - deployment/data/nginx/run-nginx.sh | 26 - deployment/docker_compose/README.md | 40 - .../docker_compose/docker-compose.dev.yml | 352 - .../docker_compose/docker-compose.gpu-dev.yml | 365 - .../docker-compose.prod-no-letsencrypt.yml | 212 - .../docker_compose/docker-compose.prod.yml | 229 - .../docker-compose.search-testing.yml | 215 - .../docker_compose/env.multilingual.template | 38 - deployment/docker_compose/env.nginx.template | 11 - deployment/docker_compose/env.prod.template | 72 - deployment/docker_compose/init-letsencrypt.sh | 116 - deployment/helm/.gitignore | 3 - deployment/helm/.helmignore | 23 - deployment/helm/Chart.lock | 12 - deployment/helm/Chart.yaml | 34 - deployment/helm/templates/_helpers.tpl | 83 - deployment/helm/templates/api-deployment.yaml | 59 - deployment/helm/templates/api-hpa.yaml | 32 - deployment/helm/templates/api-service.yaml | 22 - .../helm/templates/background-deployment.yaml | 51 - deployment/helm/templates/background-hpa.yaml | 32 - deployment/helm/templates/configmap.yaml | 15 - deployment/helm/templates/danswer-secret.yaml | 11 - .../templates/indexing-model-deployment.yaml | 57 - .../helm/templates/indexing-model-pvc.yaml | 10 - .../templates/indexing-model-service.yaml | 18 - .../templates/inference-model-deployment.yaml | 45 - .../helm/templates/inference-model-pvc.yaml | 10 - .../templates/inference-model-service.yaml | 15 - deployment/helm/templates/nginx-conf.yaml | 44 - deployment/helm/templates/serviceaccount.yaml | 13 - .../helm/templates/tests/test-connection.yaml | 15 - .../helm/templates/webserver-deployment.yaml | 60 - deployment/helm/templates/webserver-hpa.yaml | 32 - .../helm/templates/webserver-service.yaml | 21 - deployment/helm/values.yaml | 473 - .../api_server-service-deployment.yaml | 57 - .../kubernetes/background-deployment.yaml | 24 - deployment/kubernetes/env-configmap.yaml | 84 - ...exing_model_server-service-deployment.yaml | 59 - ...rence_model_server-service-deployment.yaml | 56 - deployment/kubernetes/nginx-configmap.yaml | 44 - .../kubernetes/nginx-service-deployment.yaml | 55 - .../postgres-service-deployment.yaml | 58 - deployment/kubernetes/secrets.yaml | 11 - .../kubernetes/vespa-service-deployment.yaml | 63 - .../web_server-service-deployment.yaml | 38 - examples/widget/.env.example | 2 - examples/widget/.eslintrc.json | 3 - examples/widget/.gitignore | 36 - examples/widget/README.md | 70 - examples/widget/next.config.mjs | 4 - examples/widget/package-lock.json | 5933 -------- examples/widget/package.json | 28 - examples/widget/postcss.config.mjs | 8 - examples/widget/src/app/globals.css | 3 - examples/widget/src/app/layout.tsx | 23 - examples/widget/src/app/page.tsx | 9 - examples/widget/src/app/widget/Widget.tsx | 344 - examples/widget/tailwind.config.ts | 20 - examples/widget/tsconfig.json | 26 - web/.dockerignore | 2 - web/.eslintrc.json | 3 - web/.gitignore | 36 - web/.prettierignore | 6 - web/.prettierrc.json | 3 - web/Dockerfile | 123 - web/README.md | 23 - web/next.config.js | 57 - web/package-lock.json | 11677 ---------------- web/package.json | 59 - web/postcss.config.js | 6 - web/public/Amazon.webp | Bin 9580 -> 0 bytes web/public/Anthropic.svg | 8 - web/public/Axero.jpeg | Bin 7977 -> 0 bytes web/public/Azure.png | Bin 295307 -> 0 bytes web/public/Clickup.svg | 1 - web/public/Cohere.svg | 30 - web/public/Confluence.svg | 16 - web/public/Discourse.png | Bin 42742 -> 0 bytes web/public/Document360.png | Bin 11137 -> 0 bytes web/public/Dropbox.png | Bin 43377 -> 0 bytes web/public/Github.png | Bin 6393 -> 0 bytes web/public/GithubDarkMode.png | Bin 4837 -> 0 bytes web/public/Gitlab.png | Bin 19675 -> 0 bytes web/public/Gmail.png | Bin 7186 -> 0 bytes web/public/Gong.png | Bin 29413 -> 0 bytes web/public/Google.webp | Bin 6568 -> 0 bytes web/public/GoogleCloudStorage.png | Bin 22212 -> 0 bytes web/public/GoogleDrive.png | Bin 264831 -> 0 bytes web/public/GoogleSites.png | Bin 5539 -> 0 bytes web/public/Guru.svg | 1 - web/public/HubSpot.png | Bin 14062 -> 0 bytes web/public/Jira.svg | 15 - web/public/Linear.png | Bin 466996 -> 0 bytes web/public/Loopio.png | Bin 550 -> 0 bytes web/public/MediaWiki.svg | 43 - web/public/Mixedbread.png | Bin 136773 -> 0 bytes web/public/Notion.png | Bin 11406 -> 0 bytes web/public/OCI.svg | 1 - web/public/OpenSource.png | Bin 8640 -> 0 bytes web/public/Openai.svg | 1 - web/public/Productboard.webp | Bin 718 -> 0 bytes web/public/RequestTracker.png | Bin 17302 -> 0 bytes web/public/S3.png | Bin 52426 -> 0 bytes web/public/Salesforce.png | Bin 18266 -> 0 bytes web/public/Sharepoint.png | Bin 143430 -> 0 bytes web/public/SlabLogo.png | Bin 2159 -> 0 bytes web/public/Slack.png | Bin 5204 -> 0 bytes web/public/Teams.png | Bin 126479 -> 0 bytes web/public/Voyage.png | Bin 14883 -> 0 bytes web/public/Wikipedia.svg | 1 - web/public/Zendesk.svg | 8 - web/public/Zulip.png | Bin 3237 -> 0 bytes web/public/danswer.ico | Bin 15406 -> 0 bytes web/public/logo.png | Bin 160237 -> 0 bytes web/public/microsoft.png | Bin 5598 -> 0 bytes web/public/nomic.svg | 8 - web/public/r2.png | Bin 12851 -> 0 bytes web/src/app/admin/add-connector/page.tsx | 162 - .../app/admin/assistants/AssistantEditor.tsx | 1221 -- .../admin/assistants/CollapsibleSection.tsx | 56 - .../app/admin/assistants/HidableSection.tsx | 50 - web/src/app/admin/assistants/PersonaTable.tsx | 217 - .../assistants/[id]/DeletePersonaButton.tsx | 39 - web/src/app/admin/assistants/[id]/page.tsx | 50 - web/src/app/admin/assistants/enums.ts | 4 - web/src/app/admin/assistants/interfaces.ts | 44 - web/src/app/admin/assistants/lib.ts | 320 - web/src/app/admin/assistants/new/page.tsx | 41 - web/src/app/admin/assistants/page.tsx | 74 - .../admin/bot/SlackBotConfigCreationForm.tsx | 460 - web/src/app/admin/bot/SlackBotTokensForm.tsx | 76 - web/src/app/admin/bot/[id]/page.tsx | 117 - web/src/app/admin/bot/hooks.ts | 23 - web/src/app/admin/bot/lib.ts | 102 - web/src/app/admin/bot/new/page.tsx | 83 - web/src/app/admin/bot/page.tsx | 314 - .../llm/ConfiguredLLMProviderDisplay.tsx | 191 - .../llm/CustomLLMProviderUpdateForm.tsx | 532 - .../configuration/llm/LLMConfiguration.tsx | 185 - .../llm/LLMProviderUpdateForm.tsx | 448 - .../app/admin/configuration/llm/constants.ts | 4 - .../app/admin/configuration/llm/interfaces.ts | 55 - web/src/app/admin/configuration/llm/page.tsx | 21 - .../configuration/search/UpgradingPage.tsx | 116 - .../app/admin/configuration/search/page.tsx | 191 - .../connector/[ccPairId]/ConfigDisplay.tsx | 131 - .../connector/[ccPairId]/DeletionButton.tsx | 53 - .../[ccPairId]/IndexingAttemptsTable.tsx | 159 - .../[ccPairId]/ModifyStatusButtonCluster.tsx | 58 - .../connector/[ccPairId]/ReIndexButton.tsx | 140 - web/src/app/admin/connector/[ccPairId]/lib.ts | 13 - .../app/admin/connector/[ccPairId]/page.tsx | 256 - .../app/admin/connector/[ccPairId]/types.ts | 22 - .../[connector]/AddConnectorPage.tsx | 617 - .../[connector]/ConnectorWrapper.tsx | 37 - .../admin/connectors/[connector]/Sidebar.tsx | 117 - .../[connector]/auth/callback/route.ts | 43 - .../app/admin/connectors/[connector]/page.tsx | 9 - .../connectors/[connector]/pages/Advanced.tsx | 68 - .../pages/ConnectorInput/FileInput.tsx | 37 - .../pages/ConnectorInput/ListInput.tsx | 74 - .../pages/ConnectorInput/NumberInput.tsx | 42 - .../pages/ConnectorInput/SelectInput.tsx | 45 - .../pages/DynamicConnectorCreationForm.tsx | 115 - .../pages/formelements/NumberInput.tsx | 42 - .../[connector]/pages/gdrive/Credential.tsx | 467 - .../pages/gdrive/GoogleDrivePage.tsx | 155 - .../[connector]/pages/gmail/Credential.tsx | 462 - .../[connector]/pages/gmail/GmailPage.tsx | 159 - .../[connector]/pages/utils/files.ts | 112 - .../[connector]/pages/utils/google_site.ts | 87 - .../[connector]/pages/utils/hooks.ts | 65 - web/src/app/admin/documents/ScoreEditor.tsx | 53 - .../app/admin/documents/explorer/Explorer.tsx | 220 - web/src/app/admin/documents/explorer/lib.ts | 15 - web/src/app/admin/documents/explorer/page.tsx | 29 - .../feedback/DocumentFeedbackTable.tsx | 170 - .../app/admin/documents/feedback/constants.ts | 2 - web/src/app/admin/documents/feedback/page.tsx | 76 - web/src/app/admin/documents/lib.ts | 34 - .../sets/DocumentSetCreationForm.tsx | 355 - .../documents/sets/[documentSetId]/page.tsx | 110 - web/src/app/admin/documents/sets/hooks.tsx | 24 - web/src/app/admin/documents/sets/lib.ts | 74 - web/src/app/admin/documents/sets/new/page.tsx | 79 - web/src/app/admin/documents/sets/page.tsx | 358 - .../EmbeddingModelSelectionForm.tsx | 306 - .../admin/embeddings/RerankingFormPage.tsx | 246 - web/src/app/admin/embeddings/interfaces.ts | 89 - .../embeddings/modals/AlreadyPickedModal.tsx | 32 - .../modals/ChangeCredentialsModal.tsx | 232 - .../modals/DeleteCredentialsModal.tsx | 42 - .../embeddings/modals/ModelSelectionModal.tsx | 64 - .../modals/ProviderCreationModal.tsx | 233 - .../embeddings/modals/SelectModelModal.tsx | 37 - web/src/app/admin/embeddings/page.tsx | 18 - .../pages/AdvancedEmbeddingFormPage.tsx | 172 - .../embeddings/pages/CloudEmbeddingPage.tsx | 199 - .../embeddings/pages/EmbeddingFormPage.tsx | 441 - .../embeddings/pages/OpenEmbeddingPage.tsx | 69 - .../indexing/[id]/IndexAttemptErrorsTable.tsx | 189 - web/src/app/admin/indexing/[id]/lib.ts | 3 - web/src/app/admin/indexing/[id]/page.tsx | 58 - web/src/app/admin/indexing/[id]/types.ts | 15 - .../status/CCPairIndexingStatusTable.tsx | 557 - web/src/app/admin/indexing/status/page.tsx | 94 - web/src/app/admin/layout.tsx | 9 - web/src/app/admin/prompt-library/hooks.ts | 46 - .../app/admin/prompt-library/interfaces.ts | 31 - .../prompt-library/modals/AddPromptModal.tsx | 84 - .../prompt-library/modals/EditPromptModal.tsx | 138 - web/src/app/admin/prompt-library/page.tsx | 32 - .../admin/prompt-library/promptLibrary.tsx | 260 - .../admin/prompt-library/promptSection.tsx | 146 - web/src/app/admin/settings/SettingsForm.tsx | 289 - web/src/app/admin/settings/interfaces.ts | 36 - web/src/app/admin/settings/page.tsx | 23 - .../StandardAnswerCreationForm.tsx | 151 - .../app/admin/standard-answer/[id]/page.tsx | 67 - web/src/app/admin/standard-answer/hooks.ts | 26 - web/src/app/admin/standard-answer/lib.ts | 86 - .../app/admin/standard-answer/new/page.tsx | 41 - web/src/app/admin/standard-answer/page.tsx | 412 - web/src/app/admin/systeminfo/page.tsx | 41 - .../CreateRateLimitModal.tsx | 172 - .../TokenRateLimitTables.tsx | 204 - web/src/app/admin/token-rate-limits/lib.ts | 64 - web/src/app/admin/token-rate-limits/page.tsx | 230 - web/src/app/admin/token-rate-limits/types.ts | 22 - web/src/app/admin/tools/ToolEditor.tsx | 261 - web/src/app/admin/tools/ToolsTable.tsx | 107 - .../tools/edit/[toolId]/DeleteToolButton.tsx | 28 - .../app/admin/tools/edit/[toolId]/page.tsx | 56 - web/src/app/admin/tools/new/page.tsx | 25 - web/src/app/admin/tools/page.tsx | 69 - web/src/app/admin/users/page.tsx | 223 - .../app/assistants/AssistantSharedStatus.tsx | 83 - .../app/assistants/AssistantsPageTitle.tsx | 18 - web/src/app/assistants/LargeBackButton.tsx | 16 - web/src/app/assistants/NavigationButton.tsx | 25 - web/src/app/assistants/SidebarWrapper.tsx | 166 - web/src/app/assistants/ToolsDisplay.tsx | 129 - web/src/app/assistants/edit/[id]/page.tsx | 63 - .../assistants/gallery/AssistantsGallery.tsx | 207 - .../gallery/WrappedAssistantsGallery.tsx | 45 - web/src/app/assistants/gallery/page.tsx | 47 - .../assistants/mine/AssistantSharingModal.tsx | 227 - .../app/assistants/mine/AssistantsList.tsx | 449 - .../assistants/mine/WrappedAssistantsMine.tsx | 44 - .../assistants/mine/WrappedInputPrompts.tsx | 63 - web/src/app/assistants/mine/page.tsx | 47 - web/src/app/assistants/new/page.tsx | 55 - web/src/app/auth/error/page.tsx | 22 - web/src/app/auth/lib.ts | 11 - web/src/app/auth/login/EmailPasswordForm.tsx | 113 - web/src/app/auth/login/LoginText.tsx | 17 - web/src/app/auth/login/SignInButton.tsx | 51 - web/src/app/auth/login/page.tsx | 116 - web/src/app/auth/logout/route.ts | 13 - web/src/app/auth/oauth/callback/route.ts | 23 - web/src/app/auth/oidc/callback/route.ts | 23 - web/src/app/auth/saml/callback/route.ts | 43 - web/src/app/auth/signup/page.tsx | 82 - web/src/app/auth/verify-email/Verify.tsx | 82 - web/src/app/auth/verify-email/page.tsx | 30 - .../RequestNewVerificationEmail.tsx | 46 - .../app/auth/waiting-on-verification/page.tsx | 67 - web/src/app/chat/ChatBanner.tsx | 130 - web/src/app/chat/ChatIntro.tsx | 119 - web/src/app/chat/ChatPage.tsx | 2202 --- web/src/app/chat/ChatPersonaSelector.tsx | 141 - web/src/app/chat/ChatPopup.tsx | 73 - web/src/app/chat/RegenerateOption.tsx | 184 - web/src/app/chat/StarterMessage.tsx | 21 - web/src/app/chat/WrappedChat.tsx | 24 - .../documentSidebar/ChatDocumentDisplay.tsx | 133 - .../chat/documentSidebar/DocumentSelector.tsx | 65 - .../chat/documentSidebar/DocumentSidebar.tsx | 164 - .../SelectedDocumentDisplay.tsx | 24 - web/src/app/chat/files/InputBarPreview.tsx | 166 - .../chat/files/documents/DocumentPreview.tsx | 136 - .../app/chat/files/images/FullImageModal.tsx | 50 - .../app/chat/files/images/InMessageImage.tsx | 36 - .../files/images/InputBarPreviewImage.tsx | 40 - web/src/app/chat/files/images/utils.ts | 3 - web/src/app/chat/folders/FolderList.tsx | 334 - web/src/app/chat/folders/FolderManagement.tsx | 82 - web/src/app/chat/folders/interfaces.ts | 8 - web/src/app/chat/input/ChatInputAssistant.tsx | 52 - web/src/app/chat/input/ChatInputBar.tsx | 639 - web/src/app/chat/input/ChatInputOption.tsx | 103 - .../app/chat/input/SelectedFilterDisplay.tsx | 152 - web/src/app/chat/interfaces.ts | 140 - web/src/app/chat/lib.tsx | 730 - web/src/app/chat/message/CodeBlock.tsx | 140 - web/src/app/chat/message/Messages.tsx | 933 -- web/src/app/chat/message/SearchSummary.tsx | 187 - web/src/app/chat/message/SkippedSearch.tsx | 48 - .../app/chat/message/custom-code-styles.css | 36 - web/src/app/chat/message/hooks.ts | 38 - web/src/app/chat/modal/FeedbackModal.tsx | 122 - .../app/chat/modal/SetDefaultModelModal.tsx | 208 - .../app/chat/modal/ShareChatSessionModal.tsx | 158 - .../modal/configuration/AssistantsTab.tsx | 92 - .../app/chat/modal/configuration/LlmTab.tsx | 125 - web/src/app/chat/modifiers/ChatFilters.tsx | 453 - .../app/chat/modifiers/SearchTypeSelector.tsx | 71 - .../app/chat/modifiers/SelectedDocuments.tsx | 25 - web/src/app/chat/page.tsx | 69 - web/src/app/chat/searchParams.ts | 27 - .../sessionSidebar/ChatSessionDisplay.tsx | 244 - .../chat/sessionSidebar/HistorySidebar.tsx | 194 - web/src/app/chat/sessionSidebar/PagesTab.tsx | 147 - web/src/app/chat/sessionSidebar/types.ts | 1 - .../shared/[chatId]/SharedChatDisplay.tsx | 126 - web/src/app/chat/shared/[chatId]/page.tsx | 71 - .../app/chat/shared_chat_search/FixedLogo.tsx | 47 - .../shared_chat_search/FunctionalWrapper.tsx | 157 - .../app/chat/tools/GeneratingImageDisplay.tsx | 102 - .../app/chat/tools/ToolRunningAnimation.tsx | 21 - web/src/app/chat/tools/constants.ts | 3 - web/src/app/chat/types.ts | 6 - web/src/app/chat/useDocumentSelection.ts | 69 - web/src/app/ee/LICENSE | 36 - .../ee/admin/api-key/DanswerApiKeyForm.tsx | 132 - web/src/app/ee/admin/api-key/lib.ts | 39 - web/src/app/ee/admin/api-key/page.tsx | 289 - web/src/app/ee/admin/api-key/types.ts | 15 - .../app/ee/admin/groups/ConnectorEditor.tsx | 64 - web/src/app/ee/admin/groups/UserEditor.tsx | 96 - .../ee/admin/groups/UserGroupCreationForm.tsx | 151 - .../app/ee/admin/groups/UserGroupsTable.tsx | 296 - .../groups/[groupId]/AddConnectorForm.tsx | 156 - .../admin/groups/[groupId]/AddMemberForm.tsx | 65 - .../[groupId]/AddTokenRateLimitForm.tsx | 60 - .../admin/groups/[groupId]/GroupDisplay.tsx | 447 - web/src/app/ee/admin/groups/[groupId]/hook.ts | 12 - web/src/app/ee/admin/groups/[groupId]/lib.ts | 29 - .../app/ee/admin/groups/[groupId]/page.tsx | 76 - web/src/app/ee/admin/groups/lib.ts | 17 - web/src/app/ee/admin/groups/page.tsx | 107 - web/src/app/ee/admin/groups/types.ts | 15 - web/src/app/ee/admin/layout.tsx | 11 - .../admin/performance/DateRangeSelector.tsx | 47 - .../CustomAnalyticsUpdateForm.tsx | 115 - .../performance/custom-analytics/page.tsx | 46 - web/src/app/ee/admin/performance/dateUtils.ts | 26 - web/src/app/ee/admin/performance/lib.ts | 100 - .../query-history/DownloadAsCSV.tsx | 13 - .../query-history/FeedbackBadge.tsx | 41 - .../query-history/QueryHistoryTable.tsx | 177 - .../performance/query-history/[id]/page.tsx | 103 - .../admin/performance/query-history/page.tsx | 15 - .../performance/usage/DanswerBotChart.tsx | 78 - .../admin/performance/usage/FeedbackChart.tsx | 75 - .../usage/QueryPerformanceChart.tsx | 94 - .../admin/performance/usage/UsageReports.tsx | 263 - .../app/ee/admin/performance/usage/page.tsx | 34 - .../app/ee/admin/performance/usage/types.ts | 62 - .../ee/admin/whitelabeling/ImageUpload.tsx | 72 - .../admin/whitelabeling/WhitelabelingForm.tsx | 301 - web/src/app/ee/admin/whitelabeling/page.tsx | 16 - web/src/app/ee/layout.tsx | 19 - web/src/app/globals.css | 232 - web/src/app/layout.tsx | 124 - web/src/app/page.tsx | 16 - web/src/app/prompts/page.tsx | 40 - web/src/app/search/WrappedSearch.tsx | 51 - web/src/app/search/page.tsx | 218 - web/src/components/AdvancedOptionsToggle.tsx | 28 - web/src/components/BackButton.tsx | 39 - web/src/components/BasicClickable.tsx | 106 - web/src/components/Bubble.tsx | 43 - web/src/components/Button.tsx | 37 - web/src/components/CopyButton.tsx | 29 - web/src/components/CustomCheckbox.tsx | 35 - .../components/DanswerInitializingLoader.tsx | 18 - web/src/components/DeleteButton.tsx | 28 - web/src/components/Dropdown.tsx | 454 - web/src/components/EditButton.tsx | 25 - web/src/components/EditableValue.tsx | 73 - web/src/components/ErrorCallout.tsx | 23 - web/src/components/HoverPopup.tsx | 65 - web/src/components/Hoverable.tsx | 41 - web/src/components/InternetSearchIcon.tsx | 9 - web/src/components/IsPublicGroupSelector.tsx | 163 - web/src/components/Loading.tsx | 62 - web/src/components/Logo.tsx | 46 - web/src/components/MetadataBadge.tsx | 27 - web/src/components/Modal.tsx | 92 - web/src/components/MultiSelectDropdown.tsx | 113 - web/src/components/PageSelector.tsx | 149 - web/src/components/SSRAutoRefresh.tsx | 31 - web/src/components/SourceIcon.tsx | 16 - web/src/components/Spinner.tsx | 9 - web/src/components/Status.tsx | 134 - web/src/components/SwitchModelModal.tsx | 38 - web/src/components/UserDropdown.tsx | 151 - web/src/components/admin/ClientLayout.tsx | 338 - web/src/components/admin/Layout.tsx | 52 - web/src/components/admin/Title.tsx | 29 - .../admin/connectors/AdminSidebar.tsx | 133 - .../connectors/AttachCredentialPopup.tsx | 3 - .../admin/connectors/BasicTable.tsx | 83 - .../admin/connectors/ConnectorForm.tsx | 382 - .../admin/connectors/ConnectorTitle.tsx | 132 - .../admin/connectors/CredentialForm.tsx | 103 - .../admin/connectors/CustomButton.tsx | 0 web/src/components/admin/connectors/Field.tsx | 574 - .../admin/connectors/FileUpload.tsx | 62 - .../admin/connectors/IsPublicField.tsx | 20 - web/src/components/admin/connectors/Popup.tsx | 39 - .../AttachCredentialButtonForTable.tsx | 20 - .../buttons/IndexButtonForTable.tsx | 20 - web/src/components/admin/connectors/types.ts | 13 - web/src/components/admin/users/BulkAdd.tsx | 85 - .../admin/users/CenteredPageSelector.tsx | 20 - .../admin/users/InvitedUserTable.tsx | 109 - .../admin/users/SignedUpUserTable.tsx | 236 - .../components/assistants/AssistantCards.tsx | 117 - .../components/assistants/AssistantIcon.tsx | 67 - web/src/components/chat_search/Header.tsx | 137 - .../chat_search/MinimalMarkdown.tsx | 35 - web/src/components/chat_search/hooks.ts | 83 - web/src/components/context/ChatContext.tsx | 38 - .../components/context/EmbeddingContext.tsx | 103 - web/src/components/context/FormContext.tsx | 100 - .../credentials/CredentialFields.tsx | 178 - .../credentials/CredentialSection.tsx | 205 - .../credentials/actions/CreateCredential.tsx | 278 - .../credentials/actions/EditCredential.tsx | 110 - .../credentials/actions/ModifyCredential.tsx | 290 - web/src/components/credentials/lib.ts | 51 - web/src/components/credentials/types.ts | 8 - .../documentSet/DocumentSetSelectable.tsx | 54 - .../components/embedding/CustomModelForm.tsx | 128 - .../components/embedding/EmbeddingSidebar.tsx | 96 - .../components/embedding/ModelSelector.tsx | 142 - .../embedding/ReindexingProgressTable.tsx | 86 - web/src/components/embedding/interfaces.tsx | 353 - .../components/header/AnnouncementBanner.tsx | 101 - web/src/components/header/HeaderTitle.tsx | 14 - web/src/components/header/HeaderWrapper.tsx | 11 - web/src/components/header/LogoType.tsx | 120 - web/src/components/health/healthcheck.tsx | 63 - web/src/components/icons/icons.tsx | 2756 ---- web/src/components/icons/mixedbread.svg | 1 - .../search/NoCompleteSourceModal.tsx | 71 - .../initialSetup/search/NoSourcesModal.tsx | 55 - .../initialSetup/welcome/WelcomeModal.tsx | 270 - .../welcome/WelcomeModalWrapper.tsx | 24 - .../initialSetup/welcome/constants.ts | 1 - .../components/initialSetup/welcome/lib.ts | 56 - web/src/components/llm/ApiKeyForm.tsx | 77 - web/src/components/llm/ApiKeyModal.tsx | 74 - web/src/components/llm/LLMList.tsx | 84 - web/src/components/loading.css | 18 - .../components/modals/DeleteEntityModal.tsx | 39 - .../components/modals/ExceptionTraceModal.tsx | 49 - .../components/modals/GenericConfirmModal.tsx | 42 - web/src/components/modals/ModalWrapper.tsx | 63 - web/src/components/popover/DefaultPopover.tsx | 59 - web/src/components/popover/Popover.tsx | 71 - web/src/components/popover/styles.css | 10 - web/src/components/popup/Popup.tsx | 144 - .../components/resizable/ResizableSection.tsx | 121 - web/src/components/resizable/constants.ts | 2 - .../components/search/DateRangeSelector.tsx | 118 - web/src/components/search/DocumentDisplay.tsx | 376 - .../search/DocumentFeedbackBlock.tsx | 139 - .../search/DocumentUpdatedAtBadge.tsx | 6 - web/src/components/search/PersonaSelector.tsx | 55 - web/src/components/search/QAFeedback.tsx | 97 - web/src/components/search/SearchAnswer.tsx | 165 - web/src/components/search/SearchBar.tsx | 298 - web/src/components/search/SearchHelper.tsx | 101 - .../search/SearchResultsDisplay.tsx | 286 - web/src/components/search/SearchSection.tsx | 779 -- .../components/search/SearchTypeSelector.tsx | 43 - .../search/filtering/FilterDropdown.tsx | 123 - .../components/search/filtering/Filters.tsx | 519 - .../components/search/filtering/TagFilter.tsx | 152 - .../search/results/AnswerSection.tsx | 88 - .../components/search/results/Citation.tsx | 55 - .../search/results/QuotesSection.tsx | 117 - .../search/results/ResponseSection.tsx | 79 - .../components/settings/SettingsProvider.tsx | 32 - web/src/components/settings/lib.ts | 101 - .../usePaidEnterpriseFeaturesEnabled.ts | 12 - web/src/components/spinner.css | 23 - web/src/components/table/DragHandle.tsx | 15 - web/src/components/table/DraggableRow.tsx | 51 - web/src/components/table/DraggableTable.tsx | 122 - .../components/table/DraggableTableBody.tsx | 93 - web/src/components/table/StaticRow.tsx | 23 - web/src/components/table/interfaces.ts | 7 - web/src/components/tooltip/CustomTooltip.tsx | 168 - web/src/components/tooltip/Tooltip.tsx | 48 - web/src/components/user/UserProvider.tsx | 55 - .../lib/admin/users/userMutationFetcher.ts | 18 - web/src/lib/assistantIconUtils.tsx | 134 - web/src/lib/assistants/checkOwnership.ts | 19 - web/src/lib/assistants/fetchAssistantsSS.ts | 12 - .../assistants/fetchPersonaEditorInfoSS.ts | 125 - web/src/lib/assistants/orderAssistants.ts | 29 - web/src/lib/assistants/shareAssistant.ts | 59 - .../assistants/updateAssistantPreferences.ts | 62 - web/src/lib/browserUtilities.tsx | 32 - web/src/lib/ccPair.ts | 48 - .../lib/chat/fetchAssistantsGalleryData.ts | 0 web/src/lib/chat/fetchChatData.ts | 248 - web/src/lib/chat/fetchSomeChatData.ts | 236 - web/src/lib/clickUtils.ts | 20 - web/src/lib/connector.ts | 115 - web/src/lib/connectors/connectors.ts | 949 -- web/src/lib/connectors/credentials.ts | 429 - web/src/lib/constants.ts | 56 - web/src/lib/contains.ts | 53 - web/src/lib/credential.ts | 99 - web/src/lib/dateUtils.ts | 41 - web/src/lib/documentDeletion.ts | 62 - web/src/lib/documentUtils.ts | 21 - web/src/lib/drag/constants.ts | 2 - web/src/lib/fetchUtils.ts | 7 - web/src/lib/fetcher.ts | 44 - web/src/lib/fileUtils.ts | 4 - web/src/lib/filters.ts | 32 - web/src/lib/gmail.ts | 42 - web/src/lib/googleDrive.ts | 44 - web/src/lib/hooks.ts | 304 - web/src/lib/indexAttempt.ts | 20 - web/src/lib/llm/fetchLLMs.ts | 10 - web/src/lib/llm/utils.ts | 99 - web/src/lib/redirectSS.ts | 23 - web/src/lib/search/cancellable.ts | 42 - web/src/lib/search/chatSessions.ts | 12 - web/src/lib/search/interfaces.ts | 163 - web/src/lib/search/keyword.ts | 45 - web/src/lib/search/streamingQa.ts | 200 - web/src/lib/search/streamingUtils.ts | 137 - web/src/lib/search/utils.ts | 24 - web/src/lib/search/utilsSS.ts | 30 - web/src/lib/sources.ts | 345 - web/src/lib/ss/ccPair.ts | 5 - web/src/lib/tags/tagUtils.ts | 23 - web/src/lib/time.ts | 143 - web/src/lib/tools/edit.ts | 111 - web/src/lib/tools/fetchTools.ts | 34 - web/src/lib/tools/interfaces.ts | 23 - web/src/lib/types.ts | 242 - web/src/lib/urlBuilder.ts | 21 - web/src/lib/user.ts | 59 - web/src/lib/userSS.ts | 150 - web/src/lib/users/UserSettings.tsx | 15 - web/src/lib/users/interfaces.ts | 8 - web/src/lib/utilsSS.ts | 30 - web/src/lib/version.ts | 27 - web/src/middleware.ts | 39 - web/tailwind-themes/custom/.gitignore | 4 - web/tailwind-themes/tailwind.config.js | 306 - web/tailwind.config.js | 11 - web/tsconfig.json | 28 - 1196 files changed, 1 insertion(+), 147630 deletions(-) delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/docker-build-push-backend-container-on-tag.yml delete mode 100644 .github/workflows/docker-build-push-model-server-container-on-tag.yml delete mode 100644 .github/workflows/docker-build-push-web-container-on-tag.yml delete mode 100644 .github/workflows/docker-tag-latest.yml delete mode 100644 .github/workflows/helm-build-push.yml delete mode 100644 .github/workflows/pr-python-checks.yml delete mode 100644 .github/workflows/pr-python-tests.yml delete mode 100644 .github/workflows/pr-quality-checks.yml delete mode 100644 .github/workflows/run-it.yml delete mode 100644 .gitignore delete mode 100644 .pre-commit-config.yaml delete mode 100644 .vscode/env_template.txt delete mode 100644 .vscode/launch.template.jsonc delete mode 100644 CONTRIBUTING.md delete mode 100644 backend/.dockerignore delete mode 100644 backend/.gitignore delete mode 100644 backend/.trivyignore delete mode 100644 backend/Dockerfile delete mode 100644 backend/Dockerfile.model_server delete mode 100644 backend/alembic.ini delete mode 100644 backend/alembic/README.md delete mode 100644 backend/alembic/env.py delete mode 100644 backend/alembic/script.py.mako delete mode 100644 backend/alembic/versions/0568ccf46a6b_add_thread_specific_model_selection.py delete mode 100644 backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py delete mode 100644 backend/alembic/versions/08a1eda20fe1_add_earliest_indexing_to_connector.py delete mode 100644 backend/alembic/versions/0a2b51deb0b8_add_starter_prompts.py delete mode 100644 backend/alembic/versions/0a98909f2757_enable_encrypted_fields.py delete mode 100644 backend/alembic/versions/15326fcec57e_introduce_danswer_apis.py delete mode 100644 backend/alembic/versions/173cae5bba26_port_config_store.py delete mode 100644 backend/alembic/versions/1f60f60c3401_embedding_model_search_settings.py delete mode 100644 backend/alembic/versions/213fd978c6d8_notifications.py delete mode 100644 backend/alembic/versions/23957775e5f5_remove_feedback_foreignkey_constraint.py delete mode 100644 backend/alembic/versions/2666d766cb9b_google_oauth2.py delete mode 100644 backend/alembic/versions/27c6ecc08586_permission_framework.py delete mode 100644 backend/alembic/versions/2d2304e27d8c_add_above_below_to_persona.py delete mode 100644 backend/alembic/versions/30c1d5744104_persona_datetime_aware.py delete mode 100644 backend/alembic/versions/325975216eb3_add_icon_color_and_icon_shape_to_persona.py delete mode 100644 backend/alembic/versions/351faebd379d_add_curator_fields.py delete mode 100644 backend/alembic/versions/3879338f8ba1_add_tool_table.py delete mode 100644 backend/alembic/versions/38eda64af7fe_add_chat_session_sharing.py delete mode 100644 backend/alembic/versions/3a7802814195_add_alternate_assistant_to_chat_message.py delete mode 100644 backend/alembic/versions/3b25685ff73c_move_is_public_to_cc_pair.py delete mode 100644 backend/alembic/versions/3c5e35aa9af0_polling_document_count.py delete mode 100644 backend/alembic/versions/401c1ac29467_add_tables_for_ui_based_llm_.py delete mode 100644 backend/alembic/versions/43cbbb3f5e6a_rename_index_origin_to_index_recursively.py delete mode 100644 backend/alembic/versions/44f856ae2a4a_add_cloud_embedding_model.py delete mode 100644 backend/alembic/versions/4505fd7302e1_added_is_internet_to_dbdoc.py delete mode 100644 backend/alembic/versions/465f78d9b7f9_larger_access_tokens_for_oauth.py delete mode 100644 backend/alembic/versions/46625e4745d4_remove_native_enum.py delete mode 100644 backend/alembic/versions/4738e4b3bae1_pg_file_store.py delete mode 100644 backend/alembic/versions/473a1a7ca408_add_display_model_names_to_llm_provider.py delete mode 100644 backend/alembic/versions/47433d30de82_create_indexattempt_table.py delete mode 100644 backend/alembic/versions/475fcefe8826_add_name_to_api_key.py delete mode 100644 backend/alembic/versions/48d14957fe80_add_support_for_custom_tools.py delete mode 100644 backend/alembic/versions/4a951134c801_moved_status_to_connector_credential_.py delete mode 100644 backend/alembic/versions/4b08d97e175a_change_default_prune_freq.py delete mode 100644 backend/alembic/versions/4ea2c93919c1_add_type_to_credentials.py delete mode 100644 backend/alembic/versions/50b683a8295c_add_additional_retrieval_controls_to_.py delete mode 100644 backend/alembic/versions/570282d33c49_track_danswerbot_explicitly.py delete mode 100644 backend/alembic/versions/57b53544726e_add_document_set_tables.py delete mode 100644 backend/alembic/versions/5809c0787398_add_chat_sessions.py delete mode 100644 backend/alembic/versions/5e84129c8be3_add_docs_indexed_column_to_index_.py delete mode 100644 backend/alembic/versions/5f4b8568a221_add_removed_documents_to_index_attempt.py delete mode 100644 backend/alembic/versions/5fc1f54cc252_hybrid_enum.py delete mode 100644 backend/alembic/versions/643a84a42a33_add_user_configured_names_to_llmprovider.py delete mode 100644 backend/alembic/versions/6d387b3196c2_basic_auth.py delete mode 100644 backend/alembic/versions/703313b75876_add_tokenratelimit_tables.py delete mode 100644 backend/alembic/versions/70f00c45c0f2_more_descriptive_filestore.py delete mode 100644 backend/alembic/versions/72bdc9929a46_permission_auto_sync_framework.py delete mode 100644 backend/alembic/versions/7477a5f5d728_added_model_defaults_for_users.py delete mode 100644 backend/alembic/versions/7547d982db8f_chat_folders.py delete mode 100644 backend/alembic/versions/767f1c2a00eb_count_chat_tokens.py delete mode 100644 backend/alembic/versions/76b60d407dfb_cc_pair_name_not_unique.py delete mode 100644 backend/alembic/versions/776b3bbe9092_remove_remaining_enums.py delete mode 100644 backend/alembic/versions/77d07dffae64_forcibly_remove_more_enum_types_from_.py delete mode 100644 backend/alembic/versions/78dbe7e38469_task_tracking.py delete mode 100644 backend/alembic/versions/795b20b85b4b_add_llm_group_permissions_control.py delete mode 100644 backend/alembic/versions/79acd316403a_add_api_key_table.py delete mode 100644 backend/alembic/versions/7aea705850d5_added_slack_auto_filter.py delete mode 100644 backend/alembic/versions/7ccea01261f6_store_chat_retrieval_docs.py delete mode 100644 backend/alembic/versions/7da0ae5ad583_add_description_to_persona.py delete mode 100644 backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py delete mode 100644 backend/alembic/versions/7f726bad5367_slack_followup.py delete mode 100644 backend/alembic/versions/7f99be1cb9f5_add_index_for_getting_documents_just_by_.py delete mode 100644 backend/alembic/versions/800f48024ae9_add_id_to_connectorcredentialpair.py delete mode 100644 backend/alembic/versions/80696cf850ae_add_chat_session_to_query_event.py delete mode 100644 backend/alembic/versions/891cd83c87a8_add_is_visible_to_persona.py delete mode 100644 backend/alembic/versions/8987770549c0_add_full_exception_stack_trace.py delete mode 100644 backend/alembic/versions/8a87bd6ec550_associate_index_attempts_with_ccpair.py delete mode 100644 backend/alembic/versions/8aabb57f3b49_restructure_document_indices.py delete mode 100644 backend/alembic/versions/8e26726b7683_chat_context_addition.py delete mode 100644 backend/alembic/versions/904451035c9b_store_tool_details.py delete mode 100644 backend/alembic/versions/904e5138fffb_tags.py delete mode 100644 backend/alembic/versions/91fd3b470d1a_remove_documentsource_from_tag.py delete mode 100644 backend/alembic/versions/91ffac7e65b3_add_expiry_time.py delete mode 100644 backend/alembic/versions/9d97fecfab7f_added_retrieved_docs_to_query_event.py delete mode 100644 backend/alembic/versions/a3bfd0d64902_add_chosen_assistants_to_user_table.py delete mode 100644 backend/alembic/versions/a570b80a5f20_usergroup_tables.py delete mode 100644 backend/alembic/versions/ae62505e3acc_add_saml_accounts.py delete mode 100644 backend/alembic/versions/b082fec533f0_make_last_attempt_status_nullable.py delete mode 100644 backend/alembic/versions/b156fa702355_chat_reworked.py delete mode 100644 backend/alembic/versions/b85f02ec1308_fix_file_type_migration.py delete mode 100644 backend/alembic/versions/b896bbd0d5a7_backfill_is_internet_data_to_false.py delete mode 100644 backend/alembic/versions/baf71f781b9e_add_llm_model_version_override_to_.py delete mode 100644 backend/alembic/versions/bc9771dccadf_create_usage_reports_table.py delete mode 100644 backend/alembic/versions/c18cdf4b497e_add_standard_answer_tables.py delete mode 100644 backend/alembic/versions/c5b692fa265c_add_index_attempt_errors_table.py delete mode 100644 backend/alembic/versions/d5645c915d0e_remove_deletion_attempt_table.py delete mode 100644 backend/alembic/versions/d61e513bef0a_add_total_docs_for_index_attempt.py delete mode 100644 backend/alembic/versions/d7111c1238cd_remove_document_ids.py delete mode 100644 backend/alembic/versions/d716b0791ddd_combined_slack_id_fields.py delete mode 100644 backend/alembic/versions/d929f0c1c6af_feedback_feature.py delete mode 100644 backend/alembic/versions/d9ec13955951_remove__dim_suffix_from_model_name.py delete mode 100644 backend/alembic/versions/da4c21c69164_chosen_assistants_changed_to_jsonb.py delete mode 100644 backend/alembic/versions/dba7f71618f5_danswer_custom_tool_flow.py delete mode 100644 backend/alembic/versions/dbaa756c2ccf_embedding_models.py delete mode 100644 backend/alembic/versions/df0c7ad8a076_added_deletion_attempt_table.py delete mode 100644 backend/alembic/versions/e0a68a81d434_add_chat_feedback.py delete mode 100644 backend/alembic/versions/e1392f05e840_added_input_prompts.py delete mode 100644 backend/alembic/versions/e209dc5a8156_added_prune_frequency.py delete mode 100644 backend/alembic/versions/e50154680a5c_no_source_enum.py delete mode 100644 backend/alembic/versions/e6a4bbc13fe4_add_index_for_retrieving_latest_index_.py delete mode 100644 backend/alembic/versions/e86866a9c78a_add_persona_to_chat_session.py delete mode 100644 backend/alembic/versions/e91df4e935ef_private_personas_documentsets.py delete mode 100644 backend/alembic/versions/ec3ec2eabf7b_index_from_beginning.py delete mode 100644 backend/alembic/versions/ec85f2b3c544_remove_last_attempt_status_from_cc_pair.py delete mode 100644 backend/alembic/versions/ecab2b3f1a3b_add_overrides_to_the_chat_session.py delete mode 100644 backend/alembic/versions/ee3f4b47fad5_added_alternate_model_to_chat_message.py delete mode 100644 backend/alembic/versions/ef7da92f7213_add_files_to_chatmessage.py delete mode 100644 backend/alembic/versions/f17bf3b0d9f1_embedding_provider_by_provider_type.py delete mode 100644 backend/alembic/versions/f1c6478c3fd8_add_pre_defined_feedback.py delete mode 100644 backend/alembic/versions/fad14119fb92_delete_tags_with_wrong_enum.py delete mode 100644 backend/alembic/versions/fcd135795f21_add_slack_bot_display_type.py delete mode 100644 backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py delete mode 100644 backend/alembic/versions/ffc707a226b4_basic_document_metadata.py delete mode 100644 backend/assets/.gitignore delete mode 100644 backend/danswer/__init__.py delete mode 100644 backend/danswer/access/__init__.py delete mode 100644 backend/danswer/access/access.py delete mode 100644 backend/danswer/access/models.py delete mode 100644 backend/danswer/access/utils.py delete mode 100644 backend/danswer/auth/__init__.py delete mode 100644 backend/danswer/auth/invited_users.py delete mode 100644 backend/danswer/auth/noauth_user.py delete mode 100644 backend/danswer/auth/schemas.py delete mode 100644 backend/danswer/auth/users.py delete mode 100644 backend/danswer/background/celery/celery_app.py delete mode 100644 backend/danswer/background/celery/celery_run.py delete mode 100644 backend/danswer/background/celery/celery_utils.py delete mode 100644 backend/danswer/background/connector_deletion.py delete mode 100644 backend/danswer/background/indexing/checkpointing.py delete mode 100644 backend/danswer/background/indexing/dask_utils.py delete mode 100644 backend/danswer/background/indexing/job_client.py delete mode 100644 backend/danswer/background/indexing/run_indexing.py delete mode 100644 backend/danswer/background/indexing/tracer.py delete mode 100644 backend/danswer/background/task_utils.py delete mode 100755 backend/danswer/background/update.py delete mode 100644 backend/danswer/chat/__init__.py delete mode 100644 backend/danswer/chat/chat_utils.py delete mode 100644 backend/danswer/chat/input_prompts.yaml delete mode 100644 backend/danswer/chat/load_yamls.py delete mode 100644 backend/danswer/chat/models.py delete mode 100644 backend/danswer/chat/personas.yaml delete mode 100644 backend/danswer/chat/process_message.py delete mode 100644 backend/danswer/chat/prompts.yaml delete mode 100644 backend/danswer/chat/tools.py delete mode 100644 backend/danswer/configs/__init__.py delete mode 100644 backend/danswer/configs/app_configs.py delete mode 100644 backend/danswer/configs/chat_configs.py delete mode 100644 backend/danswer/configs/constants.py delete mode 100644 backend/danswer/configs/danswerbot_configs.py delete mode 100644 backend/danswer/configs/model_configs.py delete mode 100644 backend/danswer/connectors/README.md delete mode 100644 backend/danswer/connectors/__init__.py delete mode 100644 backend/danswer/connectors/axero/__init__.py delete mode 100644 backend/danswer/connectors/axero/connector.py delete mode 100644 backend/danswer/connectors/blob/__init__.py delete mode 100644 backend/danswer/connectors/blob/connector.py delete mode 100644 backend/danswer/connectors/bookstack/__init__.py delete mode 100644 backend/danswer/connectors/bookstack/client.py delete mode 100644 backend/danswer/connectors/bookstack/connector.py delete mode 100644 backend/danswer/connectors/clickup/__init__.py delete mode 100644 backend/danswer/connectors/clickup/connector.py delete mode 100644 backend/danswer/connectors/confluence/__init__.py delete mode 100644 backend/danswer/connectors/confluence/connector.py delete mode 100644 backend/danswer/connectors/confluence/rate_limit_handler.py delete mode 100644 backend/danswer/connectors/connector_runner.py delete mode 100644 backend/danswer/connectors/cross_connector_utils/__init__.py delete mode 100644 backend/danswer/connectors/cross_connector_utils/miscellaneous_utils.py delete mode 100644 backend/danswer/connectors/cross_connector_utils/rate_limit_wrapper.py delete mode 100644 backend/danswer/connectors/cross_connector_utils/retry_wrapper.py delete mode 100644 backend/danswer/connectors/danswer_jira/__init__.py delete mode 100644 backend/danswer/connectors/danswer_jira/connector.py delete mode 100644 backend/danswer/connectors/danswer_jira/utils.py delete mode 100644 backend/danswer/connectors/discourse/__init__.py delete mode 100644 backend/danswer/connectors/discourse/connector.py delete mode 100644 backend/danswer/connectors/document360/__init__.py delete mode 100644 backend/danswer/connectors/document360/connector.py delete mode 100644 backend/danswer/connectors/document360/utils.py delete mode 100644 backend/danswer/connectors/dropbox/__init__.py delete mode 100644 backend/danswer/connectors/dropbox/connector.py delete mode 100644 backend/danswer/connectors/factory.py delete mode 100644 backend/danswer/connectors/file/__init__.py delete mode 100644 backend/danswer/connectors/file/connector.py delete mode 100644 backend/danswer/connectors/github/__init__.py delete mode 100644 backend/danswer/connectors/github/connector.py delete mode 100644 backend/danswer/connectors/gitlab/__init__.py delete mode 100644 backend/danswer/connectors/gitlab/connector.py delete mode 100644 backend/danswer/connectors/gmail/__init__.py delete mode 100644 backend/danswer/connectors/gmail/connector.py delete mode 100644 backend/danswer/connectors/gmail/connector_auth.py delete mode 100644 backend/danswer/connectors/gmail/constants.py delete mode 100644 backend/danswer/connectors/gong/__init__.py delete mode 100644 backend/danswer/connectors/gong/connector.py delete mode 100644 backend/danswer/connectors/google_drive/__init__.py delete mode 100644 backend/danswer/connectors/google_drive/connector.py delete mode 100644 backend/danswer/connectors/google_drive/connector_auth.py delete mode 100644 backend/danswer/connectors/google_drive/constants.py delete mode 100644 backend/danswer/connectors/google_site/__init__.py delete mode 100644 backend/danswer/connectors/google_site/connector.py delete mode 100644 backend/danswer/connectors/guru/__init__.py delete mode 100644 backend/danswer/connectors/guru/connector.py delete mode 100644 backend/danswer/connectors/hubspot/__init__.py delete mode 100644 backend/danswer/connectors/hubspot/connector.py delete mode 100644 backend/danswer/connectors/interfaces.py delete mode 100644 backend/danswer/connectors/linear/__init__.py delete mode 100644 backend/danswer/connectors/linear/connector.py delete mode 100644 backend/danswer/connectors/loopio/__init__.py delete mode 100644 backend/danswer/connectors/loopio/connector.py delete mode 100644 backend/danswer/connectors/mediawiki/__init__.py delete mode 100644 backend/danswer/connectors/mediawiki/family.py delete mode 100644 backend/danswer/connectors/mediawiki/wiki.py delete mode 100644 backend/danswer/connectors/models.py delete mode 100644 backend/danswer/connectors/notion/__init__.py delete mode 100644 backend/danswer/connectors/notion/connector.py delete mode 100644 backend/danswer/connectors/productboard/__init__.py delete mode 100644 backend/danswer/connectors/productboard/connector.py delete mode 100644 backend/danswer/connectors/requesttracker/.gitignore delete mode 100644 backend/danswer/connectors/requesttracker/__init__.py delete mode 100644 backend/danswer/connectors/requesttracker/connector.py delete mode 100644 backend/danswer/connectors/salesforce/__init__.py delete mode 100644 backend/danswer/connectors/salesforce/connector.py delete mode 100644 backend/danswer/connectors/salesforce/utils.py delete mode 100644 backend/danswer/connectors/sharepoint/__init__.py delete mode 100644 backend/danswer/connectors/sharepoint/connector.py delete mode 100644 backend/danswer/connectors/slab/__init__.py delete mode 100644 backend/danswer/connectors/slab/connector.py delete mode 100644 backend/danswer/connectors/slack/__init__.py delete mode 100644 backend/danswer/connectors/slack/connector.py delete mode 100644 backend/danswer/connectors/slack/load_connector.py delete mode 100644 backend/danswer/connectors/slack/utils.py delete mode 100644 backend/danswer/connectors/teams/__init__.py delete mode 100644 backend/danswer/connectors/teams/connector.py delete mode 100644 backend/danswer/connectors/web/__init__.py delete mode 100644 backend/danswer/connectors/web/connector.py delete mode 100644 backend/danswer/connectors/wikipedia/__init__.py delete mode 100644 backend/danswer/connectors/wikipedia/connector.py delete mode 100644 backend/danswer/connectors/zendesk/__init__.py delete mode 100644 backend/danswer/connectors/zendesk/connector.py delete mode 100644 backend/danswer/connectors/zulip/__init__.py delete mode 100644 backend/danswer/connectors/zulip/connector.py delete mode 100644 backend/danswer/connectors/zulip/schemas.py delete mode 100644 backend/danswer/connectors/zulip/utils.py delete mode 100644 backend/danswer/danswerbot/slack/blocks.py delete mode 100644 backend/danswer/danswerbot/slack/config.py delete mode 100644 backend/danswer/danswerbot/slack/constants.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/__init__.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/handle_buttons.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/handle_message.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py delete mode 100644 backend/danswer/danswerbot/slack/handlers/utils.py delete mode 100644 backend/danswer/danswerbot/slack/icons.py delete mode 100644 backend/danswer/danswerbot/slack/listener.py delete mode 100644 backend/danswer/danswerbot/slack/models.py delete mode 100644 backend/danswer/danswerbot/slack/tokens.py delete mode 100644 backend/danswer/danswerbot/slack/utils.py delete mode 100644 backend/danswer/db/__init__.py delete mode 100644 backend/danswer/db/auth.py delete mode 100644 backend/danswer/db/chat.py delete mode 100644 backend/danswer/db/connector.py delete mode 100644 backend/danswer/db/connector_credential_pair.py delete mode 100644 backend/danswer/db/constants.py delete mode 100644 backend/danswer/db/credentials.py delete mode 100644 backend/danswer/db/deletion_attempt.py delete mode 100644 backend/danswer/db/document.py delete mode 100644 backend/danswer/db/document_set.py delete mode 100644 backend/danswer/db/engine.py delete mode 100644 backend/danswer/db/enums.py delete mode 100644 backend/danswer/db/feedback.py delete mode 100644 backend/danswer/db/folder.py delete mode 100644 backend/danswer/db/index_attempt.py delete mode 100644 backend/danswer/db/input_prompt.py delete mode 100644 backend/danswer/db/llm.py delete mode 100644 backend/danswer/db/models.py delete mode 100644 backend/danswer/db/notification.py delete mode 100644 backend/danswer/db/persona.py delete mode 100644 backend/danswer/db/pg_file_store.py delete mode 100644 backend/danswer/db/pydantic_type.py delete mode 100644 backend/danswer/db/search_settings.py delete mode 100644 backend/danswer/db/slack_bot_config.py delete mode 100644 backend/danswer/db/standard_answer.py delete mode 100644 backend/danswer/db/swap_index.py delete mode 100644 backend/danswer/db/tag.py delete mode 100644 backend/danswer/db/tasks.py delete mode 100644 backend/danswer/db/tools.py delete mode 100644 backend/danswer/db/users.py delete mode 100644 backend/danswer/db/utils.py delete mode 100644 backend/danswer/document_index/__init__.py delete mode 100644 backend/danswer/document_index/document_index_utils.py delete mode 100644 backend/danswer/document_index/factory.py delete mode 100644 backend/danswer/document_index/interfaces.py delete mode 100644 backend/danswer/document_index/vespa/__init__.py delete mode 100644 backend/danswer/document_index/vespa/app_config/schemas/danswer_chunk.sd delete mode 100644 backend/danswer/document_index/vespa/app_config/services.xml delete mode 100644 backend/danswer/document_index/vespa/app_config/validation-overrides.xml delete mode 100644 backend/danswer/document_index/vespa/chunk_retrieval.py delete mode 100644 backend/danswer/document_index/vespa/deletion.py delete mode 100644 backend/danswer/document_index/vespa/index.py delete mode 100644 backend/danswer/document_index/vespa/indexing_utils.py delete mode 100644 backend/danswer/document_index/vespa/shared_utils/utils.py delete mode 100644 backend/danswer/document_index/vespa/shared_utils/vespa_request_builders.py delete mode 100644 backend/danswer/document_index/vespa_constants.py delete mode 100644 backend/danswer/dynamic_configs/__init__.py delete mode 100644 backend/danswer/dynamic_configs/factory.py delete mode 100644 backend/danswer/dynamic_configs/interface.py delete mode 100644 backend/danswer/dynamic_configs/store.py delete mode 100644 backend/danswer/file_processing/__init__.py delete mode 100644 backend/danswer/file_processing/enums.py delete mode 100644 backend/danswer/file_processing/extract_file_text.py delete mode 100644 backend/danswer/file_processing/html_utils.py delete mode 100644 backend/danswer/file_store/constants.py delete mode 100644 backend/danswer/file_store/file_store.py delete mode 100644 backend/danswer/file_store/models.py delete mode 100644 backend/danswer/file_store/utils.py delete mode 100644 backend/danswer/indexing/__init__.py delete mode 100644 backend/danswer/indexing/chunker.py delete mode 100644 backend/danswer/indexing/embedder.py delete mode 100644 backend/danswer/indexing/indexing_pipeline.py delete mode 100644 backend/danswer/indexing/models.py delete mode 100644 backend/danswer/llm/__init__.py delete mode 100644 backend/danswer/llm/answering/answer.py delete mode 100644 backend/danswer/llm/answering/models.py delete mode 100644 backend/danswer/llm/answering/prompts/build.py delete mode 100644 backend/danswer/llm/answering/prompts/citations_prompt.py delete mode 100644 backend/danswer/llm/answering/prompts/quotes_prompt.py delete mode 100644 backend/danswer/llm/answering/prompts/utils.py delete mode 100644 backend/danswer/llm/answering/prune_and_merge.py delete mode 100644 backend/danswer/llm/answering/stream_processing/citation_processing.py delete mode 100644 backend/danswer/llm/answering/stream_processing/quotes_processing.py delete mode 100644 backend/danswer/llm/answering/stream_processing/utils.py delete mode 100644 backend/danswer/llm/chat_llm.py delete mode 100644 backend/danswer/llm/custom_llm.py delete mode 100644 backend/danswer/llm/exceptions.py delete mode 100644 backend/danswer/llm/factory.py delete mode 100644 backend/danswer/llm/headers.py delete mode 100644 backend/danswer/llm/interfaces.py delete mode 100644 backend/danswer/llm/llm_initialization.py delete mode 100644 backend/danswer/llm/llm_provider_options.py delete mode 100644 backend/danswer/llm/override_models.py delete mode 100644 backend/danswer/llm/utils.py delete mode 100644 backend/danswer/main.py delete mode 100644 backend/danswer/natural_language_processing/__init__.py delete mode 100644 backend/danswer/natural_language_processing/search_nlp_models.py delete mode 100644 backend/danswer/natural_language_processing/utils.py delete mode 100644 backend/danswer/one_shot_answer/__init__.py delete mode 100644 backend/danswer/one_shot_answer/answer_question.py delete mode 100644 backend/danswer/one_shot_answer/models.py delete mode 100644 backend/danswer/one_shot_answer/qa_utils.py delete mode 100644 backend/danswer/prompts/__init__.py delete mode 100644 backend/danswer/prompts/agentic_evaluation.py delete mode 100644 backend/danswer/prompts/answer_validation.py delete mode 100644 backend/danswer/prompts/chat_prompts.py delete mode 100644 backend/danswer/prompts/chat_tools.py delete mode 100644 backend/danswer/prompts/constants.py delete mode 100644 backend/danswer/prompts/direct_qa_prompts.py delete mode 100644 backend/danswer/prompts/filter_extration.py delete mode 100644 backend/danswer/prompts/llm_chunk_filter.py delete mode 100644 backend/danswer/prompts/miscellaneous_prompts.py delete mode 100644 backend/danswer/prompts/prompt_utils.py delete mode 100644 backend/danswer/prompts/query_validation.py delete mode 100644 backend/danswer/prompts/token_counts.py delete mode 100644 backend/danswer/search/__init__.py delete mode 100644 backend/danswer/search/enums.py delete mode 100644 backend/danswer/search/models.py delete mode 100644 backend/danswer/search/pipeline.py delete mode 100644 backend/danswer/search/postprocessing/postprocessing.py delete mode 100644 backend/danswer/search/postprocessing/reranker.py delete mode 100644 backend/danswer/search/preprocessing/access_filters.py delete mode 100644 backend/danswer/search/preprocessing/preprocessing.py delete mode 100644 backend/danswer/search/retrieval/search_runner.py delete mode 100644 backend/danswer/search/search_settings.py delete mode 100644 backend/danswer/search/utils.py delete mode 100644 backend/danswer/secondary_llm_flows/__init__.py delete mode 100644 backend/danswer/secondary_llm_flows/agentic_evaluation.py delete mode 100644 backend/danswer/secondary_llm_flows/answer_validation.py delete mode 100644 backend/danswer/secondary_llm_flows/chat_session_naming.py delete mode 100644 backend/danswer/secondary_llm_flows/choose_search.py delete mode 100644 backend/danswer/secondary_llm_flows/chunk_usefulness.py delete mode 100644 backend/danswer/secondary_llm_flows/query_expansion.py delete mode 100644 backend/danswer/secondary_llm_flows/query_validation.py delete mode 100644 backend/danswer/secondary_llm_flows/source_filter.py delete mode 100644 backend/danswer/secondary_llm_flows/time_filter.py delete mode 100644 backend/danswer/server/__init__.py delete mode 100644 backend/danswer/server/auth_check.py delete mode 100644 backend/danswer/server/danswer_api/__init__.py delete mode 100644 backend/danswer/server/danswer_api/ingestion.py delete mode 100644 backend/danswer/server/danswer_api/models.py delete mode 100644 backend/danswer/server/documents/__init__.py delete mode 100644 backend/danswer/server/documents/cc_pair.py delete mode 100644 backend/danswer/server/documents/connector.py delete mode 100644 backend/danswer/server/documents/credential.py delete mode 100644 backend/danswer/server/documents/document.py delete mode 100644 backend/danswer/server/documents/indexing.py delete mode 100644 backend/danswer/server/documents/models.py delete mode 100644 backend/danswer/server/features/__init__.py delete mode 100644 backend/danswer/server/features/document_set/__init__.py delete mode 100644 backend/danswer/server/features/document_set/api.py delete mode 100644 backend/danswer/server/features/document_set/models.py delete mode 100644 backend/danswer/server/features/folder/__init__.py delete mode 100644 backend/danswer/server/features/folder/api.py delete mode 100644 backend/danswer/server/features/folder/models.py delete mode 100644 backend/danswer/server/features/input_prompt/__init__.py delete mode 100644 backend/danswer/server/features/input_prompt/api.py delete mode 100644 backend/danswer/server/features/input_prompt/models.py delete mode 100644 backend/danswer/server/features/persona/__init__.py delete mode 100644 backend/danswer/server/features/persona/api.py delete mode 100644 backend/danswer/server/features/persona/models.py delete mode 100644 backend/danswer/server/features/prompt/__init__.py delete mode 100644 backend/danswer/server/features/prompt/api.py delete mode 100644 backend/danswer/server/features/prompt/models.py delete mode 100644 backend/danswer/server/features/tool/api.py delete mode 100644 backend/danswer/server/features/tool/models.py delete mode 100644 backend/danswer/server/gpts/api.py delete mode 100644 backend/danswer/server/manage/__init__.py delete mode 100644 backend/danswer/server/manage/administrative.py delete mode 100644 backend/danswer/server/manage/embedding/api.py delete mode 100644 backend/danswer/server/manage/embedding/models.py delete mode 100644 backend/danswer/server/manage/get_state.py delete mode 100644 backend/danswer/server/manage/llm/api.py delete mode 100644 backend/danswer/server/manage/llm/models.py delete mode 100644 backend/danswer/server/manage/models.py delete mode 100644 backend/danswer/server/manage/search_settings.py delete mode 100644 backend/danswer/server/manage/slack_bot.py delete mode 100644 backend/danswer/server/manage/standard_answer.py delete mode 100644 backend/danswer/server/manage/users.py delete mode 100644 backend/danswer/server/middleware/latency_logging.py delete mode 100644 backend/danswer/server/models.py delete mode 100644 backend/danswer/server/query_and_chat/__init__.py delete mode 100644 backend/danswer/server/query_and_chat/chat_backend.py delete mode 100644 backend/danswer/server/query_and_chat/models.py delete mode 100644 backend/danswer/server/query_and_chat/query_backend.py delete mode 100644 backend/danswer/server/query_and_chat/token_limit.py delete mode 100644 backend/danswer/server/settings/api.py delete mode 100644 backend/danswer/server/settings/models.py delete mode 100644 backend/danswer/server/settings/store.py delete mode 100644 backend/danswer/server/token_rate_limits/api.py delete mode 100644 backend/danswer/server/token_rate_limits/models.py delete mode 100644 backend/danswer/server/utils.py delete mode 100644 backend/danswer/tools/built_in_tools.py delete mode 100644 backend/danswer/tools/custom/base_tool_types.py delete mode 100644 backend/danswer/tools/custom/custom_tool.py delete mode 100644 backend/danswer/tools/custom/custom_tool_prompt_builder.py delete mode 100644 backend/danswer/tools/custom/custom_tool_prompts.py delete mode 100644 backend/danswer/tools/custom/openapi_parsing.py delete mode 100644 backend/danswer/tools/force.py delete mode 100644 backend/danswer/tools/images/image_generation_tool.py delete mode 100644 backend/danswer/tools/images/prompt.py delete mode 100644 backend/danswer/tools/internet_search/internet_search_tool.py delete mode 100644 backend/danswer/tools/internet_search/models.py delete mode 100644 backend/danswer/tools/message.py delete mode 100644 backend/danswer/tools/models.py delete mode 100644 backend/danswer/tools/search/search_tool.py delete mode 100644 backend/danswer/tools/search/search_utils.py delete mode 100644 backend/danswer/tools/tool.py delete mode 100644 backend/danswer/tools/tool_runner.py delete mode 100644 backend/danswer/tools/tool_selection.py delete mode 100644 backend/danswer/tools/utils.py delete mode 100644 backend/danswer/utils/__init__.py delete mode 100644 backend/danswer/utils/batching.py delete mode 100644 backend/danswer/utils/callbacks.py delete mode 100644 backend/danswer/utils/encryption.py delete mode 100644 backend/danswer/utils/logger.py delete mode 100644 backend/danswer/utils/sitemap.py delete mode 100644 backend/danswer/utils/telemetry.py delete mode 100644 backend/danswer/utils/text_processing.py delete mode 100644 backend/danswer/utils/threadpool_concurrency.py delete mode 100644 backend/danswer/utils/timing.py delete mode 100644 backend/danswer/utils/variable_functionality.py delete mode 100644 backend/ee/LICENSE delete mode 100644 backend/ee/__init__.py delete mode 100644 backend/ee/danswer/__init__.py delete mode 100644 backend/ee/danswer/access/access.py delete mode 100644 backend/ee/danswer/auth/__init__.py delete mode 100644 backend/ee/danswer/auth/api_key.py delete mode 100644 backend/ee/danswer/auth/users.py delete mode 100644 backend/ee/danswer/background/celery/celery_app.py delete mode 100644 backend/ee/danswer/background/celery_utils.py delete mode 100644 backend/ee/danswer/background/permission_sync.py delete mode 100644 backend/ee/danswer/background/task_name_builders.py delete mode 100644 backend/ee/danswer/configs/__init__.py delete mode 100644 backend/ee/danswer/configs/app_configs.py delete mode 100644 backend/ee/danswer/configs/saml_config/template.settings.json delete mode 100644 backend/ee/danswer/connectors/__init__.py delete mode 100644 backend/ee/danswer/connectors/confluence/__init__.py delete mode 100644 backend/ee/danswer/connectors/confluence/perm_sync.py delete mode 100644 backend/ee/danswer/connectors/factory.py delete mode 100644 backend/ee/danswer/db/__init__.py delete mode 100644 backend/ee/danswer/db/analytics.py delete mode 100644 backend/ee/danswer/db/api_key.py delete mode 100644 backend/ee/danswer/db/connector.py delete mode 100644 backend/ee/danswer/db/connector_credential_pair.py delete mode 100644 backend/ee/danswer/db/document.py delete mode 100644 backend/ee/danswer/db/document_set.py delete mode 100644 backend/ee/danswer/db/permission_sync.py delete mode 100644 backend/ee/danswer/db/persona.py delete mode 100644 backend/ee/danswer/db/query_history.py delete mode 100644 backend/ee/danswer/db/saml.py delete mode 100644 backend/ee/danswer/db/token_limit.py delete mode 100644 backend/ee/danswer/db/usage_export.py delete mode 100644 backend/ee/danswer/db/user_group.py delete mode 100644 backend/ee/danswer/main.py delete mode 100644 backend/ee/danswer/server/__init__.py delete mode 100644 backend/ee/danswer/server/analytics/api.py delete mode 100644 backend/ee/danswer/server/api_key/api.py delete mode 100644 backend/ee/danswer/server/api_key/models.py delete mode 100644 backend/ee/danswer/server/auth_check.py delete mode 100644 backend/ee/danswer/server/enterprise_settings/api.py delete mode 100644 backend/ee/danswer/server/enterprise_settings/models.py delete mode 100644 backend/ee/danswer/server/enterprise_settings/store.py delete mode 100644 backend/ee/danswer/server/query_and_chat/__init__.py delete mode 100644 backend/ee/danswer/server/query_and_chat/chat_backend.py delete mode 100644 backend/ee/danswer/server/query_and_chat/models.py delete mode 100644 backend/ee/danswer/server/query_and_chat/query_backend.py delete mode 100644 backend/ee/danswer/server/query_and_chat/token_limit.py delete mode 100644 backend/ee/danswer/server/query_history/api.py delete mode 100644 backend/ee/danswer/server/reporting/usage_export_api.py delete mode 100644 backend/ee/danswer/server/reporting/usage_export_generation.py delete mode 100644 backend/ee/danswer/server/reporting/usage_export_models.py delete mode 100644 backend/ee/danswer/server/saml.py delete mode 100644 backend/ee/danswer/server/seeding.py delete mode 100644 backend/ee/danswer/server/token_rate_limits/api.py delete mode 100644 backend/ee/danswer/server/user_group/api.py delete mode 100644 backend/ee/danswer/server/user_group/models.py delete mode 100644 backend/ee/danswer/user_groups/sync.py delete mode 100644 backend/ee/danswer/utils/__init__.py delete mode 100644 backend/ee/danswer/utils/encryption.py delete mode 100644 backend/ee/danswer/utils/secrets.py delete mode 100644 backend/model_server/__init__.py delete mode 100644 backend/model_server/constants.py delete mode 100644 backend/model_server/custom_models.py delete mode 100644 backend/model_server/danswer_torch_model.py delete mode 100644 backend/model_server/encoders.py delete mode 100644 backend/model_server/main.py delete mode 100644 backend/model_server/management_endpoints.py delete mode 100644 backend/model_server/utils.py delete mode 100644 backend/pyproject.toml delete mode 100644 backend/pytest.ini delete mode 100644 backend/requirements/cdk.txt delete mode 100644 backend/requirements/default.txt delete mode 100644 backend/requirements/dev.txt delete mode 100644 backend/requirements/ee.txt delete mode 100644 backend/requirements/model_server.txt delete mode 100644 backend/scripts/api_inference_sample.py delete mode 100644 backend/scripts/dev_run_background_jobs.py delete mode 100755 backend/scripts/force_delete_connector_by_id.py delete mode 100644 backend/scripts/reset_indexes.py delete mode 100644 backend/scripts/reset_postgres.py delete mode 100755 backend/scripts/restart_containers.sh delete mode 100644 backend/scripts/save_load_state.py delete mode 100644 backend/scripts/sources_selection_analysis.py delete mode 100644 backend/scripts/test-openapi-key.py delete mode 100644 backend/shared_configs/__init__.py delete mode 100644 backend/shared_configs/configs.py delete mode 100644 backend/shared_configs/enums.py delete mode 100644 backend/shared_configs/model_server_models.py delete mode 100644 backend/shared_configs/utils.py delete mode 100644 backend/slackbot_images/Confluence.png delete mode 100644 backend/slackbot_images/File.png delete mode 100644 backend/slackbot_images/Guru.png delete mode 100644 backend/slackbot_images/Jira.png delete mode 100644 backend/slackbot_images/README.md delete mode 100644 backend/slackbot_images/Web.png delete mode 100644 backend/slackbot_images/Zendesk.png delete mode 100644 backend/supervisord.conf delete mode 100644 backend/tests/api/test_api.py delete mode 100644 backend/tests/daily/connectors/confluence/test_confluence_basic.py delete mode 100644 backend/tests/daily/embedding/test_embeddings.py delete mode 100644 backend/tests/integration/Dockerfile delete mode 100644 backend/tests/integration/common_utils/chat.py delete mode 100644 backend/tests/integration/common_utils/connectors.py delete mode 100644 backend/tests/integration/common_utils/constants.py delete mode 100644 backend/tests/integration/common_utils/document_sets.py delete mode 100644 backend/tests/integration/common_utils/llm.py delete mode 100644 backend/tests/integration/common_utils/reset.py delete mode 100644 backend/tests/integration/common_utils/seed_documents.py delete mode 100644 backend/tests/integration/common_utils/user_groups.py delete mode 100644 backend/tests/integration/common_utils/vespa.py delete mode 100644 backend/tests/integration/conftest.py delete mode 100644 backend/tests/integration/tests/connector/test_deletion.py delete mode 100644 backend/tests/integration/tests/dev_apis/test_simple_chat_api.py delete mode 100644 backend/tests/integration/tests/document_set/test_syncing.py delete mode 100644 backend/tests/regression/answer_quality/README.md delete mode 100644 backend/tests/regression/answer_quality/__init__.py delete mode 100644 backend/tests/regression/answer_quality/api_utils.py delete mode 100644 backend/tests/regression/answer_quality/cli_utils.py delete mode 100644 backend/tests/regression/answer_quality/file_uploader.py delete mode 100644 backend/tests/regression/answer_quality/launch_eval_env.py delete mode 100644 backend/tests/regression/answer_quality/run_qa.py delete mode 100644 backend/tests/regression/answer_quality/search_test_config.yaml.template delete mode 100644 backend/tests/unit/danswer/connectors/confluence/test_rate_limit_handler.py delete mode 100644 backend/tests/unit/danswer/connectors/cross_connector_utils/test_html_utils.py delete mode 100644 backend/tests/unit/danswer/connectors/cross_connector_utils/test_rate_limit.py delete mode 100644 backend/tests/unit/danswer/connectors/cross_connector_utils/test_table.html delete mode 100644 backend/tests/unit/danswer/connectors/gmail/test_connector.py delete mode 100644 backend/tests/unit/danswer/connectors/mediawiki/__init__.py delete mode 100644 backend/tests/unit/danswer/connectors/mediawiki/test_mediawiki_family.py delete mode 100644 backend/tests/unit/danswer/connectors/mediawiki/test_wiki.py delete mode 100644 backend/tests/unit/danswer/direct_qa/test_qa_utils.py delete mode 100644 backend/tests/unit/danswer/indexing/test_chunker.py delete mode 100644 backend/tests/unit/danswer/llm/answering/stream_processing/test_citation_processing.py delete mode 100644 backend/tests/unit/danswer/llm/answering/stream_processing/test_quote_processing.py delete mode 100644 backend/tests/unit/danswer/llm/answering/test_prune_and_merge.py delete mode 100644 backend/throttle.ctrl delete mode 100644 deployment/.gitignore delete mode 100644 deployment/README.md delete mode 100644 deployment/data/nginx/app.conf.template delete mode 100644 deployment/data/nginx/app.conf.template.dev delete mode 100644 deployment/data/nginx/app.conf.template.no-letsencrypt delete mode 100755 deployment/data/nginx/run-nginx.sh delete mode 100644 deployment/docker_compose/README.md delete mode 100644 deployment/docker_compose/docker-compose.dev.yml delete mode 100644 deployment/docker_compose/docker-compose.gpu-dev.yml delete mode 100644 deployment/docker_compose/docker-compose.prod-no-letsencrypt.yml delete mode 100644 deployment/docker_compose/docker-compose.prod.yml delete mode 100644 deployment/docker_compose/docker-compose.search-testing.yml delete mode 100644 deployment/docker_compose/env.multilingual.template delete mode 100644 deployment/docker_compose/env.nginx.template delete mode 100644 deployment/docker_compose/env.prod.template delete mode 100755 deployment/docker_compose/init-letsencrypt.sh delete mode 100644 deployment/helm/.gitignore delete mode 100644 deployment/helm/.helmignore delete mode 100644 deployment/helm/Chart.lock delete mode 100644 deployment/helm/Chart.yaml delete mode 100644 deployment/helm/templates/_helpers.tpl delete mode 100644 deployment/helm/templates/api-deployment.yaml delete mode 100644 deployment/helm/templates/api-hpa.yaml delete mode 100644 deployment/helm/templates/api-service.yaml delete mode 100644 deployment/helm/templates/background-deployment.yaml delete mode 100644 deployment/helm/templates/background-hpa.yaml delete mode 100755 deployment/helm/templates/configmap.yaml delete mode 100644 deployment/helm/templates/danswer-secret.yaml delete mode 100644 deployment/helm/templates/indexing-model-deployment.yaml delete mode 100644 deployment/helm/templates/indexing-model-pvc.yaml delete mode 100644 deployment/helm/templates/indexing-model-service.yaml delete mode 100644 deployment/helm/templates/inference-model-deployment.yaml delete mode 100644 deployment/helm/templates/inference-model-pvc.yaml delete mode 100644 deployment/helm/templates/inference-model-service.yaml delete mode 100644 deployment/helm/templates/nginx-conf.yaml delete mode 100644 deployment/helm/templates/serviceaccount.yaml delete mode 100644 deployment/helm/templates/tests/test-connection.yaml delete mode 100644 deployment/helm/templates/webserver-deployment.yaml delete mode 100644 deployment/helm/templates/webserver-hpa.yaml delete mode 100644 deployment/helm/templates/webserver-service.yaml delete mode 100644 deployment/helm/values.yaml delete mode 100644 deployment/kubernetes/api_server-service-deployment.yaml delete mode 100644 deployment/kubernetes/background-deployment.yaml delete mode 100644 deployment/kubernetes/env-configmap.yaml delete mode 100644 deployment/kubernetes/indexing_model_server-service-deployment.yaml delete mode 100644 deployment/kubernetes/inference_model_server-service-deployment.yaml delete mode 100644 deployment/kubernetes/nginx-configmap.yaml delete mode 100644 deployment/kubernetes/nginx-service-deployment.yaml delete mode 100644 deployment/kubernetes/postgres-service-deployment.yaml delete mode 100644 deployment/kubernetes/secrets.yaml delete mode 100644 deployment/kubernetes/vespa-service-deployment.yaml delete mode 100644 deployment/kubernetes/web_server-service-deployment.yaml delete mode 100644 examples/widget/.env.example delete mode 100644 examples/widget/.eslintrc.json delete mode 100644 examples/widget/.gitignore delete mode 100644 examples/widget/README.md delete mode 100644 examples/widget/next.config.mjs delete mode 100644 examples/widget/package-lock.json delete mode 100644 examples/widget/package.json delete mode 100644 examples/widget/postcss.config.mjs delete mode 100644 examples/widget/src/app/globals.css delete mode 100644 examples/widget/src/app/layout.tsx delete mode 100644 examples/widget/src/app/page.tsx delete mode 100644 examples/widget/src/app/widget/Widget.tsx delete mode 100644 examples/widget/tailwind.config.ts delete mode 100644 examples/widget/tsconfig.json delete mode 100644 web/.dockerignore delete mode 100644 web/.eslintrc.json delete mode 100644 web/.gitignore delete mode 100644 web/.prettierignore delete mode 100644 web/.prettierrc.json delete mode 100644 web/Dockerfile delete mode 100644 web/README.md delete mode 100644 web/next.config.js delete mode 100644 web/package-lock.json delete mode 100644 web/package.json delete mode 100644 web/postcss.config.js delete mode 100644 web/public/Amazon.webp delete mode 100644 web/public/Anthropic.svg delete mode 100644 web/public/Axero.jpeg delete mode 100644 web/public/Azure.png delete mode 100644 web/public/Clickup.svg delete mode 100644 web/public/Cohere.svg delete mode 100644 web/public/Confluence.svg delete mode 100644 web/public/Discourse.png delete mode 100644 web/public/Document360.png delete mode 100644 web/public/Dropbox.png delete mode 100644 web/public/Github.png delete mode 100644 web/public/GithubDarkMode.png delete mode 100644 web/public/Gitlab.png delete mode 100644 web/public/Gmail.png delete mode 100644 web/public/Gong.png delete mode 100644 web/public/Google.webp delete mode 100644 web/public/GoogleCloudStorage.png delete mode 100644 web/public/GoogleDrive.png delete mode 100644 web/public/GoogleSites.png delete mode 100644 web/public/Guru.svg delete mode 100644 web/public/HubSpot.png delete mode 100644 web/public/Jira.svg delete mode 100644 web/public/Linear.png delete mode 100644 web/public/Loopio.png delete mode 100644 web/public/MediaWiki.svg delete mode 100644 web/public/Mixedbread.png delete mode 100644 web/public/Notion.png delete mode 100644 web/public/OCI.svg delete mode 100644 web/public/OpenSource.png delete mode 100644 web/public/Openai.svg delete mode 100644 web/public/Productboard.webp delete mode 100644 web/public/RequestTracker.png delete mode 100644 web/public/S3.png delete mode 100644 web/public/Salesforce.png delete mode 100644 web/public/Sharepoint.png delete mode 100644 web/public/SlabLogo.png delete mode 100644 web/public/Slack.png delete mode 100644 web/public/Teams.png delete mode 100644 web/public/Voyage.png delete mode 100644 web/public/Wikipedia.svg delete mode 100644 web/public/Zendesk.svg delete mode 100644 web/public/Zulip.png delete mode 100644 web/public/danswer.ico delete mode 100644 web/public/logo.png delete mode 100644 web/public/microsoft.png delete mode 100644 web/public/nomic.svg delete mode 100644 web/public/r2.png delete mode 100644 web/src/app/admin/add-connector/page.tsx delete mode 100644 web/src/app/admin/assistants/AssistantEditor.tsx delete mode 100644 web/src/app/admin/assistants/CollapsibleSection.tsx delete mode 100644 web/src/app/admin/assistants/HidableSection.tsx delete mode 100644 web/src/app/admin/assistants/PersonaTable.tsx delete mode 100644 web/src/app/admin/assistants/[id]/DeletePersonaButton.tsx delete mode 100644 web/src/app/admin/assistants/[id]/page.tsx delete mode 100644 web/src/app/admin/assistants/enums.ts delete mode 100644 web/src/app/admin/assistants/interfaces.ts delete mode 100644 web/src/app/admin/assistants/lib.ts delete mode 100644 web/src/app/admin/assistants/new/page.tsx delete mode 100644 web/src/app/admin/assistants/page.tsx delete mode 100644 web/src/app/admin/bot/SlackBotConfigCreationForm.tsx delete mode 100644 web/src/app/admin/bot/SlackBotTokensForm.tsx delete mode 100644 web/src/app/admin/bot/[id]/page.tsx delete mode 100644 web/src/app/admin/bot/hooks.ts delete mode 100644 web/src/app/admin/bot/lib.ts delete mode 100644 web/src/app/admin/bot/new/page.tsx delete mode 100644 web/src/app/admin/bot/page.tsx delete mode 100644 web/src/app/admin/configuration/llm/ConfiguredLLMProviderDisplay.tsx delete mode 100644 web/src/app/admin/configuration/llm/CustomLLMProviderUpdateForm.tsx delete mode 100644 web/src/app/admin/configuration/llm/LLMConfiguration.tsx delete mode 100644 web/src/app/admin/configuration/llm/LLMProviderUpdateForm.tsx delete mode 100644 web/src/app/admin/configuration/llm/constants.ts delete mode 100644 web/src/app/admin/configuration/llm/interfaces.ts delete mode 100644 web/src/app/admin/configuration/llm/page.tsx delete mode 100644 web/src/app/admin/configuration/search/UpgradingPage.tsx delete mode 100644 web/src/app/admin/configuration/search/page.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/ConfigDisplay.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/DeletionButton.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/IndexingAttemptsTable.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/ModifyStatusButtonCluster.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/ReIndexButton.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/lib.ts delete mode 100644 web/src/app/admin/connector/[ccPairId]/page.tsx delete mode 100644 web/src/app/admin/connector/[ccPairId]/types.ts delete mode 100644 web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/ConnectorWrapper.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/Sidebar.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/auth/callback/route.ts delete mode 100644 web/src/app/admin/connectors/[connector]/page.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/Advanced.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/ConnectorInput/FileInput.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/ConnectorInput/ListInput.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/ConnectorInput/NumberInput.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/ConnectorInput/SelectInput.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/DynamicConnectorCreationForm.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/formelements/NumberInput.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/gdrive/Credential.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/gmail/Credential.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/gmail/GmailPage.tsx delete mode 100644 web/src/app/admin/connectors/[connector]/pages/utils/files.ts delete mode 100644 web/src/app/admin/connectors/[connector]/pages/utils/google_site.ts delete mode 100644 web/src/app/admin/connectors/[connector]/pages/utils/hooks.ts delete mode 100644 web/src/app/admin/documents/ScoreEditor.tsx delete mode 100644 web/src/app/admin/documents/explorer/Explorer.tsx delete mode 100644 web/src/app/admin/documents/explorer/lib.ts delete mode 100644 web/src/app/admin/documents/explorer/page.tsx delete mode 100644 web/src/app/admin/documents/feedback/DocumentFeedbackTable.tsx delete mode 100644 web/src/app/admin/documents/feedback/constants.ts delete mode 100644 web/src/app/admin/documents/feedback/page.tsx delete mode 100644 web/src/app/admin/documents/lib.ts delete mode 100644 web/src/app/admin/documents/sets/DocumentSetCreationForm.tsx delete mode 100644 web/src/app/admin/documents/sets/[documentSetId]/page.tsx delete mode 100644 web/src/app/admin/documents/sets/hooks.tsx delete mode 100644 web/src/app/admin/documents/sets/lib.ts delete mode 100644 web/src/app/admin/documents/sets/new/page.tsx delete mode 100644 web/src/app/admin/documents/sets/page.tsx delete mode 100644 web/src/app/admin/embeddings/EmbeddingModelSelectionForm.tsx delete mode 100644 web/src/app/admin/embeddings/RerankingFormPage.tsx delete mode 100644 web/src/app/admin/embeddings/interfaces.ts delete mode 100644 web/src/app/admin/embeddings/modals/AlreadyPickedModal.tsx delete mode 100644 web/src/app/admin/embeddings/modals/ChangeCredentialsModal.tsx delete mode 100644 web/src/app/admin/embeddings/modals/DeleteCredentialsModal.tsx delete mode 100644 web/src/app/admin/embeddings/modals/ModelSelectionModal.tsx delete mode 100644 web/src/app/admin/embeddings/modals/ProviderCreationModal.tsx delete mode 100644 web/src/app/admin/embeddings/modals/SelectModelModal.tsx delete mode 100644 web/src/app/admin/embeddings/page.tsx delete mode 100644 web/src/app/admin/embeddings/pages/AdvancedEmbeddingFormPage.tsx delete mode 100644 web/src/app/admin/embeddings/pages/CloudEmbeddingPage.tsx delete mode 100644 web/src/app/admin/embeddings/pages/EmbeddingFormPage.tsx delete mode 100644 web/src/app/admin/embeddings/pages/OpenEmbeddingPage.tsx delete mode 100644 web/src/app/admin/indexing/[id]/IndexAttemptErrorsTable.tsx delete mode 100644 web/src/app/admin/indexing/[id]/lib.ts delete mode 100644 web/src/app/admin/indexing/[id]/page.tsx delete mode 100644 web/src/app/admin/indexing/[id]/types.ts delete mode 100644 web/src/app/admin/indexing/status/CCPairIndexingStatusTable.tsx delete mode 100644 web/src/app/admin/indexing/status/page.tsx delete mode 100644 web/src/app/admin/layout.tsx delete mode 100644 web/src/app/admin/prompt-library/hooks.ts delete mode 100644 web/src/app/admin/prompt-library/interfaces.ts delete mode 100644 web/src/app/admin/prompt-library/modals/AddPromptModal.tsx delete mode 100644 web/src/app/admin/prompt-library/modals/EditPromptModal.tsx delete mode 100644 web/src/app/admin/prompt-library/page.tsx delete mode 100644 web/src/app/admin/prompt-library/promptLibrary.tsx delete mode 100644 web/src/app/admin/prompt-library/promptSection.tsx delete mode 100644 web/src/app/admin/settings/SettingsForm.tsx delete mode 100644 web/src/app/admin/settings/interfaces.ts delete mode 100644 web/src/app/admin/settings/page.tsx delete mode 100644 web/src/app/admin/standard-answer/StandardAnswerCreationForm.tsx delete mode 100644 web/src/app/admin/standard-answer/[id]/page.tsx delete mode 100644 web/src/app/admin/standard-answer/hooks.ts delete mode 100644 web/src/app/admin/standard-answer/lib.ts delete mode 100644 web/src/app/admin/standard-answer/new/page.tsx delete mode 100644 web/src/app/admin/standard-answer/page.tsx delete mode 100644 web/src/app/admin/systeminfo/page.tsx delete mode 100644 web/src/app/admin/token-rate-limits/CreateRateLimitModal.tsx delete mode 100644 web/src/app/admin/token-rate-limits/TokenRateLimitTables.tsx delete mode 100644 web/src/app/admin/token-rate-limits/lib.ts delete mode 100644 web/src/app/admin/token-rate-limits/page.tsx delete mode 100644 web/src/app/admin/token-rate-limits/types.ts delete mode 100644 web/src/app/admin/tools/ToolEditor.tsx delete mode 100644 web/src/app/admin/tools/ToolsTable.tsx delete mode 100644 web/src/app/admin/tools/edit/[toolId]/DeleteToolButton.tsx delete mode 100644 web/src/app/admin/tools/edit/[toolId]/page.tsx delete mode 100644 web/src/app/admin/tools/new/page.tsx delete mode 100644 web/src/app/admin/tools/page.tsx delete mode 100644 web/src/app/admin/users/page.tsx delete mode 100644 web/src/app/assistants/AssistantSharedStatus.tsx delete mode 100644 web/src/app/assistants/AssistantsPageTitle.tsx delete mode 100644 web/src/app/assistants/LargeBackButton.tsx delete mode 100644 web/src/app/assistants/NavigationButton.tsx delete mode 100644 web/src/app/assistants/SidebarWrapper.tsx delete mode 100644 web/src/app/assistants/ToolsDisplay.tsx delete mode 100644 web/src/app/assistants/edit/[id]/page.tsx delete mode 100644 web/src/app/assistants/gallery/AssistantsGallery.tsx delete mode 100644 web/src/app/assistants/gallery/WrappedAssistantsGallery.tsx delete mode 100644 web/src/app/assistants/gallery/page.tsx delete mode 100644 web/src/app/assistants/mine/AssistantSharingModal.tsx delete mode 100644 web/src/app/assistants/mine/AssistantsList.tsx delete mode 100644 web/src/app/assistants/mine/WrappedAssistantsMine.tsx delete mode 100644 web/src/app/assistants/mine/WrappedInputPrompts.tsx delete mode 100644 web/src/app/assistants/mine/page.tsx delete mode 100644 web/src/app/assistants/new/page.tsx delete mode 100644 web/src/app/auth/error/page.tsx delete mode 100644 web/src/app/auth/lib.ts delete mode 100644 web/src/app/auth/login/EmailPasswordForm.tsx delete mode 100644 web/src/app/auth/login/LoginText.tsx delete mode 100644 web/src/app/auth/login/SignInButton.tsx delete mode 100644 web/src/app/auth/login/page.tsx delete mode 100644 web/src/app/auth/logout/route.ts delete mode 100644 web/src/app/auth/oauth/callback/route.ts delete mode 100644 web/src/app/auth/oidc/callback/route.ts delete mode 100644 web/src/app/auth/saml/callback/route.ts delete mode 100644 web/src/app/auth/signup/page.tsx delete mode 100644 web/src/app/auth/verify-email/Verify.tsx delete mode 100644 web/src/app/auth/verify-email/page.tsx delete mode 100644 web/src/app/auth/waiting-on-verification/RequestNewVerificationEmail.tsx delete mode 100644 web/src/app/auth/waiting-on-verification/page.tsx delete mode 100644 web/src/app/chat/ChatBanner.tsx delete mode 100644 web/src/app/chat/ChatIntro.tsx delete mode 100644 web/src/app/chat/ChatPage.tsx delete mode 100644 web/src/app/chat/ChatPersonaSelector.tsx delete mode 100644 web/src/app/chat/ChatPopup.tsx delete mode 100644 web/src/app/chat/RegenerateOption.tsx delete mode 100644 web/src/app/chat/StarterMessage.tsx delete mode 100644 web/src/app/chat/WrappedChat.tsx delete mode 100644 web/src/app/chat/documentSidebar/ChatDocumentDisplay.tsx delete mode 100644 web/src/app/chat/documentSidebar/DocumentSelector.tsx delete mode 100644 web/src/app/chat/documentSidebar/DocumentSidebar.tsx delete mode 100644 web/src/app/chat/documentSidebar/SelectedDocumentDisplay.tsx delete mode 100644 web/src/app/chat/files/InputBarPreview.tsx delete mode 100644 web/src/app/chat/files/documents/DocumentPreview.tsx delete mode 100644 web/src/app/chat/files/images/FullImageModal.tsx delete mode 100644 web/src/app/chat/files/images/InMessageImage.tsx delete mode 100644 web/src/app/chat/files/images/InputBarPreviewImage.tsx delete mode 100644 web/src/app/chat/files/images/utils.ts delete mode 100644 web/src/app/chat/folders/FolderList.tsx delete mode 100644 web/src/app/chat/folders/FolderManagement.tsx delete mode 100644 web/src/app/chat/folders/interfaces.ts delete mode 100644 web/src/app/chat/input/ChatInputAssistant.tsx delete mode 100644 web/src/app/chat/input/ChatInputBar.tsx delete mode 100644 web/src/app/chat/input/ChatInputOption.tsx delete mode 100644 web/src/app/chat/input/SelectedFilterDisplay.tsx delete mode 100644 web/src/app/chat/interfaces.ts delete mode 100644 web/src/app/chat/lib.tsx delete mode 100644 web/src/app/chat/message/CodeBlock.tsx delete mode 100644 web/src/app/chat/message/Messages.tsx delete mode 100644 web/src/app/chat/message/SearchSummary.tsx delete mode 100644 web/src/app/chat/message/SkippedSearch.tsx delete mode 100644 web/src/app/chat/message/custom-code-styles.css delete mode 100644 web/src/app/chat/message/hooks.ts delete mode 100644 web/src/app/chat/modal/FeedbackModal.tsx delete mode 100644 web/src/app/chat/modal/SetDefaultModelModal.tsx delete mode 100644 web/src/app/chat/modal/ShareChatSessionModal.tsx delete mode 100644 web/src/app/chat/modal/configuration/AssistantsTab.tsx delete mode 100644 web/src/app/chat/modal/configuration/LlmTab.tsx delete mode 100644 web/src/app/chat/modifiers/ChatFilters.tsx delete mode 100644 web/src/app/chat/modifiers/SearchTypeSelector.tsx delete mode 100644 web/src/app/chat/modifiers/SelectedDocuments.tsx delete mode 100644 web/src/app/chat/page.tsx delete mode 100644 web/src/app/chat/searchParams.ts delete mode 100644 web/src/app/chat/sessionSidebar/ChatSessionDisplay.tsx delete mode 100644 web/src/app/chat/sessionSidebar/HistorySidebar.tsx delete mode 100644 web/src/app/chat/sessionSidebar/PagesTab.tsx delete mode 100644 web/src/app/chat/sessionSidebar/types.ts delete mode 100644 web/src/app/chat/shared/[chatId]/SharedChatDisplay.tsx delete mode 100644 web/src/app/chat/shared/[chatId]/page.tsx delete mode 100644 web/src/app/chat/shared_chat_search/FixedLogo.tsx delete mode 100644 web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx delete mode 100644 web/src/app/chat/tools/GeneratingImageDisplay.tsx delete mode 100644 web/src/app/chat/tools/ToolRunningAnimation.tsx delete mode 100644 web/src/app/chat/tools/constants.ts delete mode 100644 web/src/app/chat/types.ts delete mode 100644 web/src/app/chat/useDocumentSelection.ts delete mode 100644 web/src/app/ee/LICENSE delete mode 100644 web/src/app/ee/admin/api-key/DanswerApiKeyForm.tsx delete mode 100644 web/src/app/ee/admin/api-key/lib.ts delete mode 100644 web/src/app/ee/admin/api-key/page.tsx delete mode 100644 web/src/app/ee/admin/api-key/types.ts delete mode 100644 web/src/app/ee/admin/groups/ConnectorEditor.tsx delete mode 100644 web/src/app/ee/admin/groups/UserEditor.tsx delete mode 100644 web/src/app/ee/admin/groups/UserGroupCreationForm.tsx delete mode 100644 web/src/app/ee/admin/groups/UserGroupsTable.tsx delete mode 100644 web/src/app/ee/admin/groups/[groupId]/AddConnectorForm.tsx delete mode 100644 web/src/app/ee/admin/groups/[groupId]/AddMemberForm.tsx delete mode 100644 web/src/app/ee/admin/groups/[groupId]/AddTokenRateLimitForm.tsx delete mode 100644 web/src/app/ee/admin/groups/[groupId]/GroupDisplay.tsx delete mode 100644 web/src/app/ee/admin/groups/[groupId]/hook.ts delete mode 100644 web/src/app/ee/admin/groups/[groupId]/lib.ts delete mode 100644 web/src/app/ee/admin/groups/[groupId]/page.tsx delete mode 100644 web/src/app/ee/admin/groups/lib.ts delete mode 100644 web/src/app/ee/admin/groups/page.tsx delete mode 100644 web/src/app/ee/admin/groups/types.ts delete mode 100644 web/src/app/ee/admin/layout.tsx delete mode 100644 web/src/app/ee/admin/performance/DateRangeSelector.tsx delete mode 100644 web/src/app/ee/admin/performance/custom-analytics/CustomAnalyticsUpdateForm.tsx delete mode 100644 web/src/app/ee/admin/performance/custom-analytics/page.tsx delete mode 100644 web/src/app/ee/admin/performance/dateUtils.ts delete mode 100644 web/src/app/ee/admin/performance/lib.ts delete mode 100644 web/src/app/ee/admin/performance/query-history/DownloadAsCSV.tsx delete mode 100644 web/src/app/ee/admin/performance/query-history/FeedbackBadge.tsx delete mode 100644 web/src/app/ee/admin/performance/query-history/QueryHistoryTable.tsx delete mode 100644 web/src/app/ee/admin/performance/query-history/[id]/page.tsx delete mode 100644 web/src/app/ee/admin/performance/query-history/page.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/DanswerBotChart.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/FeedbackChart.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/QueryPerformanceChart.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/UsageReports.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/page.tsx delete mode 100644 web/src/app/ee/admin/performance/usage/types.ts delete mode 100644 web/src/app/ee/admin/whitelabeling/ImageUpload.tsx delete mode 100644 web/src/app/ee/admin/whitelabeling/WhitelabelingForm.tsx delete mode 100644 web/src/app/ee/admin/whitelabeling/page.tsx delete mode 100644 web/src/app/ee/layout.tsx delete mode 100644 web/src/app/globals.css delete mode 100644 web/src/app/layout.tsx delete mode 100644 web/src/app/page.tsx delete mode 100644 web/src/app/prompts/page.tsx delete mode 100644 web/src/app/search/WrappedSearch.tsx delete mode 100644 web/src/app/search/page.tsx delete mode 100644 web/src/components/AdvancedOptionsToggle.tsx delete mode 100644 web/src/components/BackButton.tsx delete mode 100644 web/src/components/BasicClickable.tsx delete mode 100644 web/src/components/Bubble.tsx delete mode 100644 web/src/components/Button.tsx delete mode 100644 web/src/components/CopyButton.tsx delete mode 100644 web/src/components/CustomCheckbox.tsx delete mode 100644 web/src/components/DanswerInitializingLoader.tsx delete mode 100644 web/src/components/DeleteButton.tsx delete mode 100644 web/src/components/Dropdown.tsx delete mode 100644 web/src/components/EditButton.tsx delete mode 100644 web/src/components/EditableValue.tsx delete mode 100644 web/src/components/ErrorCallout.tsx delete mode 100644 web/src/components/HoverPopup.tsx delete mode 100644 web/src/components/Hoverable.tsx delete mode 100644 web/src/components/InternetSearchIcon.tsx delete mode 100644 web/src/components/IsPublicGroupSelector.tsx delete mode 100644 web/src/components/Loading.tsx delete mode 100644 web/src/components/Logo.tsx delete mode 100644 web/src/components/MetadataBadge.tsx delete mode 100644 web/src/components/Modal.tsx delete mode 100644 web/src/components/MultiSelectDropdown.tsx delete mode 100644 web/src/components/PageSelector.tsx delete mode 100644 web/src/components/SSRAutoRefresh.tsx delete mode 100644 web/src/components/SourceIcon.tsx delete mode 100644 web/src/components/Spinner.tsx delete mode 100644 web/src/components/Status.tsx delete mode 100644 web/src/components/SwitchModelModal.tsx delete mode 100644 web/src/components/UserDropdown.tsx delete mode 100644 web/src/components/admin/ClientLayout.tsx delete mode 100644 web/src/components/admin/Layout.tsx delete mode 100644 web/src/components/admin/Title.tsx delete mode 100644 web/src/components/admin/connectors/AdminSidebar.tsx delete mode 100644 web/src/components/admin/connectors/AttachCredentialPopup.tsx delete mode 100644 web/src/components/admin/connectors/BasicTable.tsx delete mode 100644 web/src/components/admin/connectors/ConnectorForm.tsx delete mode 100644 web/src/components/admin/connectors/ConnectorTitle.tsx delete mode 100644 web/src/components/admin/connectors/CredentialForm.tsx delete mode 100644 web/src/components/admin/connectors/CustomButton.tsx delete mode 100644 web/src/components/admin/connectors/Field.tsx delete mode 100644 web/src/components/admin/connectors/FileUpload.tsx delete mode 100644 web/src/components/admin/connectors/IsPublicField.tsx delete mode 100644 web/src/components/admin/connectors/Popup.tsx delete mode 100644 web/src/components/admin/connectors/buttons/AttachCredentialButtonForTable.tsx delete mode 100644 web/src/components/admin/connectors/buttons/IndexButtonForTable.tsx delete mode 100644 web/src/components/admin/connectors/types.ts delete mode 100644 web/src/components/admin/users/BulkAdd.tsx delete mode 100644 web/src/components/admin/users/CenteredPageSelector.tsx delete mode 100644 web/src/components/admin/users/InvitedUserTable.tsx delete mode 100644 web/src/components/admin/users/SignedUpUserTable.tsx delete mode 100644 web/src/components/assistants/AssistantCards.tsx delete mode 100644 web/src/components/assistants/AssistantIcon.tsx delete mode 100644 web/src/components/chat_search/Header.tsx delete mode 100644 web/src/components/chat_search/MinimalMarkdown.tsx delete mode 100644 web/src/components/chat_search/hooks.ts delete mode 100644 web/src/components/context/ChatContext.tsx delete mode 100644 web/src/components/context/EmbeddingContext.tsx delete mode 100644 web/src/components/context/FormContext.tsx delete mode 100644 web/src/components/credentials/CredentialFields.tsx delete mode 100644 web/src/components/credentials/CredentialSection.tsx delete mode 100644 web/src/components/credentials/actions/CreateCredential.tsx delete mode 100644 web/src/components/credentials/actions/EditCredential.tsx delete mode 100644 web/src/components/credentials/actions/ModifyCredential.tsx delete mode 100644 web/src/components/credentials/lib.ts delete mode 100644 web/src/components/credentials/types.ts delete mode 100644 web/src/components/documentSet/DocumentSetSelectable.tsx delete mode 100644 web/src/components/embedding/CustomModelForm.tsx delete mode 100644 web/src/components/embedding/EmbeddingSidebar.tsx delete mode 100644 web/src/components/embedding/ModelSelector.tsx delete mode 100644 web/src/components/embedding/ReindexingProgressTable.tsx delete mode 100644 web/src/components/embedding/interfaces.tsx delete mode 100644 web/src/components/header/AnnouncementBanner.tsx delete mode 100644 web/src/components/header/HeaderTitle.tsx delete mode 100644 web/src/components/header/HeaderWrapper.tsx delete mode 100644 web/src/components/header/LogoType.tsx delete mode 100644 web/src/components/health/healthcheck.tsx delete mode 100644 web/src/components/icons/icons.tsx delete mode 100644 web/src/components/icons/mixedbread.svg delete mode 100644 web/src/components/initialSetup/search/NoCompleteSourceModal.tsx delete mode 100644 web/src/components/initialSetup/search/NoSourcesModal.tsx delete mode 100644 web/src/components/initialSetup/welcome/WelcomeModal.tsx delete mode 100644 web/src/components/initialSetup/welcome/WelcomeModalWrapper.tsx delete mode 100644 web/src/components/initialSetup/welcome/constants.ts delete mode 100644 web/src/components/initialSetup/welcome/lib.ts delete mode 100644 web/src/components/llm/ApiKeyForm.tsx delete mode 100644 web/src/components/llm/ApiKeyModal.tsx delete mode 100644 web/src/components/llm/LLMList.tsx delete mode 100644 web/src/components/loading.css delete mode 100644 web/src/components/modals/DeleteEntityModal.tsx delete mode 100644 web/src/components/modals/ExceptionTraceModal.tsx delete mode 100644 web/src/components/modals/GenericConfirmModal.tsx delete mode 100644 web/src/components/modals/ModalWrapper.tsx delete mode 100644 web/src/components/popover/DefaultPopover.tsx delete mode 100644 web/src/components/popover/Popover.tsx delete mode 100644 web/src/components/popover/styles.css delete mode 100644 web/src/components/popup/Popup.tsx delete mode 100644 web/src/components/resizable/ResizableSection.tsx delete mode 100644 web/src/components/resizable/constants.ts delete mode 100644 web/src/components/search/DateRangeSelector.tsx delete mode 100644 web/src/components/search/DocumentDisplay.tsx delete mode 100644 web/src/components/search/DocumentFeedbackBlock.tsx delete mode 100644 web/src/components/search/DocumentUpdatedAtBadge.tsx delete mode 100644 web/src/components/search/PersonaSelector.tsx delete mode 100644 web/src/components/search/QAFeedback.tsx delete mode 100644 web/src/components/search/SearchAnswer.tsx delete mode 100644 web/src/components/search/SearchBar.tsx delete mode 100644 web/src/components/search/SearchHelper.tsx delete mode 100644 web/src/components/search/SearchResultsDisplay.tsx delete mode 100644 web/src/components/search/SearchSection.tsx delete mode 100644 web/src/components/search/SearchTypeSelector.tsx delete mode 100644 web/src/components/search/filtering/FilterDropdown.tsx delete mode 100644 web/src/components/search/filtering/Filters.tsx delete mode 100644 web/src/components/search/filtering/TagFilter.tsx delete mode 100644 web/src/components/search/results/AnswerSection.tsx delete mode 100644 web/src/components/search/results/Citation.tsx delete mode 100644 web/src/components/search/results/QuotesSection.tsx delete mode 100644 web/src/components/search/results/ResponseSection.tsx delete mode 100644 web/src/components/settings/SettingsProvider.tsx delete mode 100644 web/src/components/settings/lib.ts delete mode 100644 web/src/components/settings/usePaidEnterpriseFeaturesEnabled.ts delete mode 100644 web/src/components/spinner.css delete mode 100644 web/src/components/table/DragHandle.tsx delete mode 100644 web/src/components/table/DraggableRow.tsx delete mode 100644 web/src/components/table/DraggableTable.tsx delete mode 100644 web/src/components/table/DraggableTableBody.tsx delete mode 100644 web/src/components/table/StaticRow.tsx delete mode 100644 web/src/components/table/interfaces.ts delete mode 100644 web/src/components/tooltip/CustomTooltip.tsx delete mode 100644 web/src/components/tooltip/Tooltip.tsx delete mode 100644 web/src/components/user/UserProvider.tsx delete mode 100644 web/src/lib/admin/users/userMutationFetcher.ts delete mode 100644 web/src/lib/assistantIconUtils.tsx delete mode 100644 web/src/lib/assistants/checkOwnership.ts delete mode 100644 web/src/lib/assistants/fetchAssistantsSS.ts delete mode 100644 web/src/lib/assistants/fetchPersonaEditorInfoSS.ts delete mode 100644 web/src/lib/assistants/orderAssistants.ts delete mode 100644 web/src/lib/assistants/shareAssistant.ts delete mode 100644 web/src/lib/assistants/updateAssistantPreferences.ts delete mode 100644 web/src/lib/browserUtilities.tsx delete mode 100644 web/src/lib/ccPair.ts delete mode 100644 web/src/lib/chat/fetchAssistantsGalleryData.ts delete mode 100644 web/src/lib/chat/fetchChatData.ts delete mode 100644 web/src/lib/chat/fetchSomeChatData.ts delete mode 100644 web/src/lib/clickUtils.ts delete mode 100644 web/src/lib/connector.ts delete mode 100644 web/src/lib/connectors/connectors.ts delete mode 100644 web/src/lib/connectors/credentials.ts delete mode 100644 web/src/lib/constants.ts delete mode 100644 web/src/lib/contains.ts delete mode 100644 web/src/lib/credential.ts delete mode 100644 web/src/lib/dateUtils.ts delete mode 100644 web/src/lib/documentDeletion.ts delete mode 100644 web/src/lib/documentUtils.ts delete mode 100644 web/src/lib/drag/constants.ts delete mode 100644 web/src/lib/fetchUtils.ts delete mode 100644 web/src/lib/fetcher.ts delete mode 100644 web/src/lib/fileUtils.ts delete mode 100644 web/src/lib/filters.ts delete mode 100644 web/src/lib/gmail.ts delete mode 100644 web/src/lib/googleDrive.ts delete mode 100644 web/src/lib/hooks.ts delete mode 100644 web/src/lib/indexAttempt.ts delete mode 100644 web/src/lib/llm/fetchLLMs.ts delete mode 100644 web/src/lib/llm/utils.ts delete mode 100644 web/src/lib/redirectSS.ts delete mode 100644 web/src/lib/search/cancellable.ts delete mode 100644 web/src/lib/search/chatSessions.ts delete mode 100644 web/src/lib/search/interfaces.ts delete mode 100644 web/src/lib/search/keyword.ts delete mode 100644 web/src/lib/search/streamingQa.ts delete mode 100644 web/src/lib/search/streamingUtils.ts delete mode 100644 web/src/lib/search/utils.ts delete mode 100644 web/src/lib/search/utilsSS.ts delete mode 100644 web/src/lib/sources.ts delete mode 100644 web/src/lib/ss/ccPair.ts delete mode 100644 web/src/lib/tags/tagUtils.ts delete mode 100644 web/src/lib/time.ts delete mode 100644 web/src/lib/tools/edit.ts delete mode 100644 web/src/lib/tools/fetchTools.ts delete mode 100644 web/src/lib/tools/interfaces.ts delete mode 100644 web/src/lib/types.ts delete mode 100644 web/src/lib/urlBuilder.ts delete mode 100644 web/src/lib/user.ts delete mode 100644 web/src/lib/userSS.ts delete mode 100644 web/src/lib/users/UserSettings.tsx delete mode 100644 web/src/lib/users/interfaces.ts delete mode 100644 web/src/lib/utilsSS.ts delete mode 100644 web/src/lib/version.ts delete mode 100644 web/src/middleware.ts delete mode 100644 web/tailwind-themes/custom/.gitignore delete mode 100644 web/tailwind-themes/tailwind.config.js delete mode 100644 web/tailwind.config.js delete mode 100644 web/tsconfig.json diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index e57283f0377..00000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,25 +0,0 @@ -## Description -[Provide a brief description of the changes in this PR] - - -## How Has This Been Tested? -[Describe the tests you ran to verify your changes] - - -## Accepted Risk -[Any know risks or failure modes to point out to reviewers] - - -## Related Issue(s) -[If applicable, link to the issue(s) this PR addresses] - - -## Checklist: -- [ ] All of the automated tests pass -- [ ] All PR comments are addressed and marked resolved -- [ ] If there are migrations, they have been rebased to latest main -- [ ] If there are new dependencies, they are added to the requirements -- [ ] If there are new environment variables, they are added to all of the deployment methods -- [ ] If there are new APIs that don't require auth, they are added to PUBLIC_ENDPOINT_SPECS -- [ ] Docker images build and basic functionalities work -- [ ] Author has done a final read through of the PR right before merge diff --git a/.github/workflows/docker-build-push-backend-container-on-tag.yml b/.github/workflows/docker-build-push-backend-container-on-tag.yml deleted file mode 100644 index be36390c68f..00000000000 --- a/.github/workflows/docker-build-push-backend-container-on-tag.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Build and Push Backend Image on Tag - -on: - push: - tags: - - '*' - -env: - REGISTRY_IMAGE: danswer/danswer-backend - -jobs: - build-and-push: - # TODO: make this a matrix build like the web containers - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Backend Image Docker Build and Push - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ghcr.io/stackhpc/${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }} - ghcr.io/stackhpc/${{ env.REGISTRY_IMAGE }}:latest - build-args: | - DANSWER_VERSION=${{ github.ref_name }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - # To run locally: trivy image --severity HIGH,CRITICAL danswer/danswer-backend - image-ref: docker.io/${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }} - severity: 'CRITICAL,HIGH' - trivyignores: ./backend/.trivyignore diff --git a/.github/workflows/docker-build-push-model-server-container-on-tag.yml b/.github/workflows/docker-build-push-model-server-container-on-tag.yml deleted file mode 100644 index 134b77d43c2..00000000000 --- a/.github/workflows/docker-build-push-model-server-container-on-tag.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Build and Push Model Server Image on Tag - -on: - push: - tags: - - '*' - -jobs: - build-and-push: - runs-on: - group: amd64-image-builders - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Model Server Image Docker Build and Push - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/Dockerfile.model_server - platforms: linux/amd64,linux/arm64 - push: true - tags: | - danswer/danswer-model-server:${{ github.ref_name }} - danswer/danswer-model-server:latest - build-args: | - DANSWER_VERSION=${{ github.ref_name }} - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: docker.io/danswer/danswer-model-server:${{ github.ref_name }} - severity: 'CRITICAL,HIGH' diff --git a/.github/workflows/docker-build-push-web-container-on-tag.yml b/.github/workflows/docker-build-push-web-container-on-tag.yml deleted file mode 100644 index 0a97a01f7c8..00000000000 --- a/.github/workflows/docker-build-push-web-container-on-tag.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Build and Push Web Image on Tag - -on: - push: - tags: - - '*' - -env: - REGISTRY_IMAGE: danswer/danswer-web-server - -jobs: - build: - runs-on: - group: ${{ matrix.platform == 'linux/amd64' && 'amd64-image-builders' || 'arm64-image-builders' }} - strategy: - fail-fast: false - matrix: - platform: - - linux/amd64 - - linux/arm64 - - steps: - - name: Prepare - run: | - platform=${{ matrix.platform }} - echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - - - name: Checkout - uses: actions/checkout@v4 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY_IMAGE }} - tags: | - type=raw,value=${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }} - type=raw,value=${{ env.REGISTRY_IMAGE }}:latest - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Build and push by digest - id: build - uses: docker/build-push-action@v5 - with: - context: ./web - file: ./web/Dockerfile - platforms: ${{ matrix.platform }} - push: true - build-args: | - DANSWER_VERSION=${{ github.ref_name }} - # needed due to weird interactions with the builds for different platforms - no-cache: true - labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true - - - name: Export digest - run: | - mkdir -p /tmp/digests - digest="${{ steps.build.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v4 - with: - name: digests-${{ env.PLATFORM_PAIR }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - merge: - runs-on: ubuntu-latest - needs: - - build - steps: - - name: Download digests - uses: actions/download-artifact@v4 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY_IMAGE }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - - - name: Inspect image - run: | - docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} - - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: docker.io/${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }} - severity: 'CRITICAL,HIGH' diff --git a/.github/workflows/docker-tag-latest.yml b/.github/workflows/docker-tag-latest.yml deleted file mode 100644 index c0853ff3835..00000000000 --- a/.github/workflows/docker-tag-latest.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Tag Latest Version - -on: - workflow_dispatch: - inputs: - version: - description: 'The version (ie v0.0.1) to tag as latest' - required: true - -jobs: - tag: - runs-on: ubuntu-latest - steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to Docker Hub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Enable Docker CLI experimental features - run: echo "DOCKER_CLI_EXPERIMENTAL=enabled" >> $GITHUB_ENV - - - name: Pull, Tag and Push Web Server Image - run: | - docker buildx imagetools create -t danswer/danswer-web-server:latest danswer/danswer-web-server:${{ github.event.inputs.version }} - - - name: Pull, Tag and Push API Server Image - run: | - docker buildx imagetools create -t danswer/danswer-backend:latest danswer/danswer-backend:${{ github.event.inputs.version }} diff --git a/.github/workflows/helm-build-push.yml b/.github/workflows/helm-build-push.yml deleted file mode 100644 index 4cc82f2d626..00000000000 --- a/.github/workflows/helm-build-push.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Publish Danswer Helm Chart - -on: - push: - branches: - - main - -jobs: - release: - # depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions - # see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token - permissions: - contents: write - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Configure Git - run: | - git config user.name "$GITHUB_ACTOR" - git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - - - name: Install Helm - uses: azure/setup-helm@v4 - env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - - - name: Build Helm dependencies - run: | - helm repo add bitnami https://charts.bitnami.com/bitnami - helm repo add vespa https://unoplat.github.io/vespa-helm-charts - helm dependency build deployment/helm - - - name: Run chart-releaser - uses: helm/chart-releaser-action@v1.6.0 - with: - charts_dir: deployment - pages_branch: helm-publish - env: - CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - CR_RELEASE_NAME_TEMPLATE: "danswer-helm-{{ .Version }}" diff --git a/.github/workflows/pr-python-checks.yml b/.github/workflows/pr-python-checks.yml deleted file mode 100644 index 9cc624fa073..00000000000 --- a/.github/workflows/pr-python-checks.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Python Checks - -on: - merge_group: - pull_request: - branches: [ main ] - -jobs: - mypy-check: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: | - backend/requirements/default.txt - backend/requirements/dev.txt - backend/requirements/model_server.txt - - run: | - python -m pip install --upgrade pip - pip install -r backend/requirements/default.txt - pip install -r backend/requirements/dev.txt - pip install -r backend/requirements/model_server.txt - - - name: Run MyPy - run: | - cd backend - mypy . - - - name: Run ruff - run: | - cd backend - ruff . - - - name: Check import order with reorder-python-imports - run: | - cd backend - find ./danswer -name "*.py" | xargs reorder-python-imports --py311-plus - - - name: Check code formatting with Black - run: | - cd backend - black --check . diff --git a/.github/workflows/pr-python-tests.yml b/.github/workflows/pr-python-tests.yml deleted file mode 100644 index 7686de019a5..00000000000 --- a/.github/workflows/pr-python-tests.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Python Unit Tests - -on: - merge_group: - pull_request: - branches: [ main ] - -jobs: - backend-check: - runs-on: ubuntu-latest - - env: - PYTHONPATH: ./backend - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - cache: 'pip' - cache-dependency-path: | - backend/requirements/default.txt - backend/requirements/dev.txt - - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r backend/requirements/default.txt - pip install -r backend/requirements/dev.txt - - - name: Run Tests - shell: script -q -e -c "bash --noprofile --norc -eo pipefail {0}" - run: py.test -o junit_family=xunit2 -xv --ff backend/tests/unit diff --git a/.github/workflows/pr-quality-checks.yml b/.github/workflows/pr-quality-checks.yml deleted file mode 100644 index 8a42541ea5d..00000000000 --- a/.github/workflows/pr-quality-checks.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Quality Checks PR -concurrency: - group: Quality-Checks-PR-${{ github.head_ref }} - cancel-in-progress: true - -on: - merge_group: - pull_request: null - -jobs: - quality-checks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: pre-commit/action@v3.0.0 - with: - extra_args: ${{ github.event_name == 'pull_request' && format('--from-ref {0} --to-ref {1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || '' }} diff --git a/.github/workflows/run-it.yml b/.github/workflows/run-it.yml deleted file mode 100644 index 7c0c1814c3b..00000000000 --- a/.github/workflows/run-it.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: Run Integration Tests -concurrency: - group: Run-Integration-Tests-${{ github.head_ref }} - cancel-in-progress: true - -on: - merge_group: - pull_request: - branches: [ main ] - -env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - -jobs: - integration-tests: - runs-on: - group: 'arm64-image-builders' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_TOKEN }} - - - name: Build Web Docker image - uses: docker/build-push-action@v5 - with: - context: ./web - file: ./web/Dockerfile - platforms: linux/arm64 - pull: true - push: true - load: true - tags: danswer/danswer-web-server:it - cache-from: type=registry,ref=danswer/danswer-web-server:it - cache-to: | - type=registry,ref=danswer/danswer-web-server:it,mode=max - type=inline - - - name: Build Backend Docker image - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/Dockerfile - platforms: linux/arm64 - pull: true - push: true - load: true - tags: danswer/danswer-backend:it - cache-from: type=registry,ref=danswer/danswer-backend:it - cache-to: | - type=registry,ref=danswer/danswer-backend:it,mode=max - type=inline - - - name: Build Model Server Docker image - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/Dockerfile.model_server - platforms: linux/arm64 - pull: true - push: true - load: true - tags: danswer/danswer-model-server:it - cache-from: type=registry,ref=danswer/danswer-model-server:it - cache-to: | - type=registry,ref=danswer/danswer-model-server:it,mode=max - type=inline - - - name: Build integration test Docker image - uses: docker/build-push-action@v5 - with: - context: ./backend - file: ./backend/tests/integration/Dockerfile - platforms: linux/arm64 - pull: true - push: true - load: true - tags: danswer/integration-test-runner:it - cache-from: type=registry,ref=danswer/integration-test-runner:it - cache-to: | - type=registry,ref=danswer/integration-test-runner:it,mode=max - type=inline - - - name: Start Docker containers - run: | - cd deployment/docker_compose - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \ - IMAGE_TAG=it \ - docker compose -f docker-compose.dev.yml -p danswer-stack up -d --build - id: start_docker - - - name: Wait for service to be ready - run: | - echo "Starting wait-for-service script..." - - start_time=$(date +%s) - timeout=300 # 5 minutes in seconds - - while true; do - current_time=$(date +%s) - elapsed_time=$((current_time - start_time)) - - if [ $elapsed_time -ge $timeout ]; then - echo "Timeout reached. Service did not become ready in 5 minutes." - exit 1 - fi - - # Use curl with error handling to ignore specific exit code 56 - response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health || echo "curl_error") - - if [ "$response" = "200" ]; then - echo "Service is ready!" - break - elif [ "$response" = "curl_error" ]; then - echo "Curl encountered an error, possibly exit code 56. Continuing to retry..." - else - echo "Service not ready yet (HTTP status $response). Retrying in 5 seconds..." - fi - - sleep 5 - done - echo "Finished waiting for service." - - - name: Run integration tests - run: | - echo "Running integration tests..." - docker run --rm --network danswer-stack_default \ - -e POSTGRES_HOST=relational_db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=password \ - -e POSTGRES_DB=postgres \ - -e VESPA_HOST=index \ - -e API_SERVER_HOST=api_server \ - -e OPENAI_API_KEY=${OPENAI_API_KEY} \ - danswer/integration-test-runner:it - continue-on-error: true - id: run_tests - - - name: Check test results - run: | - if [ ${{ steps.run_tests.outcome }} == 'failure' ]; then - echo "Integration tests failed. Exiting with error." - exit 1 - else - echo "All integration tests passed successfully." - fi - - - name: Save Docker logs - if: success() || failure() - run: | - cd deployment/docker_compose - docker compose -f docker-compose.dev.yml -p danswer-stack logs > docker-compose.log - mv docker-compose.log ${{ github.workspace }}/docker-compose.log - - - name: Upload logs - if: success() || failure() - uses: actions/upload-artifact@v3 - with: - name: docker-logs - path: ${{ github.workspace }}/docker-compose.log - - - name: Stop Docker containers - run: | - cd deployment/docker_compose - docker compose -f docker-compose.dev.yml -p danswer-stack down -v diff --git a/.gitignore b/.gitignore deleted file mode 100644 index d9d7727b2f0..00000000000 --- a/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -.env -.DS_store -.venv -.mypy_cache -.idea -/deployment/data/nginx/app.conf -.vscode/launch.json -*.sw? -/backend/tests/regression/answer_quality/search_test_config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 4b2b147a9a7..00000000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,65 +0,0 @@ -repos: - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - language_version: python3.11 - - - repo: https://github.com/asottile/reorder_python_imports - rev: v3.9.0 - hooks: - - id: reorder-python-imports - args: ['--py311-plus', '--application-directories=backend/'] - # need to ignore alembic files, since reorder-python-imports gets confused - # and thinks that alembic is a local package since there is a folder - # in the backend directory called `alembic` - exclude: ^backend/alembic/ - - # These settings will remove unused imports with side effects - # Note: The repo currently does not and should not have imports with side effects - - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 - hooks: - - id: autoflake - args: [ '--remove-all-unused-imports', '--remove-unused-variables', '--in-place' , '--recursive'] - - - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.0.286 - hooks: - - id: ruff - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 - hooks: - - id: prettier - types_or: [html, css, javascript, ts, tsx] - additional_dependencies: - - prettier - - # We would like to have a mypy pre-commit hook, but due to the fact that - # pre-commit runs in it's own isolated environment, we would need to install - # and keep in sync all dependencies so mypy has access to the appropriate type - # stubs. This does not seem worth it at the moment, so for now we will stick to - # having mypy run via Github Actions / manually by contributors - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v1.1.1 - # hooks: - # - id: mypy - # exclude: ^tests/ - # # below are needed for type stubs since pre-commit runs in it's own - # # isolated environment. Unfortunately, this needs to be kept in sync - # # with requirements/dev.txt + requirements/default.txt - # additional_dependencies: [ - # alembic==1.10.4, - # types-beautifulsoup4==4.12.0.3, - # types-html5lib==1.1.11.13, - # types-oauthlib==3.2.0.9, - # types-psycopg2==2.9.21.10, - # types-python-dateutil==2.8.19.13, - # types-regex==2023.3.23.1, - # types-requests==2.28.11.17, - # types-retry==0.9.9.3, - # types-urllib3==1.26.25.11 - # ] - # # TODO: add back once errors are addressed - # # args: [--strict] diff --git a/.vscode/env_template.txt b/.vscode/env_template.txt deleted file mode 100644 index b3fae8cee73..00000000000 --- a/.vscode/env_template.txt +++ /dev/null @@ -1,52 +0,0 @@ -# Copy this file to .env at the base of the repo and fill in the values -# This will help with development iteration speed and reduce repeat tasks for dev -# Also check out danswer/backend/scripts/restart_containers.sh for a script to restart the containers which Danswer relies on outside of VSCode/Cursor processes - -# For local dev, often user Authentication is not needed -AUTH_TYPE=disabled - - -# Always keep these on for Dev -# Logs all model prompts to stdout -LOG_DANSWER_MODEL_INTERACTIONS=True -# More verbose logging -LOG_LEVEL=debug - - -# This passes top N results to LLM an additional time for reranking prior to answer generation -# This step is quite heavy on token usage so we disable it for dev generally -DISABLE_LLM_DOC_RELEVANCE=True - - -# Useful if you want to toggle auth on/off (google_oauth/OIDC specifically) -OAUTH_CLIENT_ID= -OAUTH_CLIENT_SECRET= -# Generally not useful for dev, we don't generally want to set up an SMTP server for dev -REQUIRE_EMAIL_VERIFICATION=False - - -# Set these so if you wipe the DB, you don't end up having to go through the UI every time -GEN_AI_API_KEY= -# If answer quality isn't important for dev, use 3.5 turbo due to it being cheaper -GEN_AI_MODEL_VERSION=gpt-3.5-turbo -FAST_GEN_AI_MODEL_VERSION=gpt-3.5-turbo - -# For Danswer Slack Bot, overrides the UI values so no need to set this up via UI every time -# Only needed if using DanswerBot -#DANSWER_BOT_SLACK_APP_TOKEN= -#DANSWER_BOT_SLACK_BOT_TOKEN= - - -# Python stuff -PYTHONPATH=./backend -PYTHONUNBUFFERED=1 - - -# Internet Search -BING_API_KEY= - - -# Enable the full set of Danswer Enterprise Edition features -# NOTE: DO NOT ENABLE THIS UNLESS YOU HAVE A PAID ENTERPRISE LICENSE (or if you are using this for local testing/development) -ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=False - diff --git a/.vscode/launch.template.jsonc b/.vscode/launch.template.jsonc deleted file mode 100644 index 9aaadb32acf..00000000000 --- a/.vscode/launch.template.jsonc +++ /dev/null @@ -1,145 +0,0 @@ -/* - - Copy this file into '.vscode/launch.json' or merge its - contents into your existing configurations. - -*/ - -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Web Server", - "type": "node", - "request": "launch", - "cwd": "${workspaceRoot}/web", - "runtimeExecutable": "npm", - "envFile": "${workspaceFolder}/.env", - "runtimeArgs": [ - "run", "dev" - ], - "console": "integratedTerminal" - }, - { - "name": "Model Server", - "type": "python", - "request": "launch", - "module": "uvicorn", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1" - }, - "args": [ - "model_server.main:app", - "--reload", - "--port", - "9000" - ], - "consoleTitle": "Model Server" - }, - { - "name": "API Server", - "type": "python", - "request": "launch", - "module": "uvicorn", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "LOG_DANSWER_MODEL_INTERACTIONS": "True", - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1" - }, - "args": [ - "danswer.main:app", - "--reload", - "--port", - "8080" - ], - "consoleTitle": "API Server" - }, - { - "name": "Indexing", - "type": "python", - "request": "launch", - "program": "danswer/background/update.py", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "ENABLE_MULTIPASS_INDEXING": "false", - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": "." - }, - "consoleTitle": "Indexing" - }, - // Celery and all async jobs, usually would include indexing as well but this is handled separately above for dev - { - "name": "Background Jobs", - "type": "python", - "request": "launch", - "program": "scripts/dev_run_background_jobs.py", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "LOG_DANSWER_MODEL_INTERACTIONS": "True", - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": "." - }, - "args": [ - "--no-indexing" - ], - "consoleTitle": "Background Jobs" - }, - // For the listner to access the Slack API, - // DANSWER_BOT_SLACK_APP_TOKEN & DANSWER_BOT_SLACK_BOT_TOKEN need to be set in .env file located in the root of the project - { - "name": "Slack Bot", - "type": "python", - "request": "launch", - "program": "danswer/danswerbot/slack/listener.py", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": "." - } - }, - { - "name": "Pytest", - "type": "python", - "request": "launch", - "module": "pytest", - "cwd": "${workspaceFolder}/backend", - "envFile": "${workspaceFolder}/.env", - "env": { - "LOG_LEVEL": "DEBUG", - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": "." - }, - "args": [ - "-v" - // Specify a sepcific module/test to run or provide nothing to run all tests - //"tests/unit/danswer/llm/answering/test_prune_and_merge.py" - ] - } - ], - "compounds": [ - { - "name": "Run Danswer", - "configurations": [ - "Web Server", - "Model Server", - "API Server", - "Indexing", - "Background Jobs", - ] - } - ] -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 116e78b6f19..00000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,191 +0,0 @@ - - -# Contributing to Danswer -Hey there! We are so excited that you're interested in Danswer. - -As an open source project in a rapidly changing space, we welcome all contributions. - - -## 💃 Guidelines -### Contribution Opportunities -The [GitHub Issues](https://github.com/danswer-ai/danswer/issues) page is a great place to start for contribution ideas. - -Issues that have been explicitly approved by the maintainers (aligned with the direction of the project) -will be marked with the `approved by maintainers` label. -Issues marked `good first issue` are an especially great place to start. - -**Connectors** to other tools are another great place to contribute. For details on how, refer to this -[README.md](https://github.com/danswer-ai/danswer/blob/main/backend/danswer/connectors/README.md). - -If you have a new/different contribution in mind, we'd love to hear about it! -Your input is vital to making sure that Danswer moves in the right direction. -Before starting on implementation, please raise a GitHub issue. - -And always feel free to message us (Chris Weaver / Yuhong Sun) on -[Slack](https://join.slack.com/t/danswer/shared_invite/zt-2afut44lv-Rw3kSWu6_OmdAXRpCv80DQ) / -[Discord](https://discord.gg/TDJ59cGV2X) directly about anything at all. - - -### Contributing Code -To contribute to this project, please follow the -["fork and pull request"](https://docs.github.com/en/get-started/quickstart/contributing-to-projects) workflow. -When opening a pull request, mention related issues and feel free to tag relevant maintainers. - -Before creating a pull request please make sure that the new changes conform to the formatting and linting requirements. -See the [Formatting and Linting](#-formatting-and-linting) section for how to run these checks locally. - - -### Getting Help 🙋 -Our goal is to make contributing as easy as possible. If you run into any issues please don't hesitate to reach out. -That way we can help future contributors and users can avoid the same issue. - -We also have support channels and generally interesting discussions on our -[Slack](https://join.slack.com/t/danswer/shared_invite/zt-2afut44lv-Rw3kSWu6_OmdAXRpCv80DQ) -and -[Discord](https://discord.gg/TDJ59cGV2X). - -We would love to see you there! - - -## Get Started 🚀 -Danswer being a fully functional app, relies on some external pieces of software, specifically: -- [Postgres](https://www.postgresql.org/) (Relational DB) -- [Vespa](https://vespa.ai/) (Vector DB/Search Engine) - -This guide provides instructions to set up the Danswer specific services outside of Docker because it's easier for -development purposes but also feel free to just use the containers and update with local changes by providing the -`--build` flag. - - -### Local Set Up -It is recommended to use Python version 3.11 - -If using a lower version, modifications will have to be made to the code. -If using a higher version, the version of Tensorflow we use may not be available for your platform. - - -#### Installing Requirements -Currently, we use pip and recommend creating a virtual environment. - -For convenience here's a command for it: -```bash -python -m venv .venv -source .venv/bin/activate -``` - ---> Note that this virtual environment MUST NOT be set up WITHIN the danswer -directory - -_For Windows, activate the virtual environment using Command Prompt:_ -```bash -.venv\Scripts\activate -``` -If using PowerShell, the command slightly differs: -```powershell -.venv\Scripts\Activate.ps1 -``` - -Install the required python dependencies: -```bash -pip install -r danswer/backend/requirements/default.txt -pip install -r danswer/backend/requirements/dev.txt -pip install -r danswer/backend/requirements/model_server.txt -``` - -Install [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) for the frontend. -Once the above is done, navigate to `danswer/web` run: -```bash -npm i -``` - -Install Playwright (required by the Web Connector) - -> Note: If you have just done the pip install, open a new terminal and source the python virtual-env again. -This will update the path to include playwright - -Then install Playwright by running: -```bash -playwright install -``` - - -#### Dependent Docker Containers -First navigate to `danswer/deployment/docker_compose`, then start up Vespa and Postgres with: -```bash -docker compose -f docker-compose.dev.yml -p danswer-stack up -d index relational_db -``` -(index refers to Vespa and relational_db refers to Postgres) - -#### Running Danswer -To start the frontend, navigate to `danswer/web` and run: -```bash -npm run dev -``` - -Next, start the model server which runs the local NLP models. -Navigate to `danswer/backend` and run: -```bash -uvicorn model_server.main:app --reload --port 9000 -``` -_For Windows (for compatibility with both PowerShell and Command Prompt):_ -```bash -powershell -Command " - uvicorn model_server.main:app --reload --port 9000 -" -``` - -The first time running Danswer, you will need to run the DB migrations for Postgres. -After the first time, this is no longer required unless the DB models change. - -Navigate to `danswer/backend` and with the venv active, run: -```bash -alembic upgrade head -``` - -Next, start the task queue which orchestrates the background jobs. -Jobs that take more time are run async from the API server. - -Still in `danswer/backend`, run: -```bash -python ./scripts/dev_run_background_jobs.py -``` - -To run the backend API server, navigate back to `danswer/backend` and run: -```bash -AUTH_TYPE=disabled uvicorn danswer.main:app --reload --port 8080 -``` -_For Windows (for compatibility with both PowerShell and Command Prompt):_ -```bash -powershell -Command " - $env:AUTH_TYPE='disabled' - uvicorn danswer.main:app --reload --port 8080 -" -``` - -Note: if you need finer logging, add the additional environment variable `LOG_LEVEL=DEBUG` to the relevant services. - -### Formatting and Linting -#### Backend -For the backend, you'll need to setup pre-commit hooks (black / reorder-python-imports). -First, install pre-commit (if you don't have it already) following the instructions -[here](https://pre-commit.com/#installation). -Then, from the `danswer/backend` directory, run: -```bash -pre-commit install -``` - -Additionally, we use `mypy` for static type checking. -Danswer is fully type-annotated, and we would like to keep it that way! -To run the mypy checks manually, run `python -m mypy .` from the `danswer/backend` directory. - - -#### Web -We use `prettier` for formatting. The desired version (2.8.8) will be installed via a `npm i` from the `danswer/web` directory. -To run the formatter, use `npx prettier --write .` from the `danswer/web` directory. -Please double check that prettier passes before creating a pull request. - - -### Release Process -Danswer follows the semver versioning standard. -A set of Docker containers will be pushed automatically to DockerHub with every tag. -You can see the containers [here](https://hub.docker.com/search?q=danswer%2F). diff --git a/README.md b/README.md index aff3cd57d5a..0973d06841e 100644 --- a/README.md +++ b/README.md @@ -1,129 +1 @@ - - -

- -

- -

-

Open Source Gen-AI Chat + Unified Search.

- -

- - Documentation - - - Slack - - - Discord - - - License - -

- -[Danswer](https://www.danswer.ai/) is the AI Assistant connected to your company's docs, apps, and people. -Danswer provides a Chat interface and plugs into any LLM of your choice. Danswer can be deployed anywhere and for any -scale - on a laptop, on-premise, or to cloud. Since you own the deployment, your user data and chats are fully in your -own control. Danswer is MIT licensed and designed to be modular and easily extensible. The system also comes fully ready -for production usage with user authentication, role management (admin/basic users), chat persistence, and a UI for -configuring Personas (AI Assistants) and their Prompts. - -Danswer also serves as a Unified Search across all common workplace tools such as Slack, Google Drive, Confluence, etc. -By combining LLMs and team specific knowledge, Danswer becomes a subject matter expert for the team. Imagine ChatGPT if -it had access to your team's unique knowledge! It enables questions such as "A customer wants feature X, is this already -supported?" or "Where's the pull request for feature Y?" - -

Usage

- -Danswer Web App: - -https://github.com/danswer-ai/danswer/assets/32520769/563be14c-9304-47b5-bf0a-9049c2b6f410 - - -Or, plug Danswer into your existing Slack workflows (more integrations to come 😁): - -https://github.com/danswer-ai/danswer/assets/25087905/3e19739b-d178-4371-9a38-011430bdec1b - - -For more details on the Admin UI to manage connectors and users, check out our -Full Video Demo! - -## Deployment - -Danswer can easily be run locally (even on a laptop) or deployed on a virtual machine with a single -`docker compose` command. Checkout our [docs](https://docs.danswer.dev/quickstart) to learn more. - -We also have built-in support for deployment on Kubernetes. Files for that can be found [here](https://github.com/danswer-ai/danswer/tree/main/deployment/kubernetes). - - -## 💃 Main Features -* Chat UI with the ability to select documents to chat with. -* Create custom AI Assistants with different prompts and backing knowledge sets. -* Connect Danswer with LLM of your choice (self-host for a fully airgapped solution). -* Document Search + AI Answers for natural language queries. -* Connectors to all common workplace tools like Google Drive, Confluence, Slack, etc. -* Slack integration to get answers and search results directly in Slack. - - -## 🚧 Roadmap -* Chat/Prompt sharing with specific teammates and user groups. -* Multi-Model model support, chat with images, video etc. -* Choosing between LLMs and parameters during chat session. -* Tool calling and agent configurations options. -* Organizational understanding and ability to locate and suggest experts from your team. - - -## Other Noteable Benefits of Danswer -* User Authentication with document level access management. -* Best in class Hybrid Search across all sources (BM-25 + prefix aware embedding models). -* Admin Dashboard to configure connectors, document-sets, access, etc. -* Custom deep learning models + learn from user feedback. -* Easy deployment and ability to host Danswer anywhere of your choosing. - - -## 🔌 Connectors -Efficiently pulls the latest changes from: - * Slack - * GitHub - * Google Drive - * Confluence - * Jira - * Zendesk - * Gmail - * Notion - * Gong - * Slab - * Linear - * Productboard - * Guru - * Bookstack - * Document360 - * Sharepoint - * Hubspot - * Local Files - * Websites - * And more ... - -## 📚 Editions - -There are two editions of Danswer: - - * Danswer Community Edition (CE) is available freely under the MIT Expat license. This version has ALL the core features discussed above. This is the version of Danswer you will get if you follow the Deployment guide above. - * Danswer Enterprise Edition (EE) includes extra features that are primarily useful for larger organizations. Specifically, this includes: - * Single Sign-On (SSO), with support for both SAML and OIDC - * Role-based access control - * Document permission inheritance from connected sources - * Usage analytics and query history accessible to admins - * Whitelabeling - * API key authentication - * Encryption of secrets - * Any many more! Checkout [our website](https://www.danswer.ai/) for the latest. - -To try the Danswer Enterprise Edition: - - 1. Checkout our [Cloud product](https://app.danswer.ai/signup). - 2. For self-hosting, contact us at [founders@danswer.ai](mailto:founders@danswer.ai) or book a call with us on our [Cal](https://cal.com/team/danswer/founders). - -## 💡 Contributing -Looking to contribute? Please check out the [Contribution Guide](CONTRIBUTING.md) for more details. +This Danswer branch is reserved for publishing Helm charts. For all other purposes, refer to the repositories [main branch](https://github.com/sd109/danswer/). diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 248a36792bd..00000000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,17 +0,0 @@ -**/__pycache__ -venv/ -env/ -*.egg-info -.cache -.git/ -.svn/ -.vscode/ -.idea/ -*.log -log/ -.env -secrets.yaml -build/ -dist/ -.coverage -htmlcov/ diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index b1c4f4db71d..00000000000 --- a/backend/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -__pycache__/ -.mypy_cache -.idea/ -site_crawls/ -.ipynb_checkpoints/ -api_keys.py -*ipynb -.env* -vespa-app.zip -dynamic_config_storage/ -celerybeat-schedule* diff --git a/backend/.trivyignore b/backend/.trivyignore deleted file mode 100644 index e8351b40741..00000000000 --- a/backend/.trivyignore +++ /dev/null @@ -1,46 +0,0 @@ -# https://github.com/madler/zlib/issues/868 -# Pulled in with base Debian image, it's part of the contrib folder but unused -# zlib1g is fine -# Will be gone with Debian image upgrade -# No impact in our settings -CVE-2023-45853 - -# krb5 related, worst case is denial of service by resource exhaustion -# Accept the risk -CVE-2024-26458 -CVE-2024-26461 -CVE-2024-26462 -CVE-2024-26458 -CVE-2024-26461 -CVE-2024-26462 -CVE-2024-26458 -CVE-2024-26461 -CVE-2024-26462 -CVE-2024-26458 -CVE-2024-26461 -CVE-2024-26462 - -# Specific to Firefox which we do not use -# No impact in our settings -CVE-2024-0743 - -# bind9 related, worst case is denial of service by CPU resource exhaustion -# Accept the risk -CVE-2023-50387 -CVE-2023-50868 -CVE-2023-50387 -CVE-2023-50868 - -# libexpat1, XML parsing resource exhaustion -# We don't parse any user provided XMLs -# No impact in our settings -CVE-2023-52425 -CVE-2024-28757 - -# sqlite, only used by NLTK library to grab word lemmatizer and stopwords -# No impact in our settings -CVE-2023-7104 - -# libharfbuzz0b, O(n^2) growth, worst case is denial of service -# Accept the risk -CVE-2023-25193 diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index d8c388801d7..00000000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,108 +0,0 @@ -FROM python:3.11.7-slim-bookworm - -LABEL com.danswer.maintainer="founders@danswer.ai" -LABEL com.danswer.description="This image is the web/frontend container of Danswer which \ - contains code for both the Community and Enterprise editions of Danswer. If you do not \ - have a contract or agreement with DanswerAI, you are not permitted to use the Enterprise \ - Edition features outside of personal development or testing purposes. Please reach out to \ - founders@danswer.ai for more information. Please visit https://github.com/danswer-ai/danswer" - -# Default DANSWER_VERSION, typically overriden during builds by GitHub Actions. -ARG DANSWER_VERSION=0.3-dev -ENV DANSWER_VERSION=${DANSWER_VERSION} - -RUN echo "DANSWER_VERSION: ${DANSWER_VERSION}" -# Install system dependencies -# cmake needed for psycopg (postgres) -# libpq-dev needed for psycopg (postgres) -# curl included just for users' convenience -# zip for Vespa step futher down -# ca-certificates for HTTPS -RUN apt-get update && \ - apt-get install -y \ - cmake \ - curl \ - zip \ - ca-certificates \ - libgnutls30=3.7.9-2+deb12u3 \ - libblkid1=2.38.1-5+deb12u1 \ - libmount1=2.38.1-5+deb12u1 \ - libsmartcols1=2.38.1-5+deb12u1 \ - libuuid1=2.38.1-5+deb12u1 \ - libxmlsec1-dev \ - pkg-config \ - gcc && \ - rm -rf /var/lib/apt/lists/* && \ - apt-get clean - -# Install Python dependencies -# Remove py which is pulled in by retry, py is not needed and is a CVE -COPY ./requirements/default.txt /tmp/requirements.txt -COPY ./requirements/ee.txt /tmp/ee-requirements.txt -RUN pip install --no-cache-dir --upgrade \ - -r /tmp/requirements.txt \ - -r /tmp/ee-requirements.txt && \ - pip uninstall -y py && \ - playwright install chromium && \ - playwright install-deps chromium && \ - ln -s /usr/local/bin/supervisord /usr/bin/supervisord - -# Cleanup for CVEs and size reduction -# https://github.com/tornadoweb/tornado/issues/3107 -# xserver-common and xvfb included by playwright installation but not needed after -# perl-base is part of the base Python Debian image but not needed for Danswer functionality -# perl-base could only be removed with --allow-remove-essential -RUN apt-get update && \ - apt-get remove -y --allow-remove-essential \ - perl-base \ - xserver-common \ - xvfb \ - cmake \ - libldap-2.5-0 \ - libxmlsec1-dev \ - pkg-config \ - gcc && \ - apt-get install -y libxmlsec1-openssl && \ - apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* && \ - rm -f /usr/local/lib/python3.11/site-packages/tornado/test/test.key - -# Pre-downloading models for setups with limited egress -RUN python -c "from tokenizers import Tokenizer; \ -Tokenizer.from_pretrained('nomic-ai/nomic-embed-text-v1')" - - -# Pre-downloading NLTK for setups with limited egress -RUN python -c "import nltk; \ - nltk.download('stopwords', quiet=True); \ - nltk.download('wordnet', quiet=True); \ - nltk.download('punkt', quiet=True);" - -# Set up application files -WORKDIR /app - -# Enterprise Version Files -COPY ./ee /app/ee -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf - -# Set up application files -COPY ./danswer /app/danswer -COPY ./shared_configs /app/shared_configs -COPY ./alembic /app/alembic -COPY ./alembic.ini /app/alembic.ini -COPY supervisord.conf /usr/etc/supervisord.conf - -# Escape hatch -COPY ./scripts/force_delete_connector_by_id.py /app/scripts/force_delete_connector_by_id.py - -# Put logo in assets -COPY ./assets /app/assets - -# Include the license in the modified image -COPY . ../LICENSE - -ENV PYTHONPATH /app - -# Default command which does nothing -# This container is used by api server and background which specify their own CMD -CMD ["tail", "-f", "/dev/null"] diff --git a/backend/Dockerfile.model_server b/backend/Dockerfile.model_server deleted file mode 100644 index f2fb1ca44d0..00000000000 --- a/backend/Dockerfile.model_server +++ /dev/null @@ -1,54 +0,0 @@ -FROM python:3.11.7-slim-bookworm - -LABEL com.danswer.maintainer="founders@danswer.ai" -LABEL com.danswer.description="This image is for the Danswer model server which runs all of the \ -AI models for Danswer. This container and all the code is MIT Licensed and free for all to use. \ -You can find it at https://hub.docker.com/r/danswer/danswer-model-server. For more details, \ -visit https://github.com/danswer-ai/danswer." - -# Default DANSWER_VERSION, typically overriden during builds by GitHub Actions. -ARG DANSWER_VERSION=0.3-dev -ENV DANSWER_VERSION=${DANSWER_VERSION} -RUN echo "DANSWER_VERSION: ${DANSWER_VERSION}" - -COPY ./requirements/model_server.txt /tmp/requirements.txt -RUN pip install --no-cache-dir --upgrade -r /tmp/requirements.txt - -RUN apt-get remove -y --allow-remove-essential perl-base && \ - apt-get autoremove -y - -# Pre-downloading models for setups with limited egress -# Download tokenizers, distilbert for the Danswer model -# Download model weights -# Run Nomic to pull in the custom architecture and have it cached locally -RUN python -c "from transformers import AutoTokenizer; \ -AutoTokenizer.from_pretrained('distilbert-base-uncased'); \ -AutoTokenizer.from_pretrained('mixedbread-ai/mxbai-rerank-xsmall-v1'); \ -from huggingface_hub import snapshot_download; \ -snapshot_download(repo_id='danswer/hybrid-intent-token-classifier', revision='v1.0.3'); \ -snapshot_download('nomic-ai/nomic-embed-text-v1'); \ -snapshot_download('mixedbread-ai/mxbai-rerank-xsmall-v1'); \ -from sentence_transformers import SentenceTransformer; \ -SentenceTransformer(model_name_or_path='nomic-ai/nomic-embed-text-v1', trust_remote_code=True);" - -# In case the user has volumes mounted to /root/.cache/huggingface that they've downloaded while -# running Danswer, don't overwrite it with the built in cache folder -RUN mv /root/.cache/huggingface /root/.cache/temp_huggingface - -WORKDIR /app - -# Utils used by model server -COPY ./danswer/utils/logger.py /app/danswer/utils/logger.py - -# Place to fetch version information -COPY ./danswer/__init__.py /app/danswer/__init__.py - -# Shared between Danswer Backend and Model Server -COPY ./shared_configs /app/shared_configs - -# Model Server main code -COPY ./model_server /app/model_server - -ENV PYTHONPATH /app - -CMD ["uvicorn", "model_server.main:app", "--host", "0.0.0.0", "--port", "9000"] diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 10ae5cfdd27..00000000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,108 +0,0 @@ -# A generic, single database configuration. - -[alembic] -# path to migration scripts -script_location = alembic - -# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s -# Uncomment the line below if you want the files to be prepended with date and time -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s - -# sys.path path, will be prepended to sys.path if present. -# defaults to the current working directory. -prepend_sys_path = . - -# timezone to use when rendering the date within the migration file -# as well as the filename. -# If specified, requires the python-dateutil library that can be -# installed by adding `alembic[tz]` to the pip requirements -# string value is passed to dateutil.tz.gettz() -# leave blank for localtime -# timezone = - -# max length of characters to apply to the -# "slug" field -# truncate_slug_length = 40 - -# set to 'true' to run the environment during -# the 'revision' command, regardless of autogenerate -# revision_environment = false - -# set to 'true' to allow .pyc and .pyo files without -# a source .py file to be detected as revisions in the -# versions/ directory -# sourceless = false - -# version location specification; This defaults -# to alembic/versions. When using multiple version -# directories, initial revisions must be specified with --version-path. -# The path separator used here should be the separator specified by "version_path_separator" below. -# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions - -# version path separator; As mentioned above, this is the character used to split -# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. -# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. -# Valid values for version_path_separator are: -# -# version_path_separator = : -# version_path_separator = ; -# version_path_separator = space -version_path_separator = os # Use os.pathsep. Default configuration used for new projects. - -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - -# the output encoding used when revision files -# are written from script.py.mako -# output_encoding = utf-8 - -# sqlalchemy.url = driver://user:pass@localhost/dbname - - -[post_write_hooks] -# post_write_hooks defines scripts or Python functions that are run -# on newly generated revision scripts. See the documentation for further -# detail and examples - -# format using "black" - use the console_scripts runner, against the "black" entrypoint -hooks = black -black.type = console_scripts -black.entrypoint = black -black.options = -l 79 REVISION_SCRIPT_FILENAME - -# Logging configuration -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console -qualname = - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s -datefmt = %H:%M:%S diff --git a/backend/alembic/README.md b/backend/alembic/README.md deleted file mode 100644 index 3337cb4f236..00000000000 --- a/backend/alembic/README.md +++ /dev/null @@ -1,19 +0,0 @@ - - -# Alembic DB Migrations -These files are for creating/updating the tables in the Relational DB (Postgres). -Danswer migrations use a generic single-database configuration with an async dbapi. - -## To generate new migrations: -run from danswer/backend: -`alembic revision --autogenerate -m ` - -More info can be found here: https://alembic.sqlalchemy.org/en/latest/autogenerate.html - -## Running migrations -To run all un-applied migrations: -`alembic upgrade head` - -To undo migrations: -`alembic downgrade -X` -where X is the number of migrations you want to undo from the current state diff --git a/backend/alembic/env.py b/backend/alembic/env.py deleted file mode 100644 index 8c028202bfc..00000000000 --- a/backend/alembic/env.py +++ /dev/null @@ -1,109 +0,0 @@ -import asyncio -from logging.config import fileConfig - -from alembic import context -from danswer.db.engine import build_connection_string -from danswer.db.models import Base -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import create_async_engine -from celery.backends.database.session import ResultModelBase # type: ignore -from sqlalchemy.schema import SchemaItem - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Interpret the config file for Python logging. -# This line sets up loggers basically. -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -# add your model's MetaData object here -# for 'autogenerate' support -# from myapp import mymodel -# target_metadata = mymodel.Base.metadata -target_metadata = [Base.metadata, ResultModelBase.metadata] - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - -EXCLUDE_TABLES = {"kombu_queue", "kombu_message"} - - -def include_object( - object: SchemaItem, - name: str, - type_: str, - reflected: bool, - compare_to: SchemaItem | None, -) -> bool: - if type_ == "table" and name in EXCLUDE_TABLES: - return False - return True - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - url = build_connection_string() - context.configure( - url=url, - target_metadata=target_metadata, # type: ignore - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection: Connection) -> None: - context.configure( - connection=connection, - target_metadata=target_metadata, # type: ignore - include_object=include_object, - ) # type: ignore - - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - """In this scenario we need to create an Engine - and associate a connection with the context. - - """ - - connectable = create_async_engine( - build_connection_string(), - poolclass=pool.NullPool, - ) - - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - - await connectable.dispose() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako deleted file mode 100644 index 55df2863d20..00000000000 --- a/backend/alembic/script.py.mako +++ /dev/null @@ -1,24 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision = ${repr(up_revision)} -down_revision = ${repr(down_revision)} -branch_labels = ${repr(branch_labels)} -depends_on = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0568ccf46a6b_add_thread_specific_model_selection.py b/backend/alembic/versions/0568ccf46a6b_add_thread_specific_model_selection.py deleted file mode 100644 index d0b90da0232..00000000000 --- a/backend/alembic/versions/0568ccf46a6b_add_thread_specific_model_selection.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add thread specific model selection - -Revision ID: 0568ccf46a6b -Revises: e209dc5a8156 -Create Date: 2024-06-19 14:25:36.376046 - -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "0568ccf46a6b" -down_revision = "e209dc5a8156" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_session", - sa.Column("current_alternate_model", sa.String(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("chat_session", "current_alternate_model") diff --git a/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py b/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py deleted file mode 100644 index 69eec4c108e..00000000000 --- a/backend/alembic/versions/05c07bf07c00_add_search_doc_relevance_details.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add search doc relevance details - -Revision ID: 05c07bf07c00 -Revises: b896bbd0d5a7 -Create Date: 2024-07-10 17:48:15.886653 - -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "05c07bf07c00" -down_revision = "b896bbd0d5a7" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "search_doc", - sa.Column("is_relevant", sa.Boolean(), nullable=True), - ) - op.add_column( - "search_doc", - sa.Column("relevance_explanation", sa.String(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("search_doc", "relevance_explanation") - op.drop_column("search_doc", "is_relevant") diff --git a/backend/alembic/versions/08a1eda20fe1_add_earliest_indexing_to_connector.py b/backend/alembic/versions/08a1eda20fe1_add_earliest_indexing_to_connector.py deleted file mode 100644 index 3f4893bd12c..00000000000 --- a/backend/alembic/versions/08a1eda20fe1_add_earliest_indexing_to_connector.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add_indexing_start_to_connector - -Revision ID: 08a1eda20fe1 -Revises: 8a87bd6ec550 -Create Date: 2024-07-23 11:12:39.462397 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "08a1eda20fe1" -down_revision = "8a87bd6ec550" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "connector", sa.Column("indexing_start", sa.DateTime(), nullable=True) - ) - - -def downgrade() -> None: - op.drop_column("connector", "indexing_start") diff --git a/backend/alembic/versions/0a2b51deb0b8_add_starter_prompts.py b/backend/alembic/versions/0a2b51deb0b8_add_starter_prompts.py deleted file mode 100644 index caade4441dc..00000000000 --- a/backend/alembic/versions/0a2b51deb0b8_add_starter_prompts.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Add starter prompts - -Revision ID: 0a2b51deb0b8 -Revises: 5f4b8568a221 -Create Date: 2024-03-02 23:23:49.960309 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "0a2b51deb0b8" -down_revision = "5f4b8568a221" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "persona", - sa.Column( - "starter_messages", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("persona", "starter_messages") diff --git a/backend/alembic/versions/0a98909f2757_enable_encrypted_fields.py b/backend/alembic/versions/0a98909f2757_enable_encrypted_fields.py deleted file mode 100644 index 29993d1e263..00000000000 --- a/backend/alembic/versions/0a98909f2757_enable_encrypted_fields.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Enable Encrypted Fields - -Revision ID: 0a98909f2757 -Revises: 570282d33c49 -Create Date: 2024-05-05 19:30:34.317972 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.sql import table -from sqlalchemy.dialects import postgresql -import json - -from danswer.utils.encryption import encrypt_string_to_bytes - -# revision identifiers, used by Alembic. -revision = "0a98909f2757" -down_revision = "570282d33c49" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - connection = op.get_bind() - - op.alter_column("key_value_store", "value", nullable=True) - op.add_column( - "key_value_store", - sa.Column( - "encrypted_value", - sa.LargeBinary, - nullable=True, - ), - ) - - # Need a temporary column to translate the JSONB to binary - op.add_column("credential", sa.Column("temp_column", sa.LargeBinary())) - - creds_table = table( - "credential", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "credential_json", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - ), - sa.Column( - "temp_column", - sa.LargeBinary(), - nullable=False, - ), - ) - - results = connection.execute(sa.select(creds_table)) - - # This uses the MIT encrypt which does not actually encrypt the credentials - # In other words, this upgrade does not apply the encryption. Porting existing sensitive data - # and key rotation currently is not supported and will come out in the future - for row_id, creds, _ in results: - creds_binary = encrypt_string_to_bytes(json.dumps(creds)) - connection.execute( - creds_table.update() - .where(creds_table.c.id == row_id) - .values(temp_column=creds_binary) - ) - - op.drop_column("credential", "credential_json") - op.alter_column("credential", "temp_column", new_column_name="credential_json") - - op.add_column("llm_provider", sa.Column("temp_column", sa.LargeBinary())) - - llm_table = table( - "llm_provider", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "api_key", - sa.String(), - nullable=False, - ), - sa.Column( - "temp_column", - sa.LargeBinary(), - nullable=False, - ), - ) - results = connection.execute(sa.select(llm_table)) - - for row_id, api_key, _ in results: - llm_key = encrypt_string_to_bytes(api_key) - connection.execute( - llm_table.update() - .where(llm_table.c.id == row_id) - .values(temp_column=llm_key) - ) - - op.drop_column("llm_provider", "api_key") - op.alter_column("llm_provider", "temp_column", new_column_name="api_key") - - -def downgrade() -> None: - # Some information loss but this is ok. Should not allow decryption via downgrade. - op.drop_column("credential", "credential_json") - op.drop_column("llm_provider", "api_key") - - op.add_column("llm_provider", sa.Column("api_key", sa.String())) - op.add_column( - "credential", - sa.Column("credential_json", postgresql.JSONB(astext_type=sa.Text())), - ) - - op.execute("DELETE FROM key_value_store WHERE value IS NULL") - op.alter_column("key_value_store", "value", nullable=False) - op.drop_column("key_value_store", "encrypted_value") diff --git a/backend/alembic/versions/15326fcec57e_introduce_danswer_apis.py b/backend/alembic/versions/15326fcec57e_introduce_danswer_apis.py deleted file mode 100644 index aecb60c21ad..00000000000 --- a/backend/alembic/versions/15326fcec57e_introduce_danswer_apis.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Introduce Danswer APIs - -Revision ID: 15326fcec57e -Revises: 77d07dffae64 -Create Date: 2023-11-11 20:51:24.228999 - -""" -from alembic import op -import sqlalchemy as sa - -from danswer.configs.constants import DocumentSource - -# revision identifiers, used by Alembic. -revision = "15326fcec57e" -down_revision = "77d07dffae64" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column("credential", "is_admin", new_column_name="admin_public") - op.add_column( - "document", - sa.Column("from_ingestion_api", sa.Boolean(), nullable=True), - ) - op.alter_column( - "connector", - "source", - type_=sa.String(length=50), - existing_type=sa.Enum(DocumentSource, native_enum=False), - existing_nullable=False, - ) - - -def downgrade() -> None: - op.drop_column("document", "from_ingestion_api") - op.alter_column("credential", "admin_public", new_column_name="is_admin") diff --git a/backend/alembic/versions/173cae5bba26_port_config_store.py b/backend/alembic/versions/173cae5bba26_port_config_store.py deleted file mode 100644 index a879d4d9b76..00000000000 --- a/backend/alembic/versions/173cae5bba26_port_config_store.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Port Config Store - -Revision ID: 173cae5bba26 -Revises: e50154680a5c -Create Date: 2024-03-19 15:30:44.425436 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "173cae5bba26" -down_revision = "e50154680a5c" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "key_value_store", - sa.Column("key", sa.String(), nullable=False), - sa.Column("value", postgresql.JSONB(astext_type=sa.Text()), nullable=False), - sa.PrimaryKeyConstraint("key"), - ) - - -def downgrade() -> None: - op.drop_table("key_value_store") diff --git a/backend/alembic/versions/1f60f60c3401_embedding_model_search_settings.py b/backend/alembic/versions/1f60f60c3401_embedding_model_search_settings.py deleted file mode 100644 index 42f4c22ed78..00000000000 --- a/backend/alembic/versions/1f60f60c3401_embedding_model_search_settings.py +++ /dev/null @@ -1,135 +0,0 @@ -"""embedding model -> search settings - -Revision ID: 1f60f60c3401 -Revises: f17bf3b0d9f1 -Create Date: 2024-08-25 12:39:51.731632 - -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -from danswer.configs.chat_configs import NUM_POSTPROCESSED_RESULTS - -# revision identifiers, used by Alembic. -revision = "1f60f60c3401" -down_revision = "f17bf3b0d9f1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_constraint( - "index_attempt__embedding_model_fk", "index_attempt", type_="foreignkey" - ) - # Rename the table - op.rename_table("embedding_model", "search_settings") - - # Add new columns - op.add_column( - "search_settings", - sa.Column( - "multipass_indexing", sa.Boolean(), nullable=False, server_default="true" - ), - ) - op.add_column( - "search_settings", - sa.Column( - "multilingual_expansion", - postgresql.ARRAY(sa.String()), - nullable=False, - server_default="{}", - ), - ) - op.add_column( - "search_settings", - sa.Column( - "disable_rerank_for_streaming", - sa.Boolean(), - nullable=False, - server_default="false", - ), - ) - op.add_column( - "search_settings", sa.Column("rerank_model_name", sa.String(), nullable=True) - ) - op.add_column( - "search_settings", sa.Column("rerank_provider_type", sa.String(), nullable=True) - ) - op.add_column( - "search_settings", sa.Column("rerank_api_key", sa.String(), nullable=True) - ) - op.add_column( - "search_settings", - sa.Column( - "num_rerank", - sa.Integer(), - nullable=False, - server_default=str(NUM_POSTPROCESSED_RESULTS), - ), - ) - - # Add the new column as nullable initially - op.add_column( - "index_attempt", sa.Column("search_settings_id", sa.Integer(), nullable=True) - ) - - # Populate the new column with data from the existing embedding_model_id - op.execute("UPDATE index_attempt SET search_settings_id = embedding_model_id") - - # Create the foreign key constraint - op.create_foreign_key( - "fk_index_attempt_search_settings", - "index_attempt", - "search_settings", - ["search_settings_id"], - ["id"], - ) - - # Make the new column non-nullable - op.alter_column("index_attempt", "search_settings_id", nullable=False) - - # Drop the old embedding_model_id column - op.drop_column("index_attempt", "embedding_model_id") - - -def downgrade() -> None: - # Add back the embedding_model_id column - op.add_column( - "index_attempt", sa.Column("embedding_model_id", sa.Integer(), nullable=True) - ) - - # Populate the old column with data from search_settings_id - op.execute("UPDATE index_attempt SET embedding_model_id = search_settings_id") - - # Make the old column non-nullable - op.alter_column("index_attempt", "embedding_model_id", nullable=False) - - # Drop the foreign key constraint - op.drop_constraint( - "fk_index_attempt_search_settings", "index_attempt", type_="foreignkey" - ) - - # Drop the new search_settings_id column - op.drop_column("index_attempt", "search_settings_id") - - # Rename the table back - op.rename_table("search_settings", "embedding_model") - - # Remove added columns - op.drop_column("embedding_model", "num_rerank") - op.drop_column("embedding_model", "rerank_api_key") - op.drop_column("embedding_model", "rerank_provider_type") - op.drop_column("embedding_model", "rerank_model_name") - op.drop_column("embedding_model", "disable_rerank_for_streaming") - op.drop_column("embedding_model", "multilingual_expansion") - op.drop_column("embedding_model", "multipass_indexing") - - op.create_foreign_key( - "index_attempt__embedding_model_fk", - "index_attempt", - "embedding_model", - ["embedding_model_id"], - ["id"], - ) diff --git a/backend/alembic/versions/213fd978c6d8_notifications.py b/backend/alembic/versions/213fd978c6d8_notifications.py deleted file mode 100644 index 563556ea50a..00000000000 --- a/backend/alembic/versions/213fd978c6d8_notifications.py +++ /dev/null @@ -1,44 +0,0 @@ -"""notifications - -Revision ID: 213fd978c6d8 -Revises: 5fc1f54cc252 -Create Date: 2024-08-10 11:13:36.070790 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "213fd978c6d8" -down_revision = "5fc1f54cc252" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "notification", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "notif_type", - sa.String(), - nullable=False, - ), - sa.Column( - "user_id", - sa.UUID(), - nullable=True, - ), - sa.Column("dismissed", sa.Boolean(), nullable=False), - sa.Column("last_shown", sa.DateTime(timezone=True), nullable=False), - sa.Column("first_shown", sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("notification") diff --git a/backend/alembic/versions/23957775e5f5_remove_feedback_foreignkey_constraint.py b/backend/alembic/versions/23957775e5f5_remove_feedback_foreignkey_constraint.py deleted file mode 100644 index 10d094e0da2..00000000000 --- a/backend/alembic/versions/23957775e5f5_remove_feedback_foreignkey_constraint.py +++ /dev/null @@ -1,86 +0,0 @@ -"""remove-feedback-foreignkey-constraint - -Revision ID: 23957775e5f5 -Revises: bc9771dccadf -Create Date: 2024-06-27 16:04:51.480437 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "23957775e5f5" -down_revision = "bc9771dccadf" -branch_labels = None # type: ignore -depends_on = None # type: ignore - - -def upgrade() -> None: - op.drop_constraint( - "chat_feedback__chat_message_fk", "chat_feedback", type_="foreignkey" - ) - op.create_foreign_key( - "chat_feedback__chat_message_fk", - "chat_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ondelete="SET NULL", - ) - op.alter_column( - "chat_feedback", "chat_message_id", existing_type=sa.Integer(), nullable=True - ) - op.drop_constraint( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - type_="foreignkey", - ) - op.create_foreign_key( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ondelete="SET NULL", - ) - op.alter_column( - "document_retrieval_feedback", - "chat_message_id", - existing_type=sa.Integer(), - nullable=True, - ) - - -def downgrade() -> None: - op.alter_column( - "chat_feedback", "chat_message_id", existing_type=sa.Integer(), nullable=False - ) - op.drop_constraint( - "chat_feedback__chat_message_fk", "chat_feedback", type_="foreignkey" - ) - op.create_foreign_key( - "chat_feedback__chat_message_fk", - "chat_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ) - - op.alter_column( - "document_retrieval_feedback", - "chat_message_id", - existing_type=sa.Integer(), - nullable=False, - ) - op.drop_constraint( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - type_="foreignkey", - ) - op.create_foreign_key( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ) diff --git a/backend/alembic/versions/2666d766cb9b_google_oauth2.py b/backend/alembic/versions/2666d766cb9b_google_oauth2.py deleted file mode 100644 index bcdbd531b69..00000000000 --- a/backend/alembic/versions/2666d766cb9b_google_oauth2.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Google OAuth2 - -Revision ID: 2666d766cb9b -Revises: 6d387b3196c2 -Create Date: 2023-05-05 15:49:35.716016 - -""" -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "2666d766cb9b" -down_revision = "6d387b3196c2" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "oauth_account", - sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.Column("oauth_name", sa.String(length=100), nullable=False), - sa.Column("access_token", sa.String(length=1024), nullable=False), - sa.Column("expires_at", sa.Integer(), nullable=True), - sa.Column("refresh_token", sa.String(length=1024), nullable=True), - sa.Column("account_id", sa.String(length=320), nullable=False), - sa.Column("account_email", sa.String(length=320), nullable=False), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="cascade"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_oauth_account_account_id"), - "oauth_account", - ["account_id"], - unique=False, - ) - op.create_index( - op.f("ix_oauth_account_oauth_name"), - "oauth_account", - ["oauth_name"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_oauth_account_oauth_name"), table_name="oauth_account") - op.drop_index(op.f("ix_oauth_account_account_id"), table_name="oauth_account") - op.drop_table("oauth_account") diff --git a/backend/alembic/versions/27c6ecc08586_permission_framework.py b/backend/alembic/versions/27c6ecc08586_permission_framework.py deleted file mode 100644 index ff41d2f5cff..00000000000 --- a/backend/alembic/versions/27c6ecc08586_permission_framework.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Permission Framework - -Revision ID: 27c6ecc08586 -Revises: 2666d766cb9b -Create Date: 2023-05-24 18:45:17.244495 - -""" -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "27c6ecc08586" -down_revision = "2666d766cb9b" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute("TRUNCATE TABLE index_attempt") - op.create_table( - "connector", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column( - "source", - sa.Enum( - "SLACK", - "WEB", - "GOOGLE_DRIVE", - "GITHUB", - "CONFLUENCE", - name="documentsource", - native_enum=False, - ), - nullable=False, - ), - sa.Column( - "input_type", - sa.Enum( - "LOAD_STATE", - "POLL", - "EVENT", - name="inputtype", - native_enum=False, - ), - nullable=True, - ), - sa.Column( - "connector_specific_config", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - ), - sa.Column("refresh_freq", sa.Integer(), nullable=True), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "time_updated", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("disabled", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "credential", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "credential_json", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - ), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column("public_doc", sa.Boolean(), nullable=False), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "time_updated", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "connector_credential_pair", - sa.Column("connector_id", sa.Integer(), nullable=False), - sa.Column("credential_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["connector_id"], - ["connector.id"], - ), - sa.ForeignKeyConstraint( - ["credential_id"], - ["credential.id"], - ), - sa.PrimaryKeyConstraint("connector_id", "credential_id"), - ) - op.add_column( - "index_attempt", - sa.Column("connector_id", sa.Integer(), nullable=True), - ) - op.add_column( - "index_attempt", - sa.Column("credential_id", sa.Integer(), nullable=True), - ) - op.create_foreign_key( - "fk_index_attempt_credential_id", - "index_attempt", - "credential", - ["credential_id"], - ["id"], - ) - op.create_foreign_key( - "fk_index_attempt_connector_id", - "index_attempt", - "connector", - ["connector_id"], - ["id"], - ) - op.drop_column("index_attempt", "connector_specific_config") - op.drop_column("index_attempt", "source") - op.drop_column("index_attempt", "input_type") - - -def downgrade() -> None: - op.execute("TRUNCATE TABLE index_attempt") - op.add_column( - "index_attempt", - sa.Column("input_type", sa.VARCHAR(), autoincrement=False, nullable=False), - ) - op.add_column( - "index_attempt", - sa.Column("source", sa.VARCHAR(), autoincrement=False, nullable=False), - ) - op.add_column( - "index_attempt", - sa.Column( - "connector_specific_config", - postgresql.JSONB(astext_type=sa.Text()), - autoincrement=False, - nullable=False, - ), - ) - - # Check if the constraint exists before dropping - conn = op.get_bind() - inspector = sa.inspect(conn) - constraints = inspector.get_foreign_keys("index_attempt") - - if any( - constraint["name"] == "fk_index_attempt_credential_id" - for constraint in constraints - ): - op.drop_constraint( - "fk_index_attempt_credential_id", "index_attempt", type_="foreignkey" - ) - - if any( - constraint["name"] == "fk_index_attempt_connector_id" - for constraint in constraints - ): - op.drop_constraint( - "fk_index_attempt_connector_id", "index_attempt", type_="foreignkey" - ) - - op.drop_column("index_attempt", "credential_id") - op.drop_column("index_attempt", "connector_id") - op.drop_table("connector_credential_pair") - op.drop_table("credential") - op.drop_table("connector") diff --git a/backend/alembic/versions/2d2304e27d8c_add_above_below_to_persona.py b/backend/alembic/versions/2d2304e27d8c_add_above_below_to_persona.py deleted file mode 100644 index cab166531ae..00000000000 --- a/backend/alembic/versions/2d2304e27d8c_add_above_below_to_persona.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Add Above Below to Persona - -Revision ID: 2d2304e27d8c -Revises: 4b08d97e175a -Create Date: 2024-08-21 19:15:15.762948 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "2d2304e27d8c" -down_revision = "4b08d97e175a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("persona", sa.Column("chunks_above", sa.Integer(), nullable=True)) - op.add_column("persona", sa.Column("chunks_below", sa.Integer(), nullable=True)) - - op.execute( - "UPDATE persona SET chunks_above = 1, chunks_below = 1 WHERE chunks_above IS NULL AND chunks_below IS NULL" - ) - - op.alter_column("persona", "chunks_above", nullable=False) - op.alter_column("persona", "chunks_below", nullable=False) - - -def downgrade() -> None: - op.drop_column("persona", "chunks_below") - op.drop_column("persona", "chunks_above") diff --git a/backend/alembic/versions/30c1d5744104_persona_datetime_aware.py b/backend/alembic/versions/30c1d5744104_persona_datetime_aware.py deleted file mode 100644 index 7bced15223d..00000000000 --- a/backend/alembic/versions/30c1d5744104_persona_datetime_aware.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Persona Datetime Aware - -Revision ID: 30c1d5744104 -Revises: 7f99be1cb9f5 -Create Date: 2023-10-16 23:21:01.283424 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "30c1d5744104" -down_revision = "7f99be1cb9f5" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("persona", sa.Column("datetime_aware", sa.Boolean(), nullable=True)) - op.execute("UPDATE persona SET datetime_aware = TRUE") - op.alter_column("persona", "datetime_aware", nullable=False) - op.create_index( - "_default_persona_name_idx", - "persona", - ["name"], - unique=True, - postgresql_where=sa.text("default_persona = true"), - ) - - -def downgrade() -> None: - op.drop_index( - "_default_persona_name_idx", - table_name="persona", - postgresql_where=sa.text("default_persona = true"), - ) - op.drop_column("persona", "datetime_aware") diff --git a/backend/alembic/versions/325975216eb3_add_icon_color_and_icon_shape_to_persona.py b/backend/alembic/versions/325975216eb3_add_icon_color_and_icon_shape_to_persona.py deleted file mode 100644 index 46beab05f5c..00000000000 --- a/backend/alembic/versions/325975216eb3_add_icon_color_and_icon_shape_to_persona.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Add icon_color and icon_shape to Persona - -Revision ID: 325975216eb3 -Revises: 91ffac7e65b3 -Create Date: 2024-07-24 21:29:31.784562 - -""" -import random -from alembic import op -import sqlalchemy as sa -from sqlalchemy.sql import table, column, select - -# revision identifiers, used by Alembic. -revision = "325975216eb3" -down_revision = "91ffac7e65b3" -branch_labels: None = None -depends_on: None = None - - -colorOptions = [ - "#FF6FBF", - "#6FB1FF", - "#B76FFF", - "#FFB56F", - "#6FFF8D", - "#FF6F6F", - "#6FFFFF", -] - - -# Function to generate a random shape ensuring at least 3 of the middle 4 squares are filled -def generate_random_shape() -> int: - center_squares = [12, 10, 6, 14, 13, 11, 7, 15] - center_fill = random.choice(center_squares) - remaining_squares = [i for i in range(16) if not (center_fill & (1 << i))] - random.shuffle(remaining_squares) - for i in range(10 - bin(center_fill).count("1")): - center_fill |= 1 << remaining_squares[i] - return center_fill - - -def upgrade() -> None: - op.add_column("persona", sa.Column("icon_color", sa.String(), nullable=True)) - op.add_column("persona", sa.Column("icon_shape", sa.Integer(), nullable=True)) - op.add_column("persona", sa.Column("uploaded_image_id", sa.String(), nullable=True)) - - persona = table( - "persona", - column("id", sa.Integer), - column("icon_color", sa.String), - column("icon_shape", sa.Integer), - ) - - conn = op.get_bind() - personas = conn.execute(select(persona.c.id)) - - for persona_id in personas: - random_color = random.choice(colorOptions) - random_shape = generate_random_shape() - conn.execute( - persona.update() - .where(persona.c.id == persona_id[0]) - .values(icon_color=random_color, icon_shape=random_shape) - ) - - -def downgrade() -> None: - op.drop_column("persona", "icon_shape") - op.drop_column("persona", "uploaded_image_id") - op.drop_column("persona", "icon_color") diff --git a/backend/alembic/versions/351faebd379d_add_curator_fields.py b/backend/alembic/versions/351faebd379d_add_curator_fields.py deleted file mode 100644 index b3254d26c16..00000000000 --- a/backend/alembic/versions/351faebd379d_add_curator_fields.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Add curator fields - -Revision ID: 351faebd379d -Revises: ee3f4b47fad5 -Create Date: 2024-08-15 22:37:08.397052 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "351faebd379d" -down_revision = "ee3f4b47fad5" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Add is_curator column to User__UserGroup table - op.add_column( - "user__user_group", - sa.Column("is_curator", sa.Boolean(), nullable=False, server_default="false"), - ) - - # Use batch mode to modify the enum type - with op.batch_alter_table("user", schema=None) as batch_op: - batch_op.alter_column( # type: ignore[attr-defined] - "role", - type_=sa.Enum( - "BASIC", - "ADMIN", - "CURATOR", - "GLOBAL_CURATOR", - name="userrole", - native_enum=False, - ), - existing_type=sa.Enum("BASIC", "ADMIN", name="userrole", native_enum=False), - existing_nullable=False, - ) - # Create the association table - op.create_table( - "credential__user_group", - sa.Column("credential_id", sa.Integer(), nullable=False), - sa.Column("user_group_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["credential_id"], - ["credential.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("credential_id", "user_group_id"), - ) - op.add_column( - "credential", - sa.Column( - "curator_public", sa.Boolean(), nullable=False, server_default="false" - ), - ) - - -def downgrade() -> None: - # Update existing records to ensure they fit within the BASIC/ADMIN roles - op.execute( - "UPDATE \"user\" SET role = 'ADMIN' WHERE role IN ('CURATOR', 'GLOBAL_CURATOR')" - ) - - # Remove is_curator column from User__UserGroup table - op.drop_column("user__user_group", "is_curator") - - with op.batch_alter_table("user", schema=None) as batch_op: - batch_op.alter_column( # type: ignore[attr-defined] - "role", - type_=sa.Enum( - "BASIC", "ADMIN", name="userrole", native_enum=False, length=20 - ), - existing_type=sa.Enum( - "BASIC", - "ADMIN", - "CURATOR", - "GLOBAL_CURATOR", - name="userrole", - native_enum=False, - ), - existing_nullable=False, - ) - # Drop the association table - op.drop_table("credential__user_group") - op.drop_column("credential", "curator_public") diff --git a/backend/alembic/versions/3879338f8ba1_add_tool_table.py b/backend/alembic/versions/3879338f8ba1_add_tool_table.py deleted file mode 100644 index f4d5cb78e6e..00000000000 --- a/backend/alembic/versions/3879338f8ba1_add_tool_table.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Add tool table - -Revision ID: 3879338f8ba1 -Revises: f1c6478c3fd8 -Create Date: 2024-05-11 16:11:23.718084 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "3879338f8ba1" -down_revision = "f1c6478c3fd8" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "tool", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("in_code_tool_id", sa.String(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "persona__tool", - sa.Column("persona_id", sa.Integer(), nullable=False), - sa.Column("tool_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.ForeignKeyConstraint( - ["tool_id"], - ["tool.id"], - ), - sa.PrimaryKeyConstraint("persona_id", "tool_id"), - ) - - -def downgrade() -> None: - op.drop_table("persona__tool") - op.drop_table("tool") diff --git a/backend/alembic/versions/38eda64af7fe_add_chat_session_sharing.py b/backend/alembic/versions/38eda64af7fe_add_chat_session_sharing.py deleted file mode 100644 index efa8246123b..00000000000 --- a/backend/alembic/versions/38eda64af7fe_add_chat_session_sharing.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Add chat session sharing - -Revision ID: 38eda64af7fe -Revises: 776b3bbe9092 -Create Date: 2024-03-27 19:41:29.073594 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "38eda64af7fe" -down_revision = "776b3bbe9092" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_session", - sa.Column( - "shared_status", - sa.Enum( - "PUBLIC", - "PRIVATE", - name="chatsessionsharedstatus", - native_enum=False, - ), - nullable=True, - ), - ) - op.execute("UPDATE chat_session SET shared_status='PRIVATE'") - op.alter_column( - "chat_session", - "shared_status", - nullable=False, - ) - - -def downgrade() -> None: - op.drop_column("chat_session", "shared_status") diff --git a/backend/alembic/versions/3a7802814195_add_alternate_assistant_to_chat_message.py b/backend/alembic/versions/3a7802814195_add_alternate_assistant_to_chat_message.py deleted file mode 100644 index bfde0162ba2..00000000000 --- a/backend/alembic/versions/3a7802814195_add_alternate_assistant_to_chat_message.py +++ /dev/null @@ -1,35 +0,0 @@ -"""add alternate assistant to chat message - -Revision ID: 3a7802814195 -Revises: 23957775e5f5 -Create Date: 2024-06-05 11:18:49.966333 - -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "3a7802814195" -down_revision = "23957775e5f5" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_message", sa.Column("alternate_assistant_id", sa.Integer(), nullable=True) - ) - op.create_foreign_key( - "fk_chat_message_persona", - "chat_message", - "persona", - ["alternate_assistant_id"], - ["id"], - ) - - -def downgrade() -> None: - op.drop_constraint("fk_chat_message_persona", "chat_message", type_="foreignkey") - op.drop_column("chat_message", "alternate_assistant_id") diff --git a/backend/alembic/versions/3b25685ff73c_move_is_public_to_cc_pair.py b/backend/alembic/versions/3b25685ff73c_move_is_public_to_cc_pair.py deleted file mode 100644 index 2f02f646b9b..00000000000 --- a/backend/alembic/versions/3b25685ff73c_move_is_public_to_cc_pair.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Move is_public to cc_pair - -Revision ID: 3b25685ff73c -Revises: e0a68a81d434 -Create Date: 2023-10-05 18:47:09.582849 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "3b25685ff73c" -down_revision = "e0a68a81d434" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "connector_credential_pair", - sa.Column("is_public", sa.Boolean(), nullable=True), - ) - # fill in is_public for existing rows - op.execute( - "UPDATE connector_credential_pair SET is_public = true WHERE is_public IS NULL" - ) - op.alter_column("connector_credential_pair", "is_public", nullable=False) - - op.add_column( - "credential", - sa.Column("is_admin", sa.Boolean(), nullable=True), - ) - op.execute("UPDATE credential SET is_admin = true WHERE is_admin IS NULL") - op.alter_column("credential", "is_admin", nullable=False) - - op.drop_column("credential", "public_doc") - - -def downgrade() -> None: - op.add_column( - "credential", - sa.Column("public_doc", sa.Boolean(), nullable=True), - ) - # setting public_doc to false for all existing rows to be safe - # NOTE: this is likely not the correct state of the world but it's the best we can do - op.execute("UPDATE credential SET public_doc = false WHERE public_doc IS NULL") - op.alter_column("credential", "public_doc", nullable=False) - op.drop_column("connector_credential_pair", "is_public") - op.drop_column("credential", "is_admin") diff --git a/backend/alembic/versions/3c5e35aa9af0_polling_document_count.py b/backend/alembic/versions/3c5e35aa9af0_polling_document_count.py deleted file mode 100644 index 1569e639dc1..00000000000 --- a/backend/alembic/versions/3c5e35aa9af0_polling_document_count.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Polling Document Count - -Revision ID: 3c5e35aa9af0 -Revises: 27c6ecc08586 -Create Date: 2023-06-14 23:45:51.760440 - -""" -import sqlalchemy as sa -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "3c5e35aa9af0" -down_revision = "27c6ecc08586" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "connector_credential_pair", - sa.Column( - "last_successful_index_time", - sa.DateTime(timezone=True), - nullable=True, - ), - ) - op.add_column( - "connector_credential_pair", - sa.Column( - "last_attempt_status", - sa.Enum( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="indexingstatus", - native_enum=False, - ), - nullable=False, - ), - ) - op.add_column( - "connector_credential_pair", - sa.Column("total_docs_indexed", sa.Integer(), nullable=False), - ) - - -def downgrade() -> None: - op.drop_column("connector_credential_pair", "total_docs_indexed") - op.drop_column("connector_credential_pair", "last_attempt_status") - op.drop_column("connector_credential_pair", "last_successful_index_time") diff --git a/backend/alembic/versions/401c1ac29467_add_tables_for_ui_based_llm_.py b/backend/alembic/versions/401c1ac29467_add_tables_for_ui_based_llm_.py deleted file mode 100644 index dcc766fe287..00000000000 --- a/backend/alembic/versions/401c1ac29467_add_tables_for_ui_based_llm_.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Add tables for UI-based LLM configuration - -Revision ID: 401c1ac29467 -Revises: 703313b75876 -Create Date: 2024-04-13 18:07:29.153817 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "401c1ac29467" -down_revision = "703313b75876" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "llm_provider", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("api_key", sa.String(), nullable=True), - sa.Column("api_base", sa.String(), nullable=True), - sa.Column("api_version", sa.String(), nullable=True), - sa.Column( - "custom_config", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - sa.Column("default_model_name", sa.String(), nullable=False), - sa.Column("fast_default_model_name", sa.String(), nullable=True), - sa.Column("is_default_provider", sa.Boolean(), unique=True, nullable=True), - sa.Column("model_names", postgresql.ARRAY(sa.String()), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - - op.add_column( - "persona", - sa.Column("llm_model_provider_override", sa.String(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("persona", "llm_model_provider_override") - - op.drop_table("llm_provider") diff --git a/backend/alembic/versions/43cbbb3f5e6a_rename_index_origin_to_index_recursively.py b/backend/alembic/versions/43cbbb3f5e6a_rename_index_origin_to_index_recursively.py deleted file mode 100644 index 6aa2ffca0a6..00000000000 --- a/backend/alembic/versions/43cbbb3f5e6a_rename_index_origin_to_index_recursively.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Rename index_origin to index_recursively - -Revision ID: 1d6ad76d1f37 -Revises: e1392f05e840 -Create Date: 2024-08-01 12:38:54.466081 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "1d6ad76d1f37" -down_revision = "e1392f05e840" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute( - """ - UPDATE connector - SET connector_specific_config = jsonb_set( - connector_specific_config, - '{index_recursively}', - 'true'::jsonb - ) - 'index_origin' - WHERE connector_specific_config ? 'index_origin' - """ - ) - - -def downgrade() -> None: - op.execute( - """ - UPDATE connector - SET connector_specific_config = jsonb_set( - connector_specific_config, - '{index_origin}', - connector_specific_config->'index_recursively' - ) - 'index_recursively' - WHERE connector_specific_config ? 'index_recursively' - """ - ) diff --git a/backend/alembic/versions/44f856ae2a4a_add_cloud_embedding_model.py b/backend/alembic/versions/44f856ae2a4a_add_cloud_embedding_model.py deleted file mode 100644 index 2d0e1a32f98..00000000000 --- a/backend/alembic/versions/44f856ae2a4a_add_cloud_embedding_model.py +++ /dev/null @@ -1,65 +0,0 @@ -"""add cloud embedding model and update embedding_model - -Revision ID: 44f856ae2a4a -Revises: d716b0791ddd -Create Date: 2024-06-28 20:01:05.927647 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "44f856ae2a4a" -down_revision = "d716b0791ddd" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Create embedding_provider table - op.create_table( - "embedding_provider", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("api_key", sa.LargeBinary(), nullable=True), - sa.Column("default_model_id", sa.Integer(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - - # Add cloud_provider_id to embedding_model table - op.add_column( - "embedding_model", sa.Column("cloud_provider_id", sa.Integer(), nullable=True) - ) - - # Add foreign key constraints - op.create_foreign_key( - "fk_embedding_model_cloud_provider", - "embedding_model", - "embedding_provider", - ["cloud_provider_id"], - ["id"], - ) - op.create_foreign_key( - "fk_embedding_provider_default_model", - "embedding_provider", - "embedding_model", - ["default_model_id"], - ["id"], - ) - - -def downgrade() -> None: - # Remove foreign key constraints - op.drop_constraint( - "fk_embedding_model_cloud_provider", "embedding_model", type_="foreignkey" - ) - op.drop_constraint( - "fk_embedding_provider_default_model", "embedding_provider", type_="foreignkey" - ) - - # Remove cloud_provider_id column - op.drop_column("embedding_model", "cloud_provider_id") - - # Drop embedding_provider table - op.drop_table("embedding_provider") diff --git a/backend/alembic/versions/4505fd7302e1_added_is_internet_to_dbdoc.py b/backend/alembic/versions/4505fd7302e1_added_is_internet_to_dbdoc.py deleted file mode 100644 index 418f8360372..00000000000 --- a/backend/alembic/versions/4505fd7302e1_added_is_internet_to_dbdoc.py +++ /dev/null @@ -1,23 +0,0 @@ -"""added is_internet to DBDoc - -Revision ID: 4505fd7302e1 -Revises: c18cdf4b497e -Create Date: 2024-06-18 20:46:09.095034 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "4505fd7302e1" -down_revision = "c18cdf4b497e" - - -def upgrade() -> None: - op.add_column("search_doc", sa.Column("is_internet", sa.Boolean(), nullable=True)) - op.add_column("tool", sa.Column("display_name", sa.String(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("tool", "display_name") - op.drop_column("search_doc", "is_internet") diff --git a/backend/alembic/versions/465f78d9b7f9_larger_access_tokens_for_oauth.py b/backend/alembic/versions/465f78d9b7f9_larger_access_tokens_for_oauth.py deleted file mode 100644 index f7a996c8331..00000000000 --- a/backend/alembic/versions/465f78d9b7f9_larger_access_tokens_for_oauth.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Larger Access Tokens for OAUTH - -Revision ID: 465f78d9b7f9 -Revises: 3c5e35aa9af0 -Create Date: 2023-07-18 17:33:40.365034 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "465f78d9b7f9" -down_revision = "3c5e35aa9af0" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column("oauth_account", "access_token", type_=sa.Text()) - - -def downgrade() -> None: - op.alter_column("oauth_account", "access_token", type_=sa.String(length=1024)) diff --git a/backend/alembic/versions/46625e4745d4_remove_native_enum.py b/backend/alembic/versions/46625e4745d4_remove_native_enum.py deleted file mode 100644 index 53c0ffdd0e1..00000000000 --- a/backend/alembic/versions/46625e4745d4_remove_native_enum.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Remove Native Enum - -Revision ID: 46625e4745d4 -Revises: 9d97fecfab7f -Create Date: 2023-10-27 11:38:33.803145 - -""" -from alembic import op -from sqlalchemy import String - -# revision identifiers, used by Alembic. -revision = "46625e4745d4" -down_revision = "9d97fecfab7f" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # At this point, we directly changed some previous migrations, - # https://github.com/danswer-ai/danswer/pull/637 - # Due to using Postgres native Enums, it caused some complications for first time users. - # To remove those complications, all Enums are only handled application side moving forward. - # This migration exists to ensure that existing users don't run into upgrade issues. - op.alter_column("index_attempt", "status", type_=String) - op.alter_column("connector_credential_pair", "last_attempt_status", type_=String) - op.execute("DROP TYPE IF EXISTS indexingstatus") - - -def downgrade() -> None: - # We don't want Native Enums, do nothing - pass diff --git a/backend/alembic/versions/4738e4b3bae1_pg_file_store.py b/backend/alembic/versions/4738e4b3bae1_pg_file_store.py deleted file mode 100644 index 819d94ddb51..00000000000 --- a/backend/alembic/versions/4738e4b3bae1_pg_file_store.py +++ /dev/null @@ -1,28 +0,0 @@ -"""PG File Store - -Revision ID: 4738e4b3bae1 -Revises: e91df4e935ef -Create Date: 2024-03-20 18:53:32.461518 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "4738e4b3bae1" -down_revision = "e91df4e935ef" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "file_store", - sa.Column("file_name", sa.String(), nullable=False), - sa.Column("lobj_oid", sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint("file_name"), - ) - - -def downgrade() -> None: - op.drop_table("file_store") diff --git a/backend/alembic/versions/473a1a7ca408_add_display_model_names_to_llm_provider.py b/backend/alembic/versions/473a1a7ca408_add_display_model_names_to_llm_provider.py deleted file mode 100644 index 2e3f377e372..00000000000 --- a/backend/alembic/versions/473a1a7ca408_add_display_model_names_to_llm_provider.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Add display_model_names to llm_provider - -Revision ID: 473a1a7ca408 -Revises: 325975216eb3 -Create Date: 2024-07-25 14:31:02.002917 - -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "473a1a7ca408" -down_revision = "325975216eb3" -branch_labels: None = None -depends_on: None = None - -default_models_by_provider = { - "openai": ["gpt-4", "gpt-4o", "gpt-4o-mini"], - "bedrock": [ - "meta.llama3-1-70b-instruct-v1:0", - "meta.llama3-1-8b-instruct-v1:0", - "anthropic.claude-3-opus-20240229-v1:0", - "mistral.mistral-large-2402-v1:0", - "anthropic.claude-3-5-sonnet-20240620-v1:0", - ], - "anthropic": ["claude-3-opus-20240229", "claude-3-5-sonnet-20240620"], -} - - -def upgrade() -> None: - op.add_column( - "llm_provider", - sa.Column("display_model_names", postgresql.ARRAY(sa.String()), nullable=True), - ) - - connection = op.get_bind() - for provider, models in default_models_by_provider.items(): - connection.execute( - sa.text( - "UPDATE llm_provider SET display_model_names = :models WHERE provider = :provider" - ), - {"models": models, "provider": provider}, - ) - - -def downgrade() -> None: - op.drop_column("llm_provider", "display_model_names") diff --git a/backend/alembic/versions/47433d30de82_create_indexattempt_table.py b/backend/alembic/versions/47433d30de82_create_indexattempt_table.py deleted file mode 100644 index a82dfabe964..00000000000 --- a/backend/alembic/versions/47433d30de82_create_indexattempt_table.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Create IndexAttempt table - -Revision ID: 47433d30de82 -Revises: -Create Date: 2023-05-04 00:55:32.971991 - -""" -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "47433d30de82" -down_revision: None = None -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "index_attempt", - sa.Column("id", sa.Integer(), nullable=False), - # String type since python enum will change often - sa.Column( - "source", - sa.String(), - nullable=False, - ), - # String type to easily accomodate new ways of pulling - # in documents - sa.Column( - "input_type", - sa.String(), - nullable=False, - ), - sa.Column( - "connector_specific_config", - postgresql.JSONB(), - nullable=False, - ), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=True, - ), - sa.Column( - "time_updated", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - server_onupdate=sa.text("now()"), # type: ignore - nullable=True, - ), - sa.Column( - "status", - sa.Enum( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="indexingstatus", - native_enum=False, - ), - nullable=False, - ), - sa.Column("document_ids", postgresql.ARRAY(sa.String()), nullable=True), - sa.Column("error_msg", sa.String(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("index_attempt") diff --git a/backend/alembic/versions/475fcefe8826_add_name_to_api_key.py b/backend/alembic/versions/475fcefe8826_add_name_to_api_key.py deleted file mode 100644 index e8912e19ab6..00000000000 --- a/backend/alembic/versions/475fcefe8826_add_name_to_api_key.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Add name to api_key - -Revision ID: 475fcefe8826 -Revises: ecab2b3f1a3b -Create Date: 2024-04-11 11:05:18.414438 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "475fcefe8826" -down_revision = "ecab2b3f1a3b" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("api_key", sa.Column("name", sa.String(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("api_key", "name") diff --git a/backend/alembic/versions/48d14957fe80_add_support_for_custom_tools.py b/backend/alembic/versions/48d14957fe80_add_support_for_custom_tools.py deleted file mode 100644 index 3a77170d856..00000000000 --- a/backend/alembic/versions/48d14957fe80_add_support_for_custom_tools.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Add support for custom tools - -Revision ID: 48d14957fe80 -Revises: b85f02ec1308 -Create Date: 2024-06-09 14:58:19.946509 - -""" -from alembic import op -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "48d14957fe80" -down_revision = "b85f02ec1308" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "tool", - sa.Column( - "openapi_schema", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - op.add_column( - "tool", - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - ) - op.create_foreign_key("tool_user_fk", "tool", "user", ["user_id"], ["id"]) - - op.create_table( - "tool_call", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("tool_id", sa.Integer(), nullable=False), - sa.Column("tool_name", sa.String(), nullable=False), - sa.Column( - "tool_arguments", postgresql.JSONB(astext_type=sa.Text()), nullable=False - ), - sa.Column( - "tool_result", postgresql.JSONB(astext_type=sa.Text()), nullable=False - ), - sa.Column( - "message_id", sa.Integer(), sa.ForeignKey("chat_message.id"), nullable=False - ), - ) - - -def downgrade() -> None: - op.drop_table("tool_call") - - op.drop_constraint("tool_user_fk", "tool", type_="foreignkey") - op.drop_column("tool", "user_id") - op.drop_column("tool", "openapi_schema") diff --git a/backend/alembic/versions/4a951134c801_moved_status_to_connector_credential_.py b/backend/alembic/versions/4a951134c801_moved_status_to_connector_credential_.py deleted file mode 100644 index 3deebaecd39..00000000000 --- a/backend/alembic/versions/4a951134c801_moved_status_to_connector_credential_.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Moved status to connector credential pair - -Revision ID: 4a951134c801 -Revises: 7477a5f5d728 -Create Date: 2024-08-10 19:20:34.527559 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "4a951134c801" -down_revision = "7477a5f5d728" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "connector_credential_pair", - sa.Column( - "status", - sa.Enum( - "ACTIVE", - "PAUSED", - "DELETING", - name="connectorcredentialpairstatus", - native_enum=False, - ), - nullable=True, - ), - ) - - # Update status of connector_credential_pair based on connector's disabled status - op.execute( - """ - UPDATE connector_credential_pair - SET status = CASE - WHEN ( - SELECT disabled - FROM connector - WHERE connector.id = connector_credential_pair.connector_id - ) = FALSE THEN 'ACTIVE' - ELSE 'PAUSED' - END - """ - ) - - # Make the status column not nullable after setting values - op.alter_column("connector_credential_pair", "status", nullable=False) - - op.drop_column("connector", "disabled") - - -def downgrade() -> None: - op.add_column( - "connector", - sa.Column("disabled", sa.BOOLEAN(), autoincrement=False, nullable=True), - ) - - # Update disabled status of connector based on connector_credential_pair's status - op.execute( - """ - UPDATE connector - SET disabled = CASE - WHEN EXISTS ( - SELECT 1 - FROM connector_credential_pair - WHERE connector_credential_pair.connector_id = connector.id - AND connector_credential_pair.status = 'ACTIVE' - ) THEN FALSE - ELSE TRUE - END - """ - ) - - # Make the disabled column not nullable after setting values - op.alter_column("connector", "disabled", nullable=False) - - op.drop_column("connector_credential_pair", "status") diff --git a/backend/alembic/versions/4b08d97e175a_change_default_prune_freq.py b/backend/alembic/versions/4b08d97e175a_change_default_prune_freq.py deleted file mode 100644 index 29316adb1df..00000000000 --- a/backend/alembic/versions/4b08d97e175a_change_default_prune_freq.py +++ /dev/null @@ -1,34 +0,0 @@ -"""change default prune_freq - -Revision ID: 4b08d97e175a -Revises: d9ec13955951 -Create Date: 2024-08-20 15:28:52.993827 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "4b08d97e175a" -down_revision = "d9ec13955951" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute( - """ - UPDATE connector - SET prune_freq = 2592000 - WHERE prune_freq = 86400 - """ - ) - - -def downgrade() -> None: - op.execute( - """ - UPDATE connector - SET prune_freq = 86400 - WHERE prune_freq = 2592000 - """ - ) diff --git a/backend/alembic/versions/4ea2c93919c1_add_type_to_credentials.py b/backend/alembic/versions/4ea2c93919c1_add_type_to_credentials.py deleted file mode 100644 index 8077b24b095..00000000000 --- a/backend/alembic/versions/4ea2c93919c1_add_type_to_credentials.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Add type to credentials - -Revision ID: 4ea2c93919c1 -Revises: 473a1a7ca408 -Create Date: 2024-07-18 13:07:13.655895 - -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "4ea2c93919c1" -down_revision = "473a1a7ca408" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Add the new 'source' column to the 'credential' table - op.add_column( - "credential", - sa.Column( - "source", - sa.String(length=100), # Use String instead of Enum - nullable=True, # Initially allow NULL values - ), - ) - op.add_column( - "credential", - sa.Column( - "name", - sa.String(), - nullable=True, - ), - ) - - # Create a temporary table that maps each credential to a single connector source. - # This is needed because a credential can be associated with multiple connectors, - # but we want to assign a single source to each credential. - # We use DISTINCT ON to ensure we only get one row per credential_id. - op.execute( - """ - CREATE TEMPORARY TABLE temp_connector_credential AS - SELECT DISTINCT ON (cc.credential_id) - cc.credential_id, - c.source AS connector_source - FROM connector_credential_pair cc - JOIN connector c ON cc.connector_id = c.id - """ - ) - - # Update the 'source' column in the 'credential' table - op.execute( - """ - UPDATE credential cred - SET source = COALESCE( - (SELECT connector_source - FROM temp_connector_credential temp - WHERE cred.id = temp.credential_id), - 'NOT_APPLICABLE' - ) - """ - ) - # If no exception was raised, alter the column - op.alter_column("credential", "source", nullable=True) # TODO modify - # # ### end Alembic commands ### - - -def downgrade() -> None: - op.drop_column("credential", "source") - op.drop_column("credential", "name") diff --git a/backend/alembic/versions/50b683a8295c_add_additional_retrieval_controls_to_.py b/backend/alembic/versions/50b683a8295c_add_additional_retrieval_controls_to_.py deleted file mode 100644 index 7ad038750dd..00000000000 --- a/backend/alembic/versions/50b683a8295c_add_additional_retrieval_controls_to_.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Add additional retrieval controls to Persona - -Revision ID: 50b683a8295c -Revises: 7da0ae5ad583 -Create Date: 2023-11-27 17:23:29.668422 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "50b683a8295c" -down_revision = "7da0ae5ad583" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("persona", sa.Column("num_chunks", sa.Integer(), nullable=True)) - op.add_column( - "persona", - sa.Column("apply_llm_relevance_filter", sa.Boolean(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("persona", "apply_llm_relevance_filter") - op.drop_column("persona", "num_chunks") diff --git a/backend/alembic/versions/570282d33c49_track_danswerbot_explicitly.py b/backend/alembic/versions/570282d33c49_track_danswerbot_explicitly.py deleted file mode 100644 index f8c0b647240..00000000000 --- a/backend/alembic/versions/570282d33c49_track_danswerbot_explicitly.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Track Danswerbot Explicitly - -Revision ID: 570282d33c49 -Revises: 7547d982db8f -Create Date: 2024-05-04 17:49:28.568109 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "570282d33c49" -down_revision = "7547d982db8f" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_session", sa.Column("danswerbot_flow", sa.Boolean(), nullable=True) - ) - op.execute("UPDATE chat_session SET danswerbot_flow = one_shot") - op.alter_column("chat_session", "danswerbot_flow", nullable=False) - - -def downgrade() -> None: - op.drop_column("chat_session", "danswerbot_flow") diff --git a/backend/alembic/versions/57b53544726e_add_document_set_tables.py b/backend/alembic/versions/57b53544726e_add_document_set_tables.py deleted file mode 100644 index b8d37fac81d..00000000000 --- a/backend/alembic/versions/57b53544726e_add_document_set_tables.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Add document set tables - -Revision ID: 57b53544726e -Revises: 800f48024ae9 -Create Date: 2023-09-20 16:59:39.097177 - -""" -from alembic import op -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "57b53544726e" -down_revision = "800f48024ae9" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "document_set", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.String(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column("is_up_to_date", sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "document_set__connector_credential_pair", - sa.Column("document_set_id", sa.Integer(), nullable=False), - sa.Column("connector_credential_pair_id", sa.Integer(), nullable=False), - sa.Column("is_current", sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint( - ["connector_credential_pair_id"], - ["connector_credential_pair.id"], - ), - sa.ForeignKeyConstraint( - ["document_set_id"], - ["document_set.id"], - ), - sa.PrimaryKeyConstraint( - "document_set_id", "connector_credential_pair_id", "is_current" - ), - ) - - -def downgrade() -> None: - op.drop_table("document_set__connector_credential_pair") - op.drop_table("document_set") diff --git a/backend/alembic/versions/5809c0787398_add_chat_sessions.py b/backend/alembic/versions/5809c0787398_add_chat_sessions.py deleted file mode 100644 index 0f00ad3b2ae..00000000000 --- a/backend/alembic/versions/5809c0787398_add_chat_sessions.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Add Chat Sessions - -Revision ID: 5809c0787398 -Revises: d929f0c1c6af -Create Date: 2023-09-04 15:29:44.002164 - -""" -import fastapi_users_db_sqlalchemy -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "5809c0787398" -down_revision = "d929f0c1c6af" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "chat_session", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column("description", sa.Text(), nullable=False), - sa.Column("deleted", sa.Boolean(), nullable=False), - sa.Column( - "time_updated", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "chat_message", - sa.Column("chat_session_id", sa.Integer(), nullable=False), - sa.Column("message_number", sa.Integer(), nullable=False), - sa.Column("edit_number", sa.Integer(), nullable=False), - sa.Column("parent_edit_number", sa.Integer(), nullable=True), - sa.Column("latest", sa.Boolean(), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column( - "message_type", - sa.Enum( - "SYSTEM", - "USER", - "ASSISTANT", - "DANSWER", - name="messagetype", - native_enum=False, - ), - nullable=False, - ), - sa.Column( - "time_sent", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["chat_session_id"], - ["chat_session.id"], - ), - sa.PrimaryKeyConstraint("chat_session_id", "message_number", "edit_number"), - ) - - -def downgrade() -> None: - op.drop_table("chat_message") - op.drop_table("chat_session") diff --git a/backend/alembic/versions/5e84129c8be3_add_docs_indexed_column_to_index_.py b/backend/alembic/versions/5e84129c8be3_add_docs_indexed_column_to_index_.py deleted file mode 100644 index 08285c6cb6a..00000000000 --- a/backend/alembic/versions/5e84129c8be3_add_docs_indexed_column_to_index_.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add docs_indexed_column + time_started to index_attempt table - -Revision ID: 5e84129c8be3 -Revises: e6a4bbc13fe4 -Create Date: 2023-08-10 21:43:09.069523 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "5e84129c8be3" -down_revision = "e6a4bbc13fe4" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "index_attempt", - sa.Column("num_docs_indexed", sa.Integer()), - ) - op.add_column( - "index_attempt", - sa.Column( - "time_started", - sa.DateTime(timezone=True), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("index_attempt", "time_started") - op.drop_column("index_attempt", "num_docs_indexed") diff --git a/backend/alembic/versions/5f4b8568a221_add_removed_documents_to_index_attempt.py b/backend/alembic/versions/5f4b8568a221_add_removed_documents_to_index_attempt.py deleted file mode 100644 index 0721072add1..00000000000 --- a/backend/alembic/versions/5f4b8568a221_add_removed_documents_to_index_attempt.py +++ /dev/null @@ -1,27 +0,0 @@ -"""add removed documents to index_attempt - -Revision ID: 5f4b8568a221 -Revises: dbaa756c2ccf -Create Date: 2024-02-16 15:02:03.319907 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "5f4b8568a221" -down_revision = "8987770549c0" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "index_attempt", - sa.Column("docs_removed_from_index", sa.Integer()), - ) - op.execute("UPDATE index_attempt SET docs_removed_from_index = 0") - - -def downgrade() -> None: - op.drop_column("index_attempt", "docs_removed_from_index") diff --git a/backend/alembic/versions/5fc1f54cc252_hybrid_enum.py b/backend/alembic/versions/5fc1f54cc252_hybrid_enum.py deleted file mode 100644 index 63b1e7875a9..00000000000 --- a/backend/alembic/versions/5fc1f54cc252_hybrid_enum.py +++ /dev/null @@ -1,25 +0,0 @@ -"""hybrid-enum - -Revision ID: 5fc1f54cc252 -Revises: 1d6ad76d1f37 -Create Date: 2024-08-06 15:35:40.278485 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "5fc1f54cc252" -down_revision = "1d6ad76d1f37" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_column("persona", "search_type") - - -def downgrade() -> None: - op.add_column("persona", sa.Column("search_type", sa.String(), nullable=True)) - op.execute("UPDATE persona SET search_type = 'SEMANTIC'") - op.alter_column("persona", "search_type", nullable=False) diff --git a/backend/alembic/versions/643a84a42a33_add_user_configured_names_to_llmprovider.py b/backend/alembic/versions/643a84a42a33_add_user_configured_names_to_llmprovider.py deleted file mode 100644 index 5ccb6d85309..00000000000 --- a/backend/alembic/versions/643a84a42a33_add_user_configured_names_to_llmprovider.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Add user-configured names to LLMProvider - -Revision ID: 643a84a42a33 -Revises: 0a98909f2757 -Create Date: 2024-05-07 14:54:55.493100 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "643a84a42a33" -down_revision = "0a98909f2757" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("llm_provider", sa.Column("provider", sa.String(), nullable=True)) - # move "name" -> "provider" to match the new schema - op.execute("UPDATE llm_provider SET provider = name") - # pretty up display name - op.execute("UPDATE llm_provider SET name = 'OpenAI' WHERE name = 'openai'") - op.execute("UPDATE llm_provider SET name = 'Anthropic' WHERE name = 'anthropic'") - op.execute("UPDATE llm_provider SET name = 'Azure OpenAI' WHERE name = 'azure'") - op.execute("UPDATE llm_provider SET name = 'AWS Bedrock' WHERE name = 'bedrock'") - - # update personas to use the new provider names - op.execute( - "UPDATE persona SET llm_model_provider_override = 'OpenAI' WHERE llm_model_provider_override = 'openai'" - ) - op.execute( - "UPDATE persona SET llm_model_provider_override = 'Anthropic' WHERE llm_model_provider_override = 'anthropic'" - ) - op.execute( - "UPDATE persona SET llm_model_provider_override = 'Azure OpenAI' WHERE llm_model_provider_override = 'azure'" - ) - op.execute( - "UPDATE persona SET llm_model_provider_override = 'AWS Bedrock' WHERE llm_model_provider_override = 'bedrock'" - ) - - -def downgrade() -> None: - op.execute("UPDATE llm_provider SET name = provider") - op.drop_column("llm_provider", "provider") diff --git a/backend/alembic/versions/6d387b3196c2_basic_auth.py b/backend/alembic/versions/6d387b3196c2_basic_auth.py deleted file mode 100644 index 8e2ad195bd0..00000000000 --- a/backend/alembic/versions/6d387b3196c2_basic_auth.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Basic Auth - -Revision ID: 6d387b3196c2 -Revises: 47433d30de82 -Create Date: 2023-05-05 14:40:10.242502 - -""" -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "6d387b3196c2" -down_revision = "47433d30de82" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "user", - sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False), - sa.Column("email", sa.String(length=320), nullable=False), - sa.Column("hashed_password", sa.String(length=1024), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("is_superuser", sa.Boolean(), nullable=False), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.Column( - "role", - sa.Enum("BASIC", "ADMIN", name="userrole", native_enum=False), - default="BASIC", - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) - op.create_table( - "accesstoken", - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.Column("token", sa.String(length=43), nullable=False), - sa.Column( - "created_at", - fastapi_users_db_sqlalchemy.generics.TIMESTAMPAware(timezone=True), - nullable=False, - ), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="cascade"), - sa.PrimaryKeyConstraint("token"), - ) - op.create_index( - op.f("ix_accesstoken_created_at"), - "accesstoken", - ["created_at"], - unique=False, - ) - op.alter_column( - "index_attempt", - "time_created", - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False, - existing_server_default=sa.text("now()"), # type: ignore - ) - op.alter_column( - "index_attempt", - "time_updated", - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=False, - ) - - -def downgrade() -> None: - op.alter_column( - "index_attempt", - "time_updated", - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True, - ) - op.alter_column( - "index_attempt", - "time_created", - existing_type=postgresql.TIMESTAMP(timezone=True), - nullable=True, - existing_server_default=sa.text("now()"), # type: ignore - ) - op.drop_index(op.f("ix_accesstoken_created_at"), table_name="accesstoken") - op.drop_table("accesstoken") - op.drop_index(op.f("ix_user_email"), table_name="user") - op.drop_table("user") diff --git a/backend/alembic/versions/703313b75876_add_tokenratelimit_tables.py b/backend/alembic/versions/703313b75876_add_tokenratelimit_tables.py deleted file mode 100644 index ed1993efed3..00000000000 --- a/backend/alembic/versions/703313b75876_add_tokenratelimit_tables.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Add TokenRateLimit Tables - -Revision ID: 703313b75876 -Revises: fad14119fb92 -Create Date: 2024-04-15 01:36:02.952809 - -""" -import json -from typing import cast -from alembic import op -import sqlalchemy as sa -from danswer.dynamic_configs.factory import get_dynamic_config_store - -# revision identifiers, used by Alembic. -revision = "703313b75876" -down_revision = "fad14119fb92" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "token_rate_limit", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("enabled", sa.Boolean(), nullable=False), - sa.Column("token_budget", sa.Integer(), nullable=False), - sa.Column("period_hours", sa.Integer(), nullable=False), - sa.Column( - "scope", - sa.String(length=10), - nullable=False, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "token_rate_limit__user_group", - sa.Column("rate_limit_id", sa.Integer(), nullable=False), - sa.Column("user_group_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["rate_limit_id"], - ["token_rate_limit.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("rate_limit_id", "user_group_id"), - ) - - try: - settings_json = cast( - str, get_dynamic_config_store().load("token_budget_settings") - ) - settings = json.loads(settings_json) - - is_enabled = settings.get("enable_token_budget", False) - token_budget = settings.get("token_budget", -1) - period_hours = settings.get("period_hours", -1) - - if is_enabled and token_budget > 0 and period_hours > 0: - op.execute( - f"INSERT INTO token_rate_limit \ - (enabled, token_budget, period_hours, scope) VALUES \ - ({is_enabled}, {token_budget}, {period_hours}, 'GLOBAL')" - ) - - # Delete the dynamic config - get_dynamic_config_store().delete("token_budget_settings") - - except Exception: - # Ignore if the dynamic config is not found - pass - - -def downgrade() -> None: - op.drop_table("token_rate_limit__user_group") - op.drop_table("token_rate_limit") diff --git a/backend/alembic/versions/70f00c45c0f2_more_descriptive_filestore.py b/backend/alembic/versions/70f00c45c0f2_more_descriptive_filestore.py deleted file mode 100644 index 3748553c355..00000000000 --- a/backend/alembic/versions/70f00c45c0f2_more_descriptive_filestore.py +++ /dev/null @@ -1,68 +0,0 @@ -"""More Descriptive Filestore - -Revision ID: 70f00c45c0f2 -Revises: 3879338f8ba1 -Create Date: 2024-05-17 17:51:41.926893 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "70f00c45c0f2" -down_revision = "3879338f8ba1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("file_store", sa.Column("display_name", sa.String(), nullable=True)) - op.add_column( - "file_store", - sa.Column( - "file_origin", - sa.String(), - nullable=False, - server_default="connector", # Default to connector - ), - ) - op.add_column( - "file_store", - sa.Column( - "file_type", sa.String(), nullable=False, server_default="text/plain" - ), - ) - op.add_column( - "file_store", - sa.Column( - "file_metadata", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - - op.execute( - """ - UPDATE file_store - SET file_origin = CASE - WHEN file_name LIKE 'chat__%' THEN 'chat_upload' - ELSE 'connector' - END, - file_name = CASE - WHEN file_name LIKE 'chat__%' THEN SUBSTR(file_name, 7) - ELSE file_name - END, - file_type = CASE - WHEN file_name LIKE 'chat__%' THEN 'image/png' - ELSE 'text/plain' - END - """ - ) - - -def downgrade() -> None: - op.drop_column("file_store", "file_metadata") - op.drop_column("file_store", "file_type") - op.drop_column("file_store", "file_origin") - op.drop_column("file_store", "display_name") diff --git a/backend/alembic/versions/72bdc9929a46_permission_auto_sync_framework.py b/backend/alembic/versions/72bdc9929a46_permission_auto_sync_framework.py deleted file mode 100644 index 0774651cc4b..00000000000 --- a/backend/alembic/versions/72bdc9929a46_permission_auto_sync_framework.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Permission Auto Sync Framework - -Revision ID: 72bdc9929a46 -Revises: 475fcefe8826 -Create Date: 2024-04-14 21:15:28.659634 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "72bdc9929a46" -down_revision = "475fcefe8826" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "email_to_external_user_cache", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("external_user_id", sa.String(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=True), - sa.Column("user_email", sa.String(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "external_permission", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=True), - sa.Column("user_email", sa.String(), nullable=False), - sa.Column( - "source_type", - sa.String(), - nullable=False, - ), - sa.Column("external_permission_group", sa.String(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "permission_sync_run", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "source_type", - sa.String(), - nullable=False, - ), - sa.Column("update_type", sa.String(), nullable=False), - sa.Column("cc_pair_id", sa.Integer(), nullable=True), - sa.Column( - "status", - sa.String(), - nullable=False, - ), - sa.Column("error_msg", sa.Text(), nullable=True), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["cc_pair_id"], - ["connector_credential_pair.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("permission_sync_run") - op.drop_table("external_permission") - op.drop_table("email_to_external_user_cache") diff --git a/backend/alembic/versions/7477a5f5d728_added_model_defaults_for_users.py b/backend/alembic/versions/7477a5f5d728_added_model_defaults_for_users.py deleted file mode 100644 index 6efb9840526..00000000000 --- a/backend/alembic/versions/7477a5f5d728_added_model_defaults_for_users.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Added model defaults for users - -Revision ID: 7477a5f5d728 -Revises: 213fd978c6d8 -Create Date: 2024-08-04 19:00:04.512634 - -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "7477a5f5d728" -down_revision = "213fd978c6d8" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("user", sa.Column("default_model", sa.Text(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("user", "default_model") diff --git a/backend/alembic/versions/7547d982db8f_chat_folders.py b/backend/alembic/versions/7547d982db8f_chat_folders.py deleted file mode 100644 index fc70090fe38..00000000000 --- a/backend/alembic/versions/7547d982db8f_chat_folders.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Chat Folders - -Revision ID: 7547d982db8f -Revises: ef7da92f7213 -Create Date: 2024-05-02 15:18:56.573347 - -""" -from alembic import op -import sqlalchemy as sa -import fastapi_users_db_sqlalchemy - -# revision identifiers, used by Alembic. -revision = "7547d982db8f" -down_revision = "ef7da92f7213" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "chat_folder", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column("name", sa.String(), nullable=True), - sa.Column("display_priority", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.add_column("chat_session", sa.Column("folder_id", sa.Integer(), nullable=True)) - op.create_foreign_key( - "chat_session_chat_folder_fk", - "chat_session", - "chat_folder", - ["folder_id"], - ["id"], - ) - - -def downgrade() -> None: - op.drop_constraint( - "chat_session_chat_folder_fk", "chat_session", type_="foreignkey" - ) - op.drop_column("chat_session", "folder_id") - op.drop_table("chat_folder") diff --git a/backend/alembic/versions/767f1c2a00eb_count_chat_tokens.py b/backend/alembic/versions/767f1c2a00eb_count_chat_tokens.py deleted file mode 100644 index 7f587bd95e7..00000000000 --- a/backend/alembic/versions/767f1c2a00eb_count_chat_tokens.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Count Chat Tokens - -Revision ID: 767f1c2a00eb -Revises: dba7f71618f5 -Create Date: 2023-09-21 10:03:21.509899 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "767f1c2a00eb" -down_revision = "dba7f71618f5" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_message", sa.Column("token_count", sa.Integer(), nullable=False) - ) - - -def downgrade() -> None: - op.drop_column("chat_message", "token_count") diff --git a/backend/alembic/versions/76b60d407dfb_cc_pair_name_not_unique.py b/backend/alembic/versions/76b60d407dfb_cc_pair_name_not_unique.py deleted file mode 100644 index 1dfbb9365d8..00000000000 --- a/backend/alembic/versions/76b60d407dfb_cc_pair_name_not_unique.py +++ /dev/null @@ -1,36 +0,0 @@ -"""CC-Pair Name not Unique - -Revision ID: 76b60d407dfb -Revises: b156fa702355 -Create Date: 2023-12-22 21:42:10.018804 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "76b60d407dfb" -down_revision = "b156fa702355" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute("DELETE FROM connector_credential_pair WHERE name IS NULL") - op.drop_constraint( - "connector_credential_pair__name__key", - "connector_credential_pair", - type_="unique", - ) - op.alter_column( - "connector_credential_pair", "name", existing_type=sa.String(), nullable=False - ) - - -def downgrade() -> None: - op.create_unique_constraint( - "connector_credential_pair__name__key", "connector_credential_pair", ["name"] - ) - op.alter_column( - "connector_credential_pair", "name", existing_type=sa.String(), nullable=True - ) diff --git a/backend/alembic/versions/776b3bbe9092_remove_remaining_enums.py b/backend/alembic/versions/776b3bbe9092_remove_remaining_enums.py deleted file mode 100644 index c2ba10b3875..00000000000 --- a/backend/alembic/versions/776b3bbe9092_remove_remaining_enums.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Remove Remaining Enums - -Revision ID: 776b3bbe9092 -Revises: 4738e4b3bae1 -Create Date: 2024-03-22 21:34:27.629444 - -""" -from alembic import op -import sqlalchemy as sa - -from danswer.db.models import IndexModelStatus -from danswer.search.enums import RecencyBiasSetting -from danswer.search.enums import SearchType - -# revision identifiers, used by Alembic. -revision = "776b3bbe9092" -down_revision = "4738e4b3bae1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column( - "persona", - "search_type", - type_=sa.String, - existing_type=sa.Enum(SearchType, native_enum=False), - existing_nullable=False, - ) - op.alter_column( - "persona", - "recency_bias", - type_=sa.String, - existing_type=sa.Enum(RecencyBiasSetting, native_enum=False), - existing_nullable=False, - ) - - # Because the indexmodelstatus enum does not have a mapping to a string type - # we need this workaround instead of directly changing the type - op.add_column("embedding_model", sa.Column("temp_status", sa.String)) - op.execute("UPDATE embedding_model SET temp_status = status::text") - op.drop_column("embedding_model", "status") - op.alter_column("embedding_model", "temp_status", new_column_name="status") - - op.execute("DROP TYPE IF EXISTS searchtype") - op.execute("DROP TYPE IF EXISTS recencybiassetting") - op.execute("DROP TYPE IF EXISTS indexmodelstatus") - - -def downgrade() -> None: - op.alter_column( - "persona", - "search_type", - type_=sa.Enum(SearchType, native_enum=False), - existing_type=sa.String(length=50), - existing_nullable=False, - ) - op.alter_column( - "persona", - "recency_bias", - type_=sa.Enum(RecencyBiasSetting, native_enum=False), - existing_type=sa.String(length=50), - existing_nullable=False, - ) - op.alter_column( - "embedding_model", - "status", - type_=sa.Enum(IndexModelStatus, native_enum=False), - existing_type=sa.String(length=50), - existing_nullable=False, - ) diff --git a/backend/alembic/versions/77d07dffae64_forcibly_remove_more_enum_types_from_.py b/backend/alembic/versions/77d07dffae64_forcibly_remove_more_enum_types_from_.py deleted file mode 100644 index c953feb3133..00000000000 --- a/backend/alembic/versions/77d07dffae64_forcibly_remove_more_enum_types_from_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""forcibly remove more enum types from postgres - -Revision ID: 77d07dffae64 -Revises: d61e513bef0a -Create Date: 2023-11-01 12:33:01.999617 - -""" -from alembic import op -from sqlalchemy import String - - -# revision identifiers, used by Alembic. -revision = "77d07dffae64" -down_revision = "d61e513bef0a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # In a PR: - # https://github.com/danswer-ai/danswer/pull/397/files#diff-f05fb341f6373790b91852579631b64ca7645797a190837156a282b67e5b19c2 - # we directly changed some previous migrations. This caused some users to have native enums - # while others wouldn't. This has caused some issues when adding new fields to these enums. - # This migration manually changes the enum types to ensure that nobody uses native enums. - op.alter_column("query_event", "selected_search_flow", type_=String) - op.alter_column("query_event", "feedback", type_=String) - op.alter_column("document_retrieval_feedback", "feedback", type_=String) - op.execute("DROP TYPE IF EXISTS searchtype") - op.execute("DROP TYPE IF EXISTS qafeedbacktype") - op.execute("DROP TYPE IF EXISTS searchfeedbacktype") - - -def downgrade() -> None: - # We don't want Native Enums, do nothing - pass diff --git a/backend/alembic/versions/78dbe7e38469_task_tracking.py b/backend/alembic/versions/78dbe7e38469_task_tracking.py deleted file mode 100644 index d50aaac4ce3..00000000000 --- a/backend/alembic/versions/78dbe7e38469_task_tracking.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Task Tracking - -Revision ID: 78dbe7e38469 -Revises: 7ccea01261f6 -Create Date: 2023-10-15 23:40:50.593262 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "78dbe7e38469" -down_revision = "7ccea01261f6" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "task_queue_jobs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("task_id", sa.String(), nullable=False), - sa.Column("task_name", sa.String(), nullable=False), - sa.Column( - "status", - sa.Enum( - "PENDING", - "STARTED", - "SUCCESS", - "FAILURE", - name="taskstatus", - native_enum=False, - ), - nullable=False, - ), - sa.Column("start_time", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "register_time", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("task_queue_jobs") diff --git a/backend/alembic/versions/795b20b85b4b_add_llm_group_permissions_control.py b/backend/alembic/versions/795b20b85b4b_add_llm_group_permissions_control.py deleted file mode 100644 index 8b7fb9a2b8d..00000000000 --- a/backend/alembic/versions/795b20b85b4b_add_llm_group_permissions_control.py +++ /dev/null @@ -1,41 +0,0 @@ -"""add_llm_group_permissions_control - -Revision ID: 795b20b85b4b -Revises: 05c07bf07c00 -Create Date: 2024-07-19 11:54:35.701558 - -""" -from alembic import op -import sqlalchemy as sa - - -revision = "795b20b85b4b" -down_revision = "05c07bf07c00" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "llm_provider__user_group", - sa.Column("llm_provider_id", sa.Integer(), nullable=False), - sa.Column("user_group_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["llm_provider_id"], - ["llm_provider.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("llm_provider_id", "user_group_id"), - ) - op.add_column( - "llm_provider", - sa.Column("is_public", sa.Boolean(), nullable=False, server_default="true"), - ) - - -def downgrade() -> None: - op.drop_table("llm_provider__user_group") - op.drop_column("llm_provider", "is_public") diff --git a/backend/alembic/versions/79acd316403a_add_api_key_table.py b/backend/alembic/versions/79acd316403a_add_api_key_table.py deleted file mode 100644 index 3c220e04152..00000000000 --- a/backend/alembic/versions/79acd316403a_add_api_key_table.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Add api_key table - -Revision ID: 79acd316403a -Revises: 904e5138fffb -Create Date: 2024-01-11 17:56:37.934381 - -""" -from alembic import op -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "79acd316403a" -down_revision = "904e5138fffb" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "api_key", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("hashed_api_key", sa.String(), nullable=False), - sa.Column("api_key_display", sa.String(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.Column( - "owner_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("api_key_display"), - sa.UniqueConstraint("hashed_api_key"), - ) - - -def downgrade() -> None: - op.drop_table("api_key") diff --git a/backend/alembic/versions/7aea705850d5_added_slack_auto_filter.py b/backend/alembic/versions/7aea705850d5_added_slack_auto_filter.py deleted file mode 100644 index b41e18f856c..00000000000 --- a/backend/alembic/versions/7aea705850d5_added_slack_auto_filter.py +++ /dev/null @@ -1,35 +0,0 @@ -"""added slack_auto_filter - -Revision ID: 7aea705850d5 -Revises: 4505fd7302e1 -Create Date: 2024-07-10 11:01:23.581015 - -""" -from alembic import op -import sqlalchemy as sa - -revision = "7aea705850d5" -down_revision = "4505fd7302e1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "slack_bot_config", - sa.Column("enable_auto_filters", sa.Boolean(), nullable=True), - ) - op.execute( - "UPDATE slack_bot_config SET enable_auto_filters = FALSE WHERE enable_auto_filters IS NULL" - ) - op.alter_column( - "slack_bot_config", - "enable_auto_filters", - existing_type=sa.Boolean(), - nullable=False, - server_default=sa.false(), - ) - - -def downgrade() -> None: - op.drop_column("slack_bot_config", "enable_auto_filters") diff --git a/backend/alembic/versions/7ccea01261f6_store_chat_retrieval_docs.py b/backend/alembic/versions/7ccea01261f6_store_chat_retrieval_docs.py deleted file mode 100644 index 5cd8916d4f9..00000000000 --- a/backend/alembic/versions/7ccea01261f6_store_chat_retrieval_docs.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Store Chat Retrieval Docs - -Revision ID: 7ccea01261f6 -Revises: a570b80a5f20 -Create Date: 2023-10-15 10:39:23.317453 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "7ccea01261f6" -down_revision = "a570b80a5f20" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_message", - sa.Column( - "reference_docs", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("chat_message", "reference_docs") diff --git a/backend/alembic/versions/7da0ae5ad583_add_description_to_persona.py b/backend/alembic/versions/7da0ae5ad583_add_description_to_persona.py deleted file mode 100644 index 92715acc116..00000000000 --- a/backend/alembic/versions/7da0ae5ad583_add_description_to_persona.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Add description to persona - -Revision ID: 7da0ae5ad583 -Revises: e86866a9c78a -Create Date: 2023-11-27 00:16:19.959414 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "7da0ae5ad583" -down_revision = "e86866a9c78a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("persona", sa.Column("description", sa.String(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("persona", "description") diff --git a/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py b/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py deleted file mode 100644 index 372fe5ebb48..00000000000 --- a/backend/alembic/versions/7da543f5672f_add_slackbotconfig_table.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Add SlackBotConfig table - -Revision ID: 7da543f5672f -Revises: febe9eaa0644 -Create Date: 2023-09-24 16:34:17.526128 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "7da543f5672f" -down_revision = "febe9eaa0644" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "slack_bot_config", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("persona_id", sa.Integer(), nullable=True), - sa.Column( - "channel_config", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("slack_bot_config") diff --git a/backend/alembic/versions/7f726bad5367_slack_followup.py b/backend/alembic/versions/7f726bad5367_slack_followup.py deleted file mode 100644 index a060458a35b..00000000000 --- a/backend/alembic/versions/7f726bad5367_slack_followup.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Slack Followup - -Revision ID: 7f726bad5367 -Revises: 79acd316403a -Create Date: 2024-01-15 00:19:55.991224 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "7f726bad5367" -down_revision = "79acd316403a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_feedback", - sa.Column("required_followup", sa.Boolean(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("chat_feedback", "required_followup") diff --git a/backend/alembic/versions/7f99be1cb9f5_add_index_for_getting_documents_just_by_.py b/backend/alembic/versions/7f99be1cb9f5_add_index_for_getting_documents_just_by_.py deleted file mode 100644 index 26d19383fe0..00000000000 --- a/backend/alembic/versions/7f99be1cb9f5_add_index_for_getting_documents_just_by_.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Add index for getting documents just by connector id / credential id - -Revision ID: 7f99be1cb9f5 -Revises: 78dbe7e38469 -Create Date: 2023-10-15 22:48:15.487762 - -""" -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "7f99be1cb9f5" -down_revision = "78dbe7e38469" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_index( - op.f( - "ix_document_by_connector_credential_pair_pkey__connector_id__credential_id" - ), - "document_by_connector_credential_pair", - ["connector_id", "credential_id"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - op.f( - "ix_document_by_connector_credential_pair_pkey__connector_id__credential_id" - ), - table_name="document_by_connector_credential_pair", - ) diff --git a/backend/alembic/versions/800f48024ae9_add_id_to_connectorcredentialpair.py b/backend/alembic/versions/800f48024ae9_add_id_to_connectorcredentialpair.py deleted file mode 100644 index c5e8536e0df..00000000000 --- a/backend/alembic/versions/800f48024ae9_add_id_to_connectorcredentialpair.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Add ID to ConnectorCredentialPair - -Revision ID: 800f48024ae9 -Revises: 767f1c2a00eb -Create Date: 2023-09-19 16:13:42.299715 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.schema import Sequence, CreateSequence - -# revision identifiers, used by Alembic. -revision = "800f48024ae9" -down_revision = "767f1c2a00eb" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - sequence = Sequence("connector_credential_pair_id_seq") - op.execute(CreateSequence(sequence)) # type: ignore - op.add_column( - "connector_credential_pair", - sa.Column( - "id", sa.Integer(), nullable=True, server_default=sequence.next_value() - ), - ) - op.add_column( - "connector_credential_pair", - sa.Column("name", sa.String(), nullable=True), - ) - - # fill in IDs for existing rows - op.execute( - "UPDATE connector_credential_pair SET id = nextval('connector_credential_pair_id_seq') WHERE id IS NULL" - ) - op.alter_column("connector_credential_pair", "id", nullable=False) - - op.create_unique_constraint( - "connector_credential_pair__name__key", "connector_credential_pair", ["name"] - ) - op.create_unique_constraint( - "connector_credential_pair__id__key", "connector_credential_pair", ["id"] - ) - - -def downgrade() -> None: - op.drop_constraint( - "connector_credential_pair__name__key", - "connector_credential_pair", - type_="unique", - ) - op.drop_constraint( - "connector_credential_pair__id__key", - "connector_credential_pair", - type_="unique", - ) - op.drop_column("connector_credential_pair", "name") - op.drop_column("connector_credential_pair", "id") - op.execute("DROP SEQUENCE connector_credential_pair_id_seq") diff --git a/backend/alembic/versions/80696cf850ae_add_chat_session_to_query_event.py b/backend/alembic/versions/80696cf850ae_add_chat_session_to_query_event.py deleted file mode 100644 index 2a1b8e97809..00000000000 --- a/backend/alembic/versions/80696cf850ae_add_chat_session_to_query_event.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add chat session to query_event - -Revision ID: 80696cf850ae -Revises: 15326fcec57e -Create Date: 2023-11-26 02:38:35.008070 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "80696cf850ae" -down_revision = "15326fcec57e" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "query_event", - sa.Column("chat_session_id", sa.Integer(), nullable=True), - ) - op.create_foreign_key( - "fk_query_event_chat_session_id", - "query_event", - "chat_session", - ["chat_session_id"], - ["id"], - ) - - -def downgrade() -> None: - op.drop_constraint( - "fk_query_event_chat_session_id", "query_event", type_="foreignkey" - ) - op.drop_column("query_event", "chat_session_id") diff --git a/backend/alembic/versions/891cd83c87a8_add_is_visible_to_persona.py b/backend/alembic/versions/891cd83c87a8_add_is_visible_to_persona.py deleted file mode 100644 index 74ff50d4bed..00000000000 --- a/backend/alembic/versions/891cd83c87a8_add_is_visible_to_persona.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Add is_visible to Persona - -Revision ID: 891cd83c87a8 -Revises: 76b60d407dfb -Create Date: 2023-12-21 11:55:54.132279 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "891cd83c87a8" -down_revision = "76b60d407dfb" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "persona", - sa.Column("is_visible", sa.Boolean(), nullable=True), - ) - op.execute("UPDATE persona SET is_visible = true") - op.alter_column("persona", "is_visible", nullable=False) - - op.add_column( - "persona", - sa.Column("display_priority", sa.Integer(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("persona", "is_visible") - op.drop_column("persona", "display_priority") diff --git a/backend/alembic/versions/8987770549c0_add_full_exception_stack_trace.py b/backend/alembic/versions/8987770549c0_add_full_exception_stack_trace.py deleted file mode 100644 index ffb7ba9d87a..00000000000 --- a/backend/alembic/versions/8987770549c0_add_full_exception_stack_trace.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Add full exception stack trace - -Revision ID: 8987770549c0 -Revises: ec3ec2eabf7b -Create Date: 2024-02-10 19:31:28.339135 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "8987770549c0" -down_revision = "ec3ec2eabf7b" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "index_attempt", sa.Column("full_exception_trace", sa.Text(), nullable=True) - ) - - -def downgrade() -> None: - op.drop_column("index_attempt", "full_exception_trace") diff --git a/backend/alembic/versions/8a87bd6ec550_associate_index_attempts_with_ccpair.py b/backend/alembic/versions/8a87bd6ec550_associate_index_attempts_with_ccpair.py deleted file mode 100644 index 166c4b7ba18..00000000000 --- a/backend/alembic/versions/8a87bd6ec550_associate_index_attempts_with_ccpair.py +++ /dev/null @@ -1,107 +0,0 @@ -"""associate index attempts with ccpair - -Revision ID: 8a87bd6ec550 -Revises: 4ea2c93919c1 -Create Date: 2024-07-22 15:15:52.558451 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "8a87bd6ec550" -down_revision = "4ea2c93919c1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Add the new connector_credential_pair_id column - op.add_column( - "index_attempt", - sa.Column("connector_credential_pair_id", sa.Integer(), nullable=True), - ) - - # Create a foreign key constraint to the connector_credential_pair table - op.create_foreign_key( - "fk_index_attempt_connector_credential_pair_id", - "index_attempt", - "connector_credential_pair", - ["connector_credential_pair_id"], - ["id"], - ) - - # Populate the new connector_credential_pair_id column using existing connector_id and credential_id - op.execute( - """ - UPDATE index_attempt ia - SET connector_credential_pair_id = ( - SELECT id FROM connector_credential_pair ccp - WHERE - (ia.connector_id IS NULL OR ccp.connector_id = ia.connector_id) - AND (ia.credential_id IS NULL OR ccp.credential_id = ia.credential_id) - LIMIT 1 - ) - WHERE ia.connector_id IS NOT NULL OR ia.credential_id IS NOT NULL - """ - ) - - # For good measure - op.execute( - """ - DELETE FROM index_attempt - WHERE connector_credential_pair_id IS NULL - """ - ) - - # Make the new connector_credential_pair_id column non-nullable - op.alter_column("index_attempt", "connector_credential_pair_id", nullable=False) - - # Drop the old connector_id and credential_id columns - op.drop_column("index_attempt", "connector_id") - op.drop_column("index_attempt", "credential_id") - - # Update the index to use connector_credential_pair_id - op.create_index( - "ix_index_attempt_latest_for_connector_credential_pair", - "index_attempt", - ["connector_credential_pair_id", "time_created"], - ) - - -def downgrade() -> None: - # Add back the old connector_id and credential_id columns - op.add_column( - "index_attempt", sa.Column("connector_id", sa.Integer(), nullable=True) - ) - op.add_column( - "index_attempt", sa.Column("credential_id", sa.Integer(), nullable=True) - ) - - # Populate the old connector_id and credential_id columns using the connector_credential_pair_id - op.execute( - """ - UPDATE index_attempt ia - SET connector_id = ccp.connector_id, credential_id = ccp.credential_id - FROM connector_credential_pair ccp - WHERE ia.connector_credential_pair_id = ccp.id - """ - ) - - # Make the old connector_id and credential_id columns non-nullable - op.alter_column("index_attempt", "connector_id", nullable=False) - op.alter_column("index_attempt", "credential_id", nullable=False) - - # Drop the new connector_credential_pair_id column - op.drop_constraint( - "fk_index_attempt_connector_credential_pair_id", - "index_attempt", - type_="foreignkey", - ) - op.drop_column("index_attempt", "connector_credential_pair_id") - - op.create_index( - "ix_index_attempt_latest_for_connector_credential_pair", - "index_attempt", - ["connector_id", "credential_id", "time_created"], - ) diff --git a/backend/alembic/versions/8aabb57f3b49_restructure_document_indices.py b/backend/alembic/versions/8aabb57f3b49_restructure_document_indices.py deleted file mode 100644 index 9026b3f97a6..00000000000 --- a/backend/alembic/versions/8aabb57f3b49_restructure_document_indices.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Restructure Document Indices - -Revision ID: 8aabb57f3b49 -Revises: 5e84129c8be3 -Create Date: 2023-08-18 21:15:57.629515 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "8aabb57f3b49" -down_revision = "5e84129c8be3" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_table("chunk") - op.execute("DROP TYPE IF EXISTS documentstoretype") - - -def downgrade() -> None: - op.create_table( - "chunk", - sa.Column("id", sa.VARCHAR(), autoincrement=False, nullable=False), - sa.Column( - "document_store_type", - postgresql.ENUM("VECTOR", "KEYWORD", name="documentstoretype"), - autoincrement=False, - nullable=False, - ), - sa.Column("document_id", sa.VARCHAR(), autoincrement=False, nullable=False), - sa.ForeignKeyConstraint( - ["document_id"], ["document.id"], name="chunk_document_id_fkey" - ), - sa.PrimaryKeyConstraint("id", "document_store_type", name="chunk_pkey"), - ) diff --git a/backend/alembic/versions/8e26726b7683_chat_context_addition.py b/backend/alembic/versions/8e26726b7683_chat_context_addition.py deleted file mode 100644 index d4d764304b7..00000000000 --- a/backend/alembic/versions/8e26726b7683_chat_context_addition.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Chat Context Addition - -Revision ID: 8e26726b7683 -Revises: 5809c0787398 -Create Date: 2023-09-13 18:34:31.327944 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "8e26726b7683" -down_revision = "5809c0787398" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "persona", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("system_text", sa.Text(), nullable=True), - sa.Column("tools_text", sa.Text(), nullable=True), - sa.Column("hint_text", sa.Text(), nullable=True), - sa.Column("default_persona", sa.Boolean(), nullable=False), - sa.Column("deleted", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.add_column("chat_message", sa.Column("persona_id", sa.Integer(), nullable=True)) - op.create_foreign_key( - "fk_chat_message_persona_id", "chat_message", "persona", ["persona_id"], ["id"] - ) - - -def downgrade() -> None: - op.drop_constraint("fk_chat_message_persona_id", "chat_message", type_="foreignkey") - op.drop_column("chat_message", "persona_id") - op.drop_table("persona") diff --git a/backend/alembic/versions/904451035c9b_store_tool_details.py b/backend/alembic/versions/904451035c9b_store_tool_details.py deleted file mode 100644 index 46ee2447233..00000000000 --- a/backend/alembic/versions/904451035c9b_store_tool_details.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Store Tool Details - -Revision ID: 904451035c9b -Revises: 3b25685ff73c -Create Date: 2023-10-05 12:29:26.620000 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "904451035c9b" -down_revision = "3b25685ff73c" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "persona", - sa.Column("tools", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - ) - op.drop_column("persona", "tools_text") - - -def downgrade() -> None: - op.add_column( - "persona", - sa.Column("tools_text", sa.TEXT(), autoincrement=False, nullable=True), - ) - op.drop_column("persona", "tools") diff --git a/backend/alembic/versions/904e5138fffb_tags.py b/backend/alembic/versions/904e5138fffb_tags.py deleted file mode 100644 index 24588eef69b..00000000000 --- a/backend/alembic/versions/904e5138fffb_tags.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Tags - -Revision ID: 904e5138fffb -Revises: 891cd83c87a8 -Create Date: 2024-01-01 10:44:43.733974 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "904e5138fffb" -down_revision = "891cd83c87a8" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "tag", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("tag_key", sa.String(), nullable=False), - sa.Column("tag_value", sa.String(), nullable=False), - sa.Column("source", sa.String(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "tag_key", "tag_value", "source", name="_tag_key_value_source_uc" - ), - ) - op.create_table( - "document__tag", - sa.Column("document_id", sa.String(), nullable=False), - sa.Column("tag_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["document_id"], - ["document.id"], - ), - sa.ForeignKeyConstraint( - ["tag_id"], - ["tag.id"], - ), - sa.PrimaryKeyConstraint("document_id", "tag_id"), - ) - - op.add_column( - "search_doc", - sa.Column( - "doc_metadata", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - op.execute("UPDATE search_doc SET doc_metadata = '{}' WHERE doc_metadata IS NULL") - op.alter_column("search_doc", "doc_metadata", nullable=False) - - -def downgrade() -> None: - op.drop_table("document__tag") - op.drop_table("tag") - op.drop_column("search_doc", "doc_metadata") diff --git a/backend/alembic/versions/91fd3b470d1a_remove_documentsource_from_tag.py b/backend/alembic/versions/91fd3b470d1a_remove_documentsource_from_tag.py deleted file mode 100644 index dc8749b9a75..00000000000 --- a/backend/alembic/versions/91fd3b470d1a_remove_documentsource_from_tag.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Remove DocumentSource from Tag - -Revision ID: 91fd3b470d1a -Revises: 173cae5bba26 -Create Date: 2024-03-21 12:05:23.956734 - -""" -from alembic import op -import sqlalchemy as sa -from danswer.configs.constants import DocumentSource - -# revision identifiers, used by Alembic. -revision = "91fd3b470d1a" -down_revision = "173cae5bba26" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column( - "tag", - "source", - type_=sa.String(length=50), - existing_type=sa.Enum(DocumentSource, native_enum=False), - existing_nullable=False, - ) - - -def downgrade() -> None: - op.alter_column( - "tag", - "source", - type_=sa.Enum(DocumentSource, native_enum=False), - existing_type=sa.String(length=50), - existing_nullable=False, - ) diff --git a/backend/alembic/versions/91ffac7e65b3_add_expiry_time.py b/backend/alembic/versions/91ffac7e65b3_add_expiry_time.py deleted file mode 100644 index 7c029b3c9cf..00000000000 --- a/backend/alembic/versions/91ffac7e65b3_add_expiry_time.py +++ /dev/null @@ -1,26 +0,0 @@ -"""add expiry time - -Revision ID: 91ffac7e65b3 -Revises: bc9771dccadf -Create Date: 2024-06-24 09:39:56.462242 - -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "91ffac7e65b3" -down_revision = "795b20b85b4b" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "user", sa.Column("oidc_expiry", sa.DateTime(timezone=True), nullable=True) - ) - - -def downgrade() -> None: - op.drop_column("user", "oidc_expiry") diff --git a/backend/alembic/versions/9d97fecfab7f_added_retrieved_docs_to_query_event.py b/backend/alembic/versions/9d97fecfab7f_added_retrieved_docs_to_query_event.py deleted file mode 100644 index e91ff3bd1c9..00000000000 --- a/backend/alembic/versions/9d97fecfab7f_added_retrieved_docs_to_query_event.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Added retrieved docs to query event - -Revision ID: 9d97fecfab7f -Revises: ffc707a226b4 -Create Date: 2023-10-20 12:22:31.930449 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "9d97fecfab7f" -down_revision = "ffc707a226b4" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "query_event", - sa.Column( - "retrieved_document_ids", - postgresql.ARRAY(sa.String()), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("query_event", "retrieved_document_ids") diff --git a/backend/alembic/versions/a3bfd0d64902_add_chosen_assistants_to_user_table.py b/backend/alembic/versions/a3bfd0d64902_add_chosen_assistants_to_user_table.py deleted file mode 100644 index 89439adb680..00000000000 --- a/backend/alembic/versions/a3bfd0d64902_add_chosen_assistants_to_user_table.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add chosen_assistants to User table - -Revision ID: a3bfd0d64902 -Revises: ec85f2b3c544 -Create Date: 2024-05-26 17:22:24.834741 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "a3bfd0d64902" -down_revision = "ec85f2b3c544" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "user", - sa.Column("chosen_assistants", postgresql.ARRAY(sa.Integer()), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("user", "chosen_assistants") diff --git a/backend/alembic/versions/a570b80a5f20_usergroup_tables.py b/backend/alembic/versions/a570b80a5f20_usergroup_tables.py deleted file mode 100644 index 57827b31682..00000000000 --- a/backend/alembic/versions/a570b80a5f20_usergroup_tables.py +++ /dev/null @@ -1,67 +0,0 @@ -"""UserGroup tables - -Revision ID: a570b80a5f20 -Revises: 904451035c9b -Create Date: 2023-10-02 12:27:10.265725 - -""" -from alembic import op -import fastapi_users_db_sqlalchemy -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "a570b80a5f20" -down_revision = "904451035c9b" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "user_group", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("is_up_to_date", sa.Boolean(), nullable=False), - sa.Column("is_up_for_deletion", sa.Boolean(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "user__user_group", - sa.Column("user_group_id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("user_group_id", "user_id"), - ) - op.create_table( - "user_group__connector_credential_pair", - sa.Column("user_group_id", sa.Integer(), nullable=False), - sa.Column("cc_pair_id", sa.Integer(), nullable=False), - sa.Column("is_current", sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint( - ["cc_pair_id"], - ["connector_credential_pair.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("user_group_id", "cc_pair_id", "is_current"), - ) - - -def downgrade() -> None: - op.drop_table("user_group__connector_credential_pair") - op.drop_table("user__user_group") - op.drop_table("user_group") diff --git a/backend/alembic/versions/ae62505e3acc_add_saml_accounts.py b/backend/alembic/versions/ae62505e3acc_add_saml_accounts.py deleted file mode 100644 index e8bc816258b..00000000000 --- a/backend/alembic/versions/ae62505e3acc_add_saml_accounts.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Add SAML Accounts - -Revision ID: ae62505e3acc -Revises: 7da543f5672f -Create Date: 2023-09-26 16:19:30.933183 - -""" -import fastapi_users_db_sqlalchemy -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "ae62505e3acc" -down_revision = "7da543f5672f" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "saml", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.Column("encrypted_cookie", sa.Text(), nullable=False), - sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("encrypted_cookie"), - sa.UniqueConstraint("user_id"), - ) - - -def downgrade() -> None: - op.drop_table("saml") diff --git a/backend/alembic/versions/b082fec533f0_make_last_attempt_status_nullable.py b/backend/alembic/versions/b082fec533f0_make_last_attempt_status_nullable.py deleted file mode 100644 index a6938e365c6..00000000000 --- a/backend/alembic/versions/b082fec533f0_make_last_attempt_status_nullable.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Make 'last_attempt_status' nullable - -Revision ID: b082fec533f0 -Revises: df0c7ad8a076 -Create Date: 2023-08-06 12:05:47.087325 - -""" -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "b082fec533f0" -down_revision = "df0c7ad8a076" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column( - "connector_credential_pair", - "last_attempt_status", - existing_type=postgresql.ENUM( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="indexingstatus", - ), - nullable=True, - ) - - -def downgrade() -> None: - op.alter_column( - "connector_credential_pair", - "last_attempt_status", - existing_type=postgresql.ENUM( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="indexingstatus", - ), - nullable=False, - ) diff --git a/backend/alembic/versions/b156fa702355_chat_reworked.py b/backend/alembic/versions/b156fa702355_chat_reworked.py deleted file mode 100644 index c80ab6a0fb1..00000000000 --- a/backend/alembic/versions/b156fa702355_chat_reworked.py +++ /dev/null @@ -1,520 +0,0 @@ -"""Chat Reworked - -Revision ID: b156fa702355 -Revises: baf71f781b9e -Create Date: 2023-12-12 00:57:41.823371 - -""" -import fastapi_users_db_sqlalchemy -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql -from sqlalchemy.dialects.postgresql import ENUM -from danswer.configs.constants import DocumentSource - -# revision identifiers, used by Alembic. -revision = "b156fa702355" -down_revision = "baf71f781b9e" -branch_labels: None = None -depends_on: None = None - - -searchtype_enum = ENUM( - "KEYWORD", "SEMANTIC", "HYBRID", name="searchtype", create_type=True -) -recencybiassetting_enum = ENUM( - "FAVOR_RECENT", - "BASE_DECAY", - "NO_DECAY", - "AUTO", - name="recencybiassetting", - create_type=True, -) - - -def upgrade() -> None: - bind = op.get_bind() - searchtype_enum.create(bind) - recencybiassetting_enum.create(bind) - - # This is irrecoverable, whatever - op.execute("DELETE FROM chat_feedback") - op.execute("DELETE FROM document_retrieval_feedback") - - op.create_table( - "search_doc", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("document_id", sa.String(), nullable=False), - sa.Column("chunk_ind", sa.Integer(), nullable=False), - sa.Column("semantic_id", sa.String(), nullable=False), - sa.Column("link", sa.String(), nullable=True), - sa.Column("blurb", sa.String(), nullable=False), - sa.Column("boost", sa.Integer(), nullable=False), - sa.Column( - "source_type", - sa.Enum(DocumentSource, native=False), - nullable=False, - ), - sa.Column("hidden", sa.Boolean(), nullable=False), - sa.Column("score", sa.Float(), nullable=False), - sa.Column("match_highlights", postgresql.ARRAY(sa.String()), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("primary_owners", postgresql.ARRAY(sa.String()), nullable=True), - sa.Column("secondary_owners", postgresql.ARRAY(sa.String()), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "prompt", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.String(), nullable=False), - sa.Column("system_prompt", sa.Text(), nullable=False), - sa.Column("task_prompt", sa.Text(), nullable=False), - sa.Column("include_citations", sa.Boolean(), nullable=False), - sa.Column("datetime_aware", sa.Boolean(), nullable=False), - sa.Column("default_prompt", sa.Boolean(), nullable=False), - sa.Column("deleted", sa.Boolean(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "persona__prompt", - sa.Column("persona_id", sa.Integer(), nullable=False), - sa.Column("prompt_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.ForeignKeyConstraint( - ["prompt_id"], - ["prompt.id"], - ), - sa.PrimaryKeyConstraint("persona_id", "prompt_id"), - ) - - # Changes to persona first so chat_sessions can have the right persona - # The empty persona will be overwritten on server startup - op.add_column( - "persona", - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - ) - op.add_column( - "persona", - sa.Column( - "search_type", - searchtype_enum, - nullable=True, - ), - ) - op.execute("UPDATE persona SET search_type = 'HYBRID'") - op.alter_column("persona", "search_type", nullable=False) - op.add_column( - "persona", - sa.Column("llm_relevance_filter", sa.Boolean(), nullable=True), - ) - op.execute("UPDATE persona SET llm_relevance_filter = TRUE") - op.alter_column("persona", "llm_relevance_filter", nullable=False) - op.add_column( - "persona", - sa.Column("llm_filter_extraction", sa.Boolean(), nullable=True), - ) - op.execute("UPDATE persona SET llm_filter_extraction = TRUE") - op.alter_column("persona", "llm_filter_extraction", nullable=False) - op.add_column( - "persona", - sa.Column( - "recency_bias", - recencybiassetting_enum, - nullable=True, - ), - ) - op.execute("UPDATE persona SET recency_bias = 'BASE_DECAY'") - op.alter_column("persona", "recency_bias", nullable=False) - op.alter_column("persona", "description", existing_type=sa.VARCHAR(), nullable=True) - op.execute("UPDATE persona SET description = ''") - op.alter_column("persona", "description", nullable=False) - op.create_foreign_key("persona__user_fk", "persona", "user", ["user_id"], ["id"]) - op.drop_column("persona", "datetime_aware") - op.drop_column("persona", "tools") - op.drop_column("persona", "hint_text") - op.drop_column("persona", "apply_llm_relevance_filter") - op.drop_column("persona", "retrieval_enabled") - op.drop_column("persona", "system_text") - - # Need to create a persona row so fk can work - result = bind.execute(sa.text("SELECT 1 FROM persona WHERE id = 0")) - exists = result.fetchone() - if not exists: - op.execute( - sa.text( - """ - INSERT INTO persona ( - id, user_id, name, description, search_type, num_chunks, - llm_relevance_filter, llm_filter_extraction, recency_bias, - llm_model_version_override, default_persona, deleted - ) VALUES ( - 0, NULL, '', '', 'HYBRID', NULL, - TRUE, TRUE, 'BASE_DECAY', NULL, TRUE, FALSE - ) - """ - ) - ) - delete_statement = sa.text( - """ - DELETE FROM persona - WHERE name = 'Danswer' AND default_persona = TRUE AND id != 0 - """ - ) - - bind.execute(delete_statement) - - op.add_column( - "chat_feedback", - sa.Column("chat_message_id", sa.Integer(), nullable=False), - ) - op.drop_constraint( - "chat_feedback_chat_message_chat_session_id_chat_message_me_fkey", - "chat_feedback", - type_="foreignkey", - ) - op.drop_column("chat_feedback", "chat_message_edit_number") - op.drop_column("chat_feedback", "chat_message_chat_session_id") - op.drop_column("chat_feedback", "chat_message_message_number") - op.add_column( - "chat_message", - sa.Column( - "id", - sa.Integer(), - primary_key=True, - autoincrement=True, - nullable=False, - unique=True, - ), - ) - op.add_column( - "chat_message", - sa.Column("parent_message", sa.Integer(), nullable=True), - ) - op.add_column( - "chat_message", - sa.Column("latest_child_message", sa.Integer(), nullable=True), - ) - op.add_column( - "chat_message", sa.Column("rephrased_query", sa.Text(), nullable=True) - ) - op.add_column("chat_message", sa.Column("prompt_id", sa.Integer(), nullable=True)) - op.add_column( - "chat_message", - sa.Column("citations", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - ) - op.add_column("chat_message", sa.Column("error", sa.Text(), nullable=True)) - op.drop_constraint("fk_chat_message_persona_id", "chat_message", type_="foreignkey") - op.create_foreign_key( - "chat_message__prompt_fk", "chat_message", "prompt", ["prompt_id"], ["id"] - ) - op.drop_column("chat_message", "parent_edit_number") - op.drop_column("chat_message", "persona_id") - op.drop_column("chat_message", "reference_docs") - op.drop_column("chat_message", "edit_number") - op.drop_column("chat_message", "latest") - op.drop_column("chat_message", "message_number") - op.add_column("chat_session", sa.Column("one_shot", sa.Boolean(), nullable=True)) - op.execute("UPDATE chat_session SET one_shot = TRUE") - op.alter_column("chat_session", "one_shot", nullable=False) - op.alter_column( - "chat_session", - "persona_id", - existing_type=sa.INTEGER(), - nullable=True, - ) - op.execute("UPDATE chat_session SET persona_id = 0") - op.alter_column("chat_session", "persona_id", nullable=False) - op.add_column( - "document_retrieval_feedback", - sa.Column("chat_message_id", sa.Integer(), nullable=False), - ) - op.drop_constraint( - "document_retrieval_feedback_qa_event_id_fkey", - "document_retrieval_feedback", - type_="foreignkey", - ) - op.create_foreign_key( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ) - op.drop_column("document_retrieval_feedback", "qa_event_id") - - # Relation table must be created after the other tables are correct - op.create_table( - "chat_message__search_doc", - sa.Column("chat_message_id", sa.Integer(), nullable=False), - sa.Column("search_doc_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["chat_message_id"], - ["chat_message.id"], - ), - sa.ForeignKeyConstraint( - ["search_doc_id"], - ["search_doc.id"], - ), - sa.PrimaryKeyConstraint("chat_message_id", "search_doc_id"), - ) - - # Needs to be created after chat_message id field is added - op.create_foreign_key( - "chat_feedback__chat_message_fk", - "chat_feedback", - "chat_message", - ["chat_message_id"], - ["id"], - ) - - op.drop_table("query_event") - - -def downgrade() -> None: - op.drop_constraint( - "chat_feedback__chat_message_fk", "chat_feedback", type_="foreignkey" - ) - op.drop_constraint( - "document_retrieval_feedback__chat_message_fk", - "document_retrieval_feedback", - type_="foreignkey", - ) - op.drop_constraint("persona__user_fk", "persona", type_="foreignkey") - op.drop_constraint("chat_message__prompt_fk", "chat_message", type_="foreignkey") - op.drop_constraint( - "chat_message__search_doc_chat_message_id_fkey", - "chat_message__search_doc", - type_="foreignkey", - ) - op.add_column( - "persona", - sa.Column("system_text", sa.TEXT(), autoincrement=False, nullable=True), - ) - op.add_column( - "persona", - sa.Column( - "retrieval_enabled", - sa.BOOLEAN(), - autoincrement=False, - nullable=True, - ), - ) - op.execute("UPDATE persona SET retrieval_enabled = TRUE") - op.alter_column("persona", "retrieval_enabled", nullable=False) - op.add_column( - "persona", - sa.Column( - "apply_llm_relevance_filter", - sa.BOOLEAN(), - autoincrement=False, - nullable=True, - ), - ) - op.add_column( - "persona", - sa.Column("hint_text", sa.TEXT(), autoincrement=False, nullable=True), - ) - op.add_column( - "persona", - sa.Column( - "tools", - postgresql.JSONB(astext_type=sa.Text()), - autoincrement=False, - nullable=True, - ), - ) - op.add_column( - "persona", - sa.Column("datetime_aware", sa.BOOLEAN(), autoincrement=False, nullable=True), - ) - op.execute("UPDATE persona SET datetime_aware = TRUE") - op.alter_column("persona", "datetime_aware", nullable=False) - op.alter_column("persona", "description", existing_type=sa.VARCHAR(), nullable=True) - op.drop_column("persona", "recency_bias") - op.drop_column("persona", "llm_filter_extraction") - op.drop_column("persona", "llm_relevance_filter") - op.drop_column("persona", "search_type") - op.drop_column("persona", "user_id") - op.add_column( - "document_retrieval_feedback", - sa.Column("qa_event_id", sa.INTEGER(), autoincrement=False, nullable=False), - ) - op.drop_column("document_retrieval_feedback", "chat_message_id") - op.alter_column( - "chat_session", "persona_id", existing_type=sa.INTEGER(), nullable=True - ) - op.drop_column("chat_session", "one_shot") - op.add_column( - "chat_message", - sa.Column( - "message_number", - sa.INTEGER(), - autoincrement=False, - nullable=False, - primary_key=True, - ), - ) - op.add_column( - "chat_message", - sa.Column("latest", sa.BOOLEAN(), autoincrement=False, nullable=False), - ) - op.add_column( - "chat_message", - sa.Column( - "edit_number", - sa.INTEGER(), - autoincrement=False, - nullable=False, - primary_key=True, - ), - ) - op.add_column( - "chat_message", - sa.Column( - "reference_docs", - postgresql.JSONB(astext_type=sa.Text()), - autoincrement=False, - nullable=True, - ), - ) - op.add_column( - "chat_message", - sa.Column("persona_id", sa.INTEGER(), autoincrement=False, nullable=True), - ) - op.add_column( - "chat_message", - sa.Column( - "parent_edit_number", - sa.INTEGER(), - autoincrement=False, - nullable=True, - ), - ) - op.create_foreign_key( - "fk_chat_message_persona_id", - "chat_message", - "persona", - ["persona_id"], - ["id"], - ) - op.drop_column("chat_message", "error") - op.drop_column("chat_message", "citations") - op.drop_column("chat_message", "prompt_id") - op.drop_column("chat_message", "rephrased_query") - op.drop_column("chat_message", "latest_child_message") - op.drop_column("chat_message", "parent_message") - op.drop_column("chat_message", "id") - op.add_column( - "chat_feedback", - sa.Column( - "chat_message_message_number", - sa.INTEGER(), - autoincrement=False, - nullable=False, - ), - ) - op.add_column( - "chat_feedback", - sa.Column( - "chat_message_chat_session_id", - sa.INTEGER(), - autoincrement=False, - nullable=False, - primary_key=True, - ), - ) - op.add_column( - "chat_feedback", - sa.Column( - "chat_message_edit_number", - sa.INTEGER(), - autoincrement=False, - nullable=False, - ), - ) - op.drop_column("chat_feedback", "chat_message_id") - op.create_table( - "query_event", - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("query", sa.VARCHAR(), autoincrement=False, nullable=False), - sa.Column( - "selected_search_flow", - sa.VARCHAR(), - autoincrement=False, - nullable=True, - ), - sa.Column("llm_answer", sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column("feedback", sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column("user_id", sa.UUID(), autoincrement=False, nullable=True), - sa.Column( - "time_created", - postgresql.TIMESTAMP(timezone=True), - server_default=sa.text("now()"), - autoincrement=False, - nullable=False, - ), - sa.Column( - "retrieved_document_ids", - postgresql.ARRAY(sa.VARCHAR()), - autoincrement=False, - nullable=True, - ), - sa.Column("chat_session_id", sa.INTEGER(), autoincrement=False, nullable=True), - sa.ForeignKeyConstraint( - ["chat_session_id"], - ["chat_session.id"], - name="fk_query_event_chat_session_id", - ), - sa.ForeignKeyConstraint( - ["user_id"], ["user.id"], name="query_event_user_id_fkey" - ), - sa.PrimaryKeyConstraint("id", name="query_event_pkey"), - ) - op.drop_table("chat_message__search_doc") - op.drop_table("persona__prompt") - op.drop_table("prompt") - op.drop_table("search_doc") - op.create_unique_constraint( - "uq_chat_message_combination", - "chat_message", - ["chat_session_id", "message_number", "edit_number"], - ) - op.create_foreign_key( - "chat_feedback_chat_message_chat_session_id_chat_message_me_fkey", - "chat_feedback", - "chat_message", - [ - "chat_message_chat_session_id", - "chat_message_message_number", - "chat_message_edit_number", - ], - ["chat_session_id", "message_number", "edit_number"], - ) - op.create_foreign_key( - "document_retrieval_feedback_qa_event_id_fkey", - "document_retrieval_feedback", - "query_event", - ["qa_event_id"], - ["id"], - ) - - op.execute("DROP TYPE IF EXISTS searchtype") - op.execute("DROP TYPE IF EXISTS recencybiassetting") - op.execute("DROP TYPE IF EXISTS documentsource") diff --git a/backend/alembic/versions/b85f02ec1308_fix_file_type_migration.py b/backend/alembic/versions/b85f02ec1308_fix_file_type_migration.py deleted file mode 100644 index ac17670b703..00000000000 --- a/backend/alembic/versions/b85f02ec1308_fix_file_type_migration.py +++ /dev/null @@ -1,28 +0,0 @@ -"""fix-file-type-migration - -Revision ID: b85f02ec1308 -Revises: a3bfd0d64902 -Create Date: 2024-05-31 18:09:26.658164 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "b85f02ec1308" -down_revision = "a3bfd0d64902" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute( - """ - UPDATE file_store - SET file_origin = UPPER(file_origin) - """ - ) - - -def downgrade() -> None: - # Let's not break anything on purpose :) - pass diff --git a/backend/alembic/versions/b896bbd0d5a7_backfill_is_internet_data_to_false.py b/backend/alembic/versions/b896bbd0d5a7_backfill_is_internet_data_to_false.py deleted file mode 100644 index 9deac574b28..00000000000 --- a/backend/alembic/versions/b896bbd0d5a7_backfill_is_internet_data_to_false.py +++ /dev/null @@ -1,23 +0,0 @@ -"""backfill is_internet data to False - -Revision ID: b896bbd0d5a7 -Revises: 44f856ae2a4a -Create Date: 2024-07-16 15:21:05.718571 - -""" -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "b896bbd0d5a7" -down_revision = "44f856ae2a4a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute("UPDATE search_doc SET is_internet = FALSE WHERE is_internet IS NULL") - - -def downgrade() -> None: - pass diff --git a/backend/alembic/versions/baf71f781b9e_add_llm_model_version_override_to_.py b/backend/alembic/versions/baf71f781b9e_add_llm_model_version_override_to_.py deleted file mode 100644 index 6a1b6adcc77..00000000000 --- a/backend/alembic/versions/baf71f781b9e_add_llm_model_version_override_to_.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Add llm_model_version_override to Persona - -Revision ID: baf71f781b9e -Revises: 50b683a8295c -Create Date: 2023-12-06 21:56:50.286158 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "baf71f781b9e" -down_revision = "50b683a8295c" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "persona", - sa.Column("llm_model_version_override", sa.String(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("persona", "llm_model_version_override") diff --git a/backend/alembic/versions/bc9771dccadf_create_usage_reports_table.py b/backend/alembic/versions/bc9771dccadf_create_usage_reports_table.py deleted file mode 100644 index eab3253a0e0..00000000000 --- a/backend/alembic/versions/bc9771dccadf_create_usage_reports_table.py +++ /dev/null @@ -1,51 +0,0 @@ -"""create usage reports table - -Revision ID: bc9771dccadf -Revises: 0568ccf46a6b -Create Date: 2024-06-18 10:04:26.800282 - -""" -from alembic import op -import sqlalchemy as sa -import fastapi_users_db_sqlalchemy - -# revision identifiers, used by Alembic. -revision = "bc9771dccadf" -down_revision = "0568ccf46a6b" - -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "usage_reports", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("report_name", sa.String(), nullable=False), - sa.Column( - "requestor_user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("period_from", sa.DateTime(timezone=True), nullable=True), - sa.Column("period_to", sa.DateTime(timezone=True), nullable=True), - sa.ForeignKeyConstraint( - ["report_name"], - ["file_store.file_name"], - ), - sa.ForeignKeyConstraint( - ["requestor_user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("usage_reports") diff --git a/backend/alembic/versions/c18cdf4b497e_add_standard_answer_tables.py b/backend/alembic/versions/c18cdf4b497e_add_standard_answer_tables.py deleted file mode 100644 index 8d2de3bf1e1..00000000000 --- a/backend/alembic/versions/c18cdf4b497e_add_standard_answer_tables.py +++ /dev/null @@ -1,75 +0,0 @@ -"""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 = None -depends_on: None = 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") diff --git a/backend/alembic/versions/c5b692fa265c_add_index_attempt_errors_table.py b/backend/alembic/versions/c5b692fa265c_add_index_attempt_errors_table.py deleted file mode 100644 index e4808042aae..00000000000 --- a/backend/alembic/versions/c5b692fa265c_add_index_attempt_errors_table.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Add index_attempt_errors table - -Revision ID: c5b692fa265c -Revises: 4a951134c801 -Create Date: 2024-08-08 14:06:39.581972 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "c5b692fa265c" -down_revision = "4a951134c801" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "index_attempt_errors", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("index_attempt_id", sa.Integer(), nullable=True), - sa.Column("batch", sa.Integer(), nullable=True), - sa.Column( - "doc_summaries", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - ), - sa.Column("error_msg", sa.Text(), nullable=True), - sa.Column("traceback", sa.Text(), nullable=True), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["index_attempt_id"], - ["index_attempt.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "index_attempt_id", - "index_attempt_errors", - ["time_created"], - unique=False, - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index("index_attempt_id", table_name="index_attempt_errors") - op.drop_table("index_attempt_errors") - # ### end Alembic commands ### diff --git a/backend/alembic/versions/d5645c915d0e_remove_deletion_attempt_table.py b/backend/alembic/versions/d5645c915d0e_remove_deletion_attempt_table.py deleted file mode 100644 index 5ef63ed331c..00000000000 --- a/backend/alembic/versions/d5645c915d0e_remove_deletion_attempt_table.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Remove deletion_attempt table - -Revision ID: d5645c915d0e -Revises: 8e26726b7683 -Create Date: 2023-09-14 15:04:14.444909 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "d5645c915d0e" -down_revision = "8e26726b7683" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_table("deletion_attempt") - - # Remove the DeletionStatus enum - op.execute("DROP TYPE IF EXISTS deletionstatus;") - - -def downgrade() -> None: - op.create_table( - "deletion_attempt", - sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), - sa.Column("connector_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column("credential_id", sa.INTEGER(), autoincrement=False, nullable=False), - sa.Column( - "status", - postgresql.ENUM( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="deletionstatus", - ), - autoincrement=False, - nullable=False, - ), - sa.Column( - "num_docs_deleted", - sa.INTEGER(), - autoincrement=False, - nullable=False, - ), - sa.Column("error_msg", sa.VARCHAR(), autoincrement=False, nullable=True), - sa.Column( - "time_created", - postgresql.TIMESTAMP(timezone=True), - server_default=sa.text("now()"), - autoincrement=False, - nullable=False, - ), - sa.Column( - "time_updated", - postgresql.TIMESTAMP(timezone=True), - server_default=sa.text("now()"), - autoincrement=False, - nullable=False, - ), - sa.ForeignKeyConstraint( - ["connector_id"], - ["connector.id"], - name="deletion_attempt_connector_id_fkey", - ), - sa.ForeignKeyConstraint( - ["credential_id"], - ["credential.id"], - name="deletion_attempt_credential_id_fkey", - ), - sa.PrimaryKeyConstraint("id", name="deletion_attempt_pkey"), - ) diff --git a/backend/alembic/versions/d61e513bef0a_add_total_docs_for_index_attempt.py b/backend/alembic/versions/d61e513bef0a_add_total_docs_for_index_attempt.py deleted file mode 100644 index 2870b869647..00000000000 --- a/backend/alembic/versions/d61e513bef0a_add_total_docs_for_index_attempt.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Add Total Docs for Index Attempt - -Revision ID: d61e513bef0a -Revises: 46625e4745d4 -Create Date: 2023-10-27 23:02:43.369964 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "d61e513bef0a" -down_revision = "46625e4745d4" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "index_attempt", - sa.Column("new_docs_indexed", sa.Integer(), nullable=True), - ) - op.alter_column( - "index_attempt", "num_docs_indexed", new_column_name="total_docs_indexed" - ) - - -def downgrade() -> None: - op.alter_column( - "index_attempt", "total_docs_indexed", new_column_name="num_docs_indexed" - ) - op.drop_column("index_attempt", "new_docs_indexed") diff --git a/backend/alembic/versions/d7111c1238cd_remove_document_ids.py b/backend/alembic/versions/d7111c1238cd_remove_document_ids.py deleted file mode 100644 index 4b40755f177..00000000000 --- a/backend/alembic/versions/d7111c1238cd_remove_document_ids.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Remove Document IDs - -Revision ID: d7111c1238cd -Revises: 465f78d9b7f9 -Create Date: 2023-07-29 15:06:25.126169 - -""" -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "d7111c1238cd" -down_revision = "465f78d9b7f9" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_column("index_attempt", "document_ids") - - -def downgrade() -> None: - op.add_column( - "index_attempt", - sa.Column( - "document_ids", - postgresql.ARRAY(sa.VARCHAR()), - autoincrement=False, - nullable=True, - ), - ) diff --git a/backend/alembic/versions/d716b0791ddd_combined_slack_id_fields.py b/backend/alembic/versions/d716b0791ddd_combined_slack_id_fields.py deleted file mode 100644 index 6510d8b39da..00000000000 --- a/backend/alembic/versions/d716b0791ddd_combined_slack_id_fields.py +++ /dev/null @@ -1,45 +0,0 @@ -"""combined slack id fields - -Revision ID: d716b0791ddd -Revises: 7aea705850d5 -Create Date: 2024-07-10 17:57:45.630550 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "d716b0791ddd" -down_revision = "7aea705850d5" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute( - """ - UPDATE slack_bot_config - SET channel_config = jsonb_set( - channel_config, - '{respond_member_group_list}', - coalesce(channel_config->'respond_team_member_list', '[]'::jsonb) || - coalesce(channel_config->'respond_slack_group_list', '[]'::jsonb) - ) - 'respond_team_member_list' - 'respond_slack_group_list' - """ - ) - - -def downgrade() -> None: - op.execute( - """ - UPDATE slack_bot_config - SET channel_config = jsonb_set( - jsonb_set( - channel_config - 'respond_member_group_list', - '{respond_team_member_list}', - '[]'::jsonb - ), - '{respond_slack_group_list}', - '[]'::jsonb - ) - """ - ) diff --git a/backend/alembic/versions/d929f0c1c6af_feedback_feature.py b/backend/alembic/versions/d929f0c1c6af_feedback_feature.py deleted file mode 100644 index 247bce1bc86..00000000000 --- a/backend/alembic/versions/d929f0c1c6af_feedback_feature.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Feedback Feature - -Revision ID: d929f0c1c6af -Revises: 8aabb57f3b49 -Create Date: 2023-08-27 13:03:54.274987 - -""" -import fastapi_users_db_sqlalchemy -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "d929f0c1c6af" -down_revision = "8aabb57f3b49" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "query_event", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("query", sa.String(), nullable=False), - sa.Column( - "selected_search_flow", - sa.Enum("KEYWORD", "SEMANTIC", name="searchtype", native_enum=False), - nullable=True, - ), - sa.Column("llm_answer", sa.String(), nullable=True), - sa.Column( - "feedback", - sa.Enum("LIKE", "DISLIKE", name="qafeedbacktype", native_enum=False), - nullable=True, - ), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "document_retrieval_feedback", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("qa_event_id", sa.Integer(), nullable=False), - sa.Column("document_id", sa.String(), nullable=False), - sa.Column("document_rank", sa.Integer(), nullable=False), - sa.Column("clicked", sa.Boolean(), nullable=False), - sa.Column( - "feedback", - sa.Enum( - "ENDORSE", - "REJECT", - "HIDE", - "UNHIDE", - name="searchfeedbacktype", - native_enum=False, - ), - nullable=True, - ), - sa.ForeignKeyConstraint( - ["document_id"], - ["document.id"], - ), - sa.ForeignKeyConstraint( - ["qa_event_id"], - ["query_event.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.add_column("document", sa.Column("boost", sa.Integer(), nullable=False)) - op.add_column("document", sa.Column("hidden", sa.Boolean(), nullable=False)) - op.add_column("document", sa.Column("semantic_id", sa.String(), nullable=False)) - op.add_column("document", sa.Column("link", sa.String(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("document", "link") - op.drop_column("document", "semantic_id") - op.drop_column("document", "hidden") - op.drop_column("document", "boost") - op.drop_table("document_retrieval_feedback") - op.drop_table("query_event") diff --git a/backend/alembic/versions/d9ec13955951_remove__dim_suffix_from_model_name.py b/backend/alembic/versions/d9ec13955951_remove__dim_suffix_from_model_name.py deleted file mode 100644 index 0e84d5fe85a..00000000000 --- a/backend/alembic/versions/d9ec13955951_remove__dim_suffix_from_model_name.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Remove _alt suffix from model_name - -Revision ID: d9ec13955951 -Revises: da4c21c69164 -Create Date: 2024-08-20 16:31:32.955686 - -""" - -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "d9ec13955951" -down_revision = "da4c21c69164" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.execute( - """ - UPDATE embedding_model - SET model_name = regexp_replace(model_name, '__danswer_alt_index$', '') - WHERE model_name LIKE '%__danswer_alt_index' - """ - ) - - -def downgrade() -> None: - # We can't reliably add the __danswer_alt_index suffix back, so we'll leave this empty - pass diff --git a/backend/alembic/versions/da4c21c69164_chosen_assistants_changed_to_jsonb.py b/backend/alembic/versions/da4c21c69164_chosen_assistants_changed_to_jsonb.py deleted file mode 100644 index 95b53cbeb41..00000000000 --- a/backend/alembic/versions/da4c21c69164_chosen_assistants_changed_to_jsonb.py +++ /dev/null @@ -1,65 +0,0 @@ -"""chosen_assistants changed to jsonb - -Revision ID: da4c21c69164 -Revises: c5b692fa265c -Create Date: 2024-08-18 19:06:47.291491 - -""" -import json -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "da4c21c69164" -down_revision = "c5b692fa265c" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - conn = op.get_bind() - existing_ids_and_chosen_assistants = conn.execute( - sa.text("select id, chosen_assistants from public.user") - ) - op.drop_column( - "user", - "chosen_assistants", - ) - op.add_column( - "user", - sa.Column( - "chosen_assistants", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - for id, chosen_assistants in existing_ids_and_chosen_assistants: - conn.execute( - sa.text( - "update public.user set chosen_assistants = :chosen_assistants where id = :id" - ), - {"chosen_assistants": json.dumps(chosen_assistants), "id": id}, - ) - - -def downgrade() -> None: - conn = op.get_bind() - existing_ids_and_chosen_assistants = conn.execute( - sa.text("select id, chosen_assistants from public.user") - ) - op.drop_column( - "user", - "chosen_assistants", - ) - op.add_column( - "user", - sa.Column("chosen_assistants", postgresql.ARRAY(sa.Integer()), nullable=True), - ) - for id, chosen_assistants in existing_ids_and_chosen_assistants: - conn.execute( - sa.text( - "update public.user set chosen_assistants = :chosen_assistants where id = :id" - ), - {"chosen_assistants": chosen_assistants, "id": id}, - ) diff --git a/backend/alembic/versions/dba7f71618f5_danswer_custom_tool_flow.py b/backend/alembic/versions/dba7f71618f5_danswer_custom_tool_flow.py deleted file mode 100644 index 7512038cdd3..00000000000 --- a/backend/alembic/versions/dba7f71618f5_danswer_custom_tool_flow.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Danswer Custom Tool Flow - -Revision ID: dba7f71618f5 -Revises: d5645c915d0e -Create Date: 2023-09-18 15:18:37.370972 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "dba7f71618f5" -down_revision = "d5645c915d0e" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "persona", - sa.Column("retrieval_enabled", sa.Boolean(), nullable=True), - ) - op.execute("UPDATE persona SET retrieval_enabled = true") - op.alter_column("persona", "retrieval_enabled", nullable=False) - - -def downgrade() -> None: - op.drop_column("persona", "retrieval_enabled") diff --git a/backend/alembic/versions/dbaa756c2ccf_embedding_models.py b/backend/alembic/versions/dbaa756c2ccf_embedding_models.py deleted file mode 100644 index 6274b3e1334..00000000000 --- a/backend/alembic/versions/dbaa756c2ccf_embedding_models.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Embedding Models - -Revision ID: dbaa756c2ccf -Revises: 7f726bad5367 -Create Date: 2024-01-25 17:12:31.813160 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy import table, column, String, Integer, Boolean - -from danswer.db.search_settings import ( - get_new_default_embedding_model, - get_old_default_embedding_model, - user_has_overridden_embedding_model, -) -from danswer.db.models import IndexModelStatus - -# revision identifiers, used by Alembic. -revision = "dbaa756c2ccf" -down_revision = "7f726bad5367" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "embedding_model", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("model_name", sa.String(), nullable=False), - sa.Column("model_dim", sa.Integer(), nullable=False), - sa.Column("normalize", sa.Boolean(), nullable=False), - sa.Column("query_prefix", sa.String(), nullable=False), - sa.Column("passage_prefix", sa.String(), nullable=False), - sa.Column("index_name", sa.String(), nullable=False), - sa.Column( - "status", - sa.Enum(IndexModelStatus, native=False), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - # since all index attempts must be associated with an embedding model, - # need to put something in here to avoid nulls. On server startup, - # this value will be overriden - EmbeddingModel = table( - "embedding_model", - column("id", Integer), - column("model_name", String), - column("model_dim", Integer), - column("normalize", Boolean), - column("query_prefix", String), - column("passage_prefix", String), - column("index_name", String), - column( - "status", sa.Enum(IndexModelStatus, name="indexmodelstatus", native=False) - ), - ) - # insert an embedding model row that corresponds to the embedding model - # the user selected via env variables before this change. This is needed since - # all index_attempts must be associated with an embedding model, so without this - # we will run into violations of non-null contraints - old_embedding_model = get_old_default_embedding_model() - op.bulk_insert( - EmbeddingModel, - [ - { - "model_name": old_embedding_model.model_name, - "model_dim": old_embedding_model.model_dim, - "normalize": old_embedding_model.normalize, - "query_prefix": old_embedding_model.query_prefix, - "passage_prefix": old_embedding_model.passage_prefix, - "index_name": old_embedding_model.index_name, - "status": IndexModelStatus.PRESENT, - } - ], - ) - # if the user has not overridden the default embedding model via env variables, - # insert the new default model into the database to auto-upgrade them - if not user_has_overridden_embedding_model(): - new_embedding_model = get_new_default_embedding_model() - op.bulk_insert( - EmbeddingModel, - [ - { - "model_name": new_embedding_model.model_name, - "model_dim": new_embedding_model.model_dim, - "normalize": new_embedding_model.normalize, - "query_prefix": new_embedding_model.query_prefix, - "passage_prefix": new_embedding_model.passage_prefix, - "index_name": new_embedding_model.index_name, - "status": IndexModelStatus.FUTURE, - } - ], - ) - - op.add_column( - "index_attempt", - sa.Column("embedding_model_id", sa.Integer(), nullable=True), - ) - op.execute( - "UPDATE index_attempt SET embedding_model_id=1 WHERE embedding_model_id IS NULL" - ) - op.alter_column( - "index_attempt", - "embedding_model_id", - existing_type=sa.Integer(), - nullable=False, - ) - op.create_foreign_key( - "index_attempt__embedding_model_fk", - "index_attempt", - "embedding_model", - ["embedding_model_id"], - ["id"], - ) - op.create_index( - "ix_embedding_model_present_unique", - "embedding_model", - ["status"], - unique=True, - postgresql_where=sa.text("status = 'PRESENT'"), - ) - op.create_index( - "ix_embedding_model_future_unique", - "embedding_model", - ["status"], - unique=True, - postgresql_where=sa.text("status = 'FUTURE'"), - ) - - -def downgrade() -> None: - op.drop_constraint( - "index_attempt__embedding_model_fk", "index_attempt", type_="foreignkey" - ) - op.drop_column("index_attempt", "embedding_model_id") - op.drop_table("embedding_model") - op.execute("DROP TYPE IF EXISTS indexmodelstatus;") diff --git a/backend/alembic/versions/df0c7ad8a076_added_deletion_attempt_table.py b/backend/alembic/versions/df0c7ad8a076_added_deletion_attempt_table.py deleted file mode 100644 index 4e3d8ce507b..00000000000 --- a/backend/alembic/versions/df0c7ad8a076_added_deletion_attempt_table.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Added deletion_attempt table - -Revision ID: df0c7ad8a076 -Revises: d7111c1238cd -Create Date: 2023-08-05 13:35:39.609619 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "df0c7ad8a076" -down_revision = "d7111c1238cd" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "document", - sa.Column("id", sa.String(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "chunk", - sa.Column("id", sa.String(), nullable=False), - sa.Column( - "document_store_type", - sa.Enum( - "VECTOR", - "KEYWORD", - name="documentstoretype", - native_enum=False, - ), - nullable=False, - ), - sa.Column("document_id", sa.String(), nullable=False), - sa.ForeignKeyConstraint( - ["document_id"], - ["document.id"], - ), - sa.PrimaryKeyConstraint("id", "document_store_type"), - ) - op.create_table( - "deletion_attempt", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("connector_id", sa.Integer(), nullable=False), - sa.Column("credential_id", sa.Integer(), nullable=False), - sa.Column( - "status", - sa.Enum( - "NOT_STARTED", - "IN_PROGRESS", - "SUCCESS", - "FAILED", - name="deletionstatus", - native_enum=False, - ), - nullable=False, - ), - sa.Column("num_docs_deleted", sa.Integer(), nullable=False), - sa.Column("error_msg", sa.String(), nullable=True), - sa.Column( - "time_created", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "time_updated", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["connector_id"], - ["connector.id"], - ), - sa.ForeignKeyConstraint( - ["credential_id"], - ["credential.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "document_by_connector_credential_pair", - sa.Column("id", sa.String(), nullable=False), - sa.Column("connector_id", sa.Integer(), nullable=False), - sa.Column("credential_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["connector_id"], - ["connector.id"], - ), - sa.ForeignKeyConstraint( - ["credential_id"], - ["credential.id"], - ), - sa.ForeignKeyConstraint( - ["id"], - ["document.id"], - ), - sa.PrimaryKeyConstraint("id", "connector_id", "credential_id"), - ) - - -def downgrade() -> None: - op.drop_table("document_by_connector_credential_pair") - op.drop_table("deletion_attempt") - op.drop_table("chunk") - op.drop_table("document") diff --git a/backend/alembic/versions/e0a68a81d434_add_chat_feedback.py b/backend/alembic/versions/e0a68a81d434_add_chat_feedback.py deleted file mode 100644 index d36bb3f34b1..00000000000 --- a/backend/alembic/versions/e0a68a81d434_add_chat_feedback.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Add Chat Feedback - -Revision ID: e0a68a81d434 -Revises: ae62505e3acc -Create Date: 2023-10-04 20:22:33.380286 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "e0a68a81d434" -down_revision = "ae62505e3acc" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "chat_feedback", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("chat_message_chat_session_id", sa.Integer(), nullable=False), - sa.Column("chat_message_message_number", sa.Integer(), nullable=False), - sa.Column("chat_message_edit_number", sa.Integer(), nullable=False), - sa.Column("is_positive", sa.Boolean(), nullable=True), - sa.Column("feedback_text", sa.Text(), nullable=True), - sa.ForeignKeyConstraint( - [ - "chat_message_chat_session_id", - "chat_message_message_number", - "chat_message_edit_number", - ], - [ - "chat_message.chat_session_id", - "chat_message.message_number", - "chat_message.edit_number", - ], - ), - sa.PrimaryKeyConstraint("id"), - ) - - -def downgrade() -> None: - op.drop_table("chat_feedback") diff --git a/backend/alembic/versions/e1392f05e840_added_input_prompts.py b/backend/alembic/versions/e1392f05e840_added_input_prompts.py deleted file mode 100644 index dd358220f7e..00000000000 --- a/backend/alembic/versions/e1392f05e840_added_input_prompts.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Added input prompts - -Revision ID: e1392f05e840 -Revises: 08a1eda20fe1 -Create Date: 2024-07-13 19:09:22.556224 - -""" - -import fastapi_users_db_sqlalchemy - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "e1392f05e840" -down_revision = "08a1eda20fe1" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "inputprompt", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("prompt", sa.String(), nullable=False), - sa.Column("content", sa.String(), nullable=False), - sa.Column("active", sa.Boolean(), nullable=False), - sa.Column("is_public", sa.Boolean(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=True, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "inputprompt__user", - sa.Column("input_prompt_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["input_prompt_id"], - ["inputprompt.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["inputprompt.id"], - ), - sa.PrimaryKeyConstraint("input_prompt_id", "user_id"), - ) - - -def downgrade() -> None: - op.drop_table("inputprompt__user") - op.drop_table("inputprompt") diff --git a/backend/alembic/versions/e209dc5a8156_added_prune_frequency.py b/backend/alembic/versions/e209dc5a8156_added_prune_frequency.py deleted file mode 100644 index 0d5c250ebb6..00000000000 --- a/backend/alembic/versions/e209dc5a8156_added_prune_frequency.py +++ /dev/null @@ -1,22 +0,0 @@ -"""added-prune-frequency - -Revision ID: e209dc5a8156 -Revises: 48d14957fe80 -Create Date: 2024-06-16 16:02:35.273231 - -""" -from alembic import op -import sqlalchemy as sa - -revision = "e209dc5a8156" -down_revision = "48d14957fe80" -branch_labels = None # type: ignore -depends_on = None # type: ignore - - -def upgrade() -> None: - op.add_column("connector", sa.Column("prune_freq", sa.Integer(), nullable=True)) - - -def downgrade() -> None: - op.drop_column("connector", "prune_freq") diff --git a/backend/alembic/versions/e50154680a5c_no_source_enum.py b/backend/alembic/versions/e50154680a5c_no_source_enum.py deleted file mode 100644 index 8a7ccc751bf..00000000000 --- a/backend/alembic/versions/e50154680a5c_no_source_enum.py +++ /dev/null @@ -1,38 +0,0 @@ -"""No Source Enum - -Revision ID: e50154680a5c -Revises: fcd135795f21 -Create Date: 2024-03-14 18:06:08.523106 - -""" -from alembic import op -import sqlalchemy as sa - -from danswer.configs.constants import DocumentSource - -# revision identifiers, used by Alembic. -revision = "e50154680a5c" -down_revision = "fcd135795f21" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.alter_column( - "search_doc", - "source_type", - type_=sa.String(length=50), - existing_type=sa.Enum(DocumentSource, native_enum=False), - existing_nullable=False, - ) - op.execute("DROP TYPE IF EXISTS documentsource") - - -def downgrade() -> None: - op.alter_column( - "search_doc", - "source_type", - type_=sa.Enum(DocumentSource, native_enum=False), - existing_type=sa.String(length=50), - existing_nullable=False, - ) diff --git a/backend/alembic/versions/e6a4bbc13fe4_add_index_for_retrieving_latest_index_.py b/backend/alembic/versions/e6a4bbc13fe4_add_index_for_retrieving_latest_index_.py deleted file mode 100644 index a95a108972c..00000000000 --- a/backend/alembic/versions/e6a4bbc13fe4_add_index_for_retrieving_latest_index_.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Add index for retrieving latest index_attempt - -Revision ID: e6a4bbc13fe4 -Revises: b082fec533f0 -Create Date: 2023-08-10 12:37:23.335471 - -""" -from alembic import op - - -# revision identifiers, used by Alembic. -revision = "e6a4bbc13fe4" -down_revision = "b082fec533f0" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_index( - op.f("ix_index_attempt_latest_for_connector_credential_pair"), - "index_attempt", - ["connector_id", "credential_id", "time_created"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index( - op.f("ix_index_attempt_latest_for_connector_credential_pair"), - table_name="index_attempt", - ) diff --git a/backend/alembic/versions/e86866a9c78a_add_persona_to_chat_session.py b/backend/alembic/versions/e86866a9c78a_add_persona_to_chat_session.py deleted file mode 100644 index 97b0d751091..00000000000 --- a/backend/alembic/versions/e86866a9c78a_add_persona_to_chat_session.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add persona to chat_session - -Revision ID: e86866a9c78a -Revises: 80696cf850ae -Create Date: 2023-11-26 02:51:47.657357 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "e86866a9c78a" -down_revision = "80696cf850ae" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column("chat_session", sa.Column("persona_id", sa.Integer(), nullable=True)) - op.create_foreign_key( - "fk_chat_session_persona_id", "chat_session", "persona", ["persona_id"], ["id"] - ) - - -def downgrade() -> None: - op.drop_constraint("fk_chat_session_persona_id", "chat_session", type_="foreignkey") - op.drop_column("chat_session", "persona_id") diff --git a/backend/alembic/versions/e91df4e935ef_private_personas_documentsets.py b/backend/alembic/versions/e91df4e935ef_private_personas_documentsets.py deleted file mode 100644 index a7eb75a1e1c..00000000000 --- a/backend/alembic/versions/e91df4e935ef_private_personas_documentsets.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Private Personas DocumentSets - -Revision ID: e91df4e935ef -Revises: 91fd3b470d1a -Create Date: 2024-03-17 11:47:24.675881 - -""" -import fastapi_users_db_sqlalchemy -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "e91df4e935ef" -down_revision = "91fd3b470d1a" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "document_set__user", - sa.Column("document_set_id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["document_set_id"], - ["document_set.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("document_set_id", "user_id"), - ) - op.create_table( - "persona__user", - sa.Column("persona_id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - fastapi_users_db_sqlalchemy.generics.GUID(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["user.id"], - ), - sa.PrimaryKeyConstraint("persona_id", "user_id"), - ) - op.create_table( - "document_set__user_group", - sa.Column("document_set_id", sa.Integer(), nullable=False), - sa.Column( - "user_group_id", - sa.Integer(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["document_set_id"], - ["document_set.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("document_set_id", "user_group_id"), - ) - op.create_table( - "persona__user_group", - sa.Column("persona_id", sa.Integer(), nullable=False), - sa.Column( - "user_group_id", - sa.Integer(), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.ForeignKeyConstraint( - ["user_group_id"], - ["user_group.id"], - ), - sa.PrimaryKeyConstraint("persona_id", "user_group_id"), - ) - - op.add_column( - "document_set", - sa.Column("is_public", sa.Boolean(), nullable=True), - ) - # fill in is_public for existing rows - op.execute("UPDATE document_set SET is_public = true WHERE is_public IS NULL") - op.alter_column("document_set", "is_public", nullable=False) - - op.add_column( - "persona", - sa.Column("is_public", sa.Boolean(), nullable=True), - ) - # fill in is_public for existing rows - op.execute("UPDATE persona SET is_public = true WHERE is_public IS NULL") - op.alter_column("persona", "is_public", nullable=False) - - -def downgrade() -> None: - op.drop_column("persona", "is_public") - - op.drop_column("document_set", "is_public") - - op.drop_table("persona__user") - op.drop_table("document_set__user") - op.drop_table("persona__user_group") - op.drop_table("document_set__user_group") diff --git a/backend/alembic/versions/ec3ec2eabf7b_index_from_beginning.py b/backend/alembic/versions/ec3ec2eabf7b_index_from_beginning.py deleted file mode 100644 index 623c1406008..00000000000 --- a/backend/alembic/versions/ec3ec2eabf7b_index_from_beginning.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Index From Beginning - -Revision ID: ec3ec2eabf7b -Revises: dbaa756c2ccf -Create Date: 2024-02-06 22:03:28.098158 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "ec3ec2eabf7b" -down_revision = "dbaa756c2ccf" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "index_attempt", sa.Column("from_beginning", sa.Boolean(), nullable=True) - ) - op.execute("UPDATE index_attempt SET from_beginning = False") - op.alter_column("index_attempt", "from_beginning", nullable=False) - - -def downgrade() -> None: - op.drop_column("index_attempt", "from_beginning") diff --git a/backend/alembic/versions/ec85f2b3c544_remove_last_attempt_status_from_cc_pair.py b/backend/alembic/versions/ec85f2b3c544_remove_last_attempt_status_from_cc_pair.py deleted file mode 100644 index fe073ce49bc..00000000000 --- a/backend/alembic/versions/ec85f2b3c544_remove_last_attempt_status_from_cc_pair.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Remove Last Attempt Status from CC Pair - -Revision ID: ec85f2b3c544 -Revises: 3879338f8ba1 -Create Date: 2024-05-23 21:39:46.126010 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "ec85f2b3c544" -down_revision = "70f00c45c0f2" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.drop_column("connector_credential_pair", "last_attempt_status") - - -def downgrade() -> None: - op.add_column( - "connector_credential_pair", - sa.Column( - "last_attempt_status", - sa.VARCHAR(), - autoincrement=False, - nullable=True, - ), - ) diff --git a/backend/alembic/versions/ecab2b3f1a3b_add_overrides_to_the_chat_session.py b/backend/alembic/versions/ecab2b3f1a3b_add_overrides_to_the_chat_session.py deleted file mode 100644 index ca2b57adbc9..00000000000 --- a/backend/alembic/versions/ecab2b3f1a3b_add_overrides_to_the_chat_session.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Add overrides to the chat session - -Revision ID: ecab2b3f1a3b -Revises: 38eda64af7fe -Create Date: 2024-04-01 19:08:21.359102 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "ecab2b3f1a3b" -down_revision = "38eda64af7fe" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_session", - sa.Column( - "llm_override", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - op.add_column( - "chat_session", - sa.Column( - "prompt_override", - postgresql.JSONB(astext_type=sa.Text()), - nullable=True, - ), - ) - - -def downgrade() -> None: - op.drop_column("chat_session", "prompt_override") - op.drop_column("chat_session", "llm_override") diff --git a/backend/alembic/versions/ee3f4b47fad5_added_alternate_model_to_chat_message.py b/backend/alembic/versions/ee3f4b47fad5_added_alternate_model_to_chat_message.py deleted file mode 100644 index 64ffdad25f7..00000000000 --- a/backend/alembic/versions/ee3f4b47fad5_added_alternate_model_to_chat_message.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Added alternate model to chat message - -Revision ID: ee3f4b47fad5 -Revises: 2d2304e27d8c -Create Date: 2024-08-12 00:11:50.915845 - -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "ee3f4b47fad5" -down_revision = "2d2304e27d8c" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_message", - sa.Column("overridden_model", sa.String(length=255), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("chat_message", "overridden_model") diff --git a/backend/alembic/versions/ef7da92f7213_add_files_to_chatmessage.py b/backend/alembic/versions/ef7da92f7213_add_files_to_chatmessage.py deleted file mode 100644 index eb04a1b8208..00000000000 --- a/backend/alembic/versions/ef7da92f7213_add_files_to_chatmessage.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Add files to ChatMessage - -Revision ID: ef7da92f7213 -Revises: 401c1ac29467 -Create Date: 2024-04-28 16:59:33.199153 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "ef7da92f7213" -down_revision = "401c1ac29467" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_message", - sa.Column("files", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("chat_message", "files") diff --git a/backend/alembic/versions/f17bf3b0d9f1_embedding_provider_by_provider_type.py b/backend/alembic/versions/f17bf3b0d9f1_embedding_provider_by_provider_type.py deleted file mode 100644 index a141f946d2a..00000000000 --- a/backend/alembic/versions/f17bf3b0d9f1_embedding_provider_by_provider_type.py +++ /dev/null @@ -1,172 +0,0 @@ -"""embedding provider by provider type - -Revision ID: f17bf3b0d9f1 -Revises: 351faebd379d -Create Date: 2024-08-21 13:13:31.120460 - -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "f17bf3b0d9f1" -down_revision = "351faebd379d" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Add provider_type column to embedding_provider - op.add_column( - "embedding_provider", - sa.Column("provider_type", sa.String(50), nullable=True), - ) - - # Update provider_type with existing name values - op.execute("UPDATE embedding_provider SET provider_type = UPPER(name)") - - # Make provider_type not nullable - op.alter_column("embedding_provider", "provider_type", nullable=False) - - # Drop the foreign key constraint in embedding_model table - op.drop_constraint( - "fk_embedding_model_cloud_provider", "embedding_model", type_="foreignkey" - ) - - # Drop the existing primary key constraint - op.drop_constraint("embedding_provider_pkey", "embedding_provider", type_="primary") - - # Create a new primary key constraint on provider_type - op.create_primary_key( - "embedding_provider_pkey", "embedding_provider", ["provider_type"] - ) - - # Add provider_type column to embedding_model - op.add_column( - "embedding_model", - sa.Column("provider_type", sa.String(50), nullable=True), - ) - - # Update provider_type for existing embedding models - op.execute( - """ - UPDATE embedding_model - SET provider_type = ( - SELECT provider_type - FROM embedding_provider - WHERE embedding_provider.id = embedding_model.cloud_provider_id - ) - """ - ) - - # Drop the old id column from embedding_provider - op.drop_column("embedding_provider", "id") - - # Drop the name column from embedding_provider - op.drop_column("embedding_provider", "name") - - # Drop the default_model_id column from embedding_provider - op.drop_column("embedding_provider", "default_model_id") - - # Drop the old cloud_provider_id column from embedding_model - op.drop_column("embedding_model", "cloud_provider_id") - - # Create the new foreign key constraint - op.create_foreign_key( - "fk_embedding_model_cloud_provider", - "embedding_model", - "embedding_provider", - ["provider_type"], - ["provider_type"], - ) - - -def downgrade() -> None: - # Drop the foreign key constraint in embedding_model table - op.drop_constraint( - "fk_embedding_model_cloud_provider", "embedding_model", type_="foreignkey" - ) - - # Add back the cloud_provider_id column to embedding_model - op.add_column( - "embedding_model", sa.Column("cloud_provider_id", sa.Integer(), nullable=True) - ) - op.add_column("embedding_provider", sa.Column("id", sa.Integer(), nullable=True)) - - # Assign incrementing IDs to embedding providers - op.execute( - """ - CREATE SEQUENCE IF NOT EXISTS embedding_provider_id_seq;""" - ) - op.execute( - """ - UPDATE embedding_provider SET id = nextval('embedding_provider_id_seq'); - """ - ) - - # Update cloud_provider_id based on provider_type - op.execute( - """ - UPDATE embedding_model - SET cloud_provider_id = CASE - WHEN provider_type IS NULL THEN NULL - ELSE ( - SELECT id - FROM embedding_provider - WHERE embedding_provider.provider_type = embedding_model.provider_type - ) - END - """ - ) - - # Drop the provider_type column from embedding_model - op.drop_column("embedding_model", "provider_type") - - # Add back the columns to embedding_provider - op.add_column("embedding_provider", sa.Column("name", sa.String(50), nullable=True)) - op.add_column( - "embedding_provider", sa.Column("default_model_id", sa.Integer(), nullable=True) - ) - - # Drop the existing primary key constraint on provider_type - op.drop_constraint("embedding_provider_pkey", "embedding_provider", type_="primary") - - # Create the original primary key constraint on id - op.create_primary_key("embedding_provider_pkey", "embedding_provider", ["id"]) - - # Update name with existing provider_type values - op.execute( - """ - UPDATE embedding_provider - SET name = CASE - WHEN provider_type = 'OPENAI' THEN 'OpenAI' - WHEN provider_type = 'COHERE' THEN 'Cohere' - WHEN provider_type = 'GOOGLE' THEN 'Google' - WHEN provider_type = 'VOYAGE' THEN 'Voyage' - ELSE provider_type - END - """ - ) - - # Drop the provider_type column from embedding_provider - op.drop_column("embedding_provider", "provider_type") - - # Recreate the foreign key constraint in embedding_model table - op.create_foreign_key( - "fk_embedding_model_cloud_provider", - "embedding_model", - "embedding_provider", - ["cloud_provider_id"], - ["id"], - ) - - # Recreate the foreign key constraint in embedding_model table - op.create_foreign_key( - "fk_embedding_provider_default_model", - "embedding_provider", - "embedding_model", - ["default_model_id"], - ["id"], - ) diff --git a/backend/alembic/versions/f1c6478c3fd8_add_pre_defined_feedback.py b/backend/alembic/versions/f1c6478c3fd8_add_pre_defined_feedback.py deleted file mode 100644 index f6ba0d7ddfc..00000000000 --- a/backend/alembic/versions/f1c6478c3fd8_add_pre_defined_feedback.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Add pre-defined feedback - -Revision ID: f1c6478c3fd8 -Revises: 643a84a42a33 -Create Date: 2024-05-09 18:11:49.210667 - -""" -from alembic import op -import sqlalchemy as sa - -revision = "f1c6478c3fd8" -down_revision = "643a84a42a33" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "chat_feedback", - sa.Column("predefined_feedback", sa.String(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("chat_feedback", "predefined_feedback") diff --git a/backend/alembic/versions/fad14119fb92_delete_tags_with_wrong_enum.py b/backend/alembic/versions/fad14119fb92_delete_tags_with_wrong_enum.py deleted file mode 100644 index b9c428640eb..00000000000 --- a/backend/alembic/versions/fad14119fb92_delete_tags_with_wrong_enum.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Delete Tags with wrong Enum - -Revision ID: fad14119fb92 -Revises: 72bdc9929a46 -Create Date: 2024-04-25 17:05:09.695703 - -""" -from alembic import op - -# revision identifiers, used by Alembic. -revision = "fad14119fb92" -down_revision = "72bdc9929a46" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - # Some documents may lose their tags but this is the only way as the enum - # mapping may have changed since tag switched to string (it will be reindexed anyway) - op.execute( - """ - DELETE FROM document__tag - WHERE tag_id IN ( - SELECT id FROM tag - WHERE source ~ '^[0-9]+$' - ) - """ - ) - - op.execute( - """ - DELETE FROM tag - WHERE source ~ '^[0-9]+$' - """ - ) - - -def downgrade() -> None: - pass diff --git a/backend/alembic/versions/fcd135795f21_add_slack_bot_display_type.py b/backend/alembic/versions/fcd135795f21_add_slack_bot_display_type.py deleted file mode 100644 index fc7a6f502e8..00000000000 --- a/backend/alembic/versions/fcd135795f21_add_slack_bot_display_type.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Add slack bot display type - -Revision ID: fcd135795f21 -Revises: 0a2b51deb0b8 -Create Date: 2024-03-04 17:03:27.116284 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "fcd135795f21" -down_revision = "0a2b51deb0b8" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "slack_bot_config", - sa.Column( - "response_type", - sa.Enum( - "QUOTES", - "CITATIONS", - name="slackbotresponsetype", - native_enum=False, - ), - nullable=True, - ), - ) - op.execute( - "UPDATE slack_bot_config SET response_type = 'QUOTES' WHERE response_type IS NULL" - ) - op.alter_column("slack_bot_config", "response_type", nullable=False) - - -def downgrade() -> None: - op.drop_column("slack_bot_config", "response_type") diff --git a/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py b/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py deleted file mode 100644 index 77ca1d14c9d..00000000000 --- a/backend/alembic/versions/febe9eaa0644_add_document_set_persona_relationship_.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Add document_set / persona relationship table - -Revision ID: febe9eaa0644 -Revises: 57b53544726e -Create Date: 2023-09-24 13:06:24.018610 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "febe9eaa0644" -down_revision = "57b53544726e" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.create_table( - "persona__document_set", - sa.Column("persona_id", sa.Integer(), nullable=False), - sa.Column("document_set_id", sa.Integer(), nullable=False), - sa.ForeignKeyConstraint( - ["document_set_id"], - ["document_set.id"], - ), - sa.ForeignKeyConstraint( - ["persona_id"], - ["persona.id"], - ), - sa.PrimaryKeyConstraint("persona_id", "document_set_id"), - ) - - -def downgrade() -> None: - op.drop_table("persona__document_set") diff --git a/backend/alembic/versions/ffc707a226b4_basic_document_metadata.py b/backend/alembic/versions/ffc707a226b4_basic_document_metadata.py deleted file mode 100644 index 3a2b3b557ee..00000000000 --- a/backend/alembic/versions/ffc707a226b4_basic_document_metadata.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Basic Document Metadata - -Revision ID: ffc707a226b4 -Revises: 30c1d5744104 -Create Date: 2023-10-18 16:52:25.967592 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision = "ffc707a226b4" -down_revision = "30c1d5744104" -branch_labels: None = None -depends_on: None = None - - -def upgrade() -> None: - op.add_column( - "document", - sa.Column("doc_updated_at", sa.DateTime(timezone=True), nullable=True), - ) - op.add_column( - "document", - sa.Column("primary_owners", postgresql.ARRAY(sa.String()), nullable=True), - ) - op.add_column( - "document", - sa.Column("secondary_owners", postgresql.ARRAY(sa.String()), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("document", "secondary_owners") - op.drop_column("document", "primary_owners") - op.drop_column("document", "doc_updated_at") diff --git a/backend/assets/.gitignore b/backend/assets/.gitignore deleted file mode 100644 index d6b7ef32c84..00000000000 --- a/backend/assets/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/backend/danswer/__init__.py b/backend/danswer/__init__.py deleted file mode 100644 index e2d480be4e6..00000000000 --- a/backend/danswer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import os - -__version__ = os.environ.get("DANSWER_VERSION", "") or "0.3-dev" diff --git a/backend/danswer/access/__init__.py b/backend/danswer/access/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/access/access.py b/backend/danswer/access/access.py deleted file mode 100644 index 5501980ab48..00000000000 --- a/backend/danswer/access/access.py +++ /dev/null @@ -1,53 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.access.models import DocumentAccess -from danswer.access.utils import prefix_user -from danswer.configs.constants import PUBLIC_DOC_PAT -from danswer.db.document import get_acccess_info_for_documents -from danswer.db.models import User -from danswer.utils.variable_functionality import fetch_versioned_implementation - - -def _get_access_for_documents( - document_ids: list[str], - db_session: Session, -) -> dict[str, DocumentAccess]: - document_access_info = get_acccess_info_for_documents( - db_session=db_session, - document_ids=document_ids, - ) - return { - document_id: DocumentAccess.build(user_ids, [], is_public) - for document_id, user_ids, is_public in document_access_info - } - - -def get_access_for_documents( - document_ids: list[str], - db_session: Session, -) -> dict[str, DocumentAccess]: - """Fetches all access information for the given documents.""" - versioned_get_access_for_documents_fn = fetch_versioned_implementation( - "danswer.access.access", "_get_access_for_documents" - ) - return versioned_get_access_for_documents_fn( - document_ids, db_session - ) # type: ignore - - -def _get_acl_for_user(user: User | None, db_session: Session) -> set[str]: - """Returns a list of ACL entries that the user has access to. This is meant to be - used downstream to filter out documents that the user does not have access to. The - user should have access to a document if at least one entry in the document's ACL - matches one entry in the returned set. - """ - if user: - return {prefix_user(str(user.id)), PUBLIC_DOC_PAT} - return {PUBLIC_DOC_PAT} - - -def get_acl_for_user(user: User | None, db_session: Session | None = None) -> set[str]: - versioned_acl_for_user_fn = fetch_versioned_implementation( - "danswer.access.access", "_get_acl_for_user" - ) - return versioned_acl_for_user_fn(user, db_session) # type: ignore diff --git a/backend/danswer/access/models.py b/backend/danswer/access/models.py deleted file mode 100644 index a87e2d94f25..00000000000 --- a/backend/danswer/access/models.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from danswer.access.utils import prefix_user -from danswer.access.utils import prefix_user_group -from danswer.configs.constants import PUBLIC_DOC_PAT - - -@dataclass(frozen=True) -class DocumentAccess: - user_ids: set[str] # stringified UUIDs - user_groups: set[str] # names of user groups associated with this document - is_public: bool - - def to_acl(self) -> list[str]: - return ( - [prefix_user(user_id) for user_id in self.user_ids] - + [prefix_user_group(group_name) for group_name in self.user_groups] - + ([PUBLIC_DOC_PAT] if self.is_public else []) - ) - - @classmethod - def build( - cls, user_ids: list[UUID | None], user_groups: list[str], is_public: bool - ) -> "DocumentAccess": - return cls( - user_ids={str(user_id) for user_id in user_ids if user_id}, - user_groups=set(user_groups), - is_public=is_public, - ) diff --git a/backend/danswer/access/utils.py b/backend/danswer/access/utils.py deleted file mode 100644 index 060560eaedc..00000000000 --- a/backend/danswer/access/utils.py +++ /dev/null @@ -1,10 +0,0 @@ -def prefix_user(user_id: str) -> str: - """Prefixes a user ID to eliminate collision with group names. - This assumes that groups are prefixed with a different prefix.""" - return f"user_id:{user_id}" - - -def prefix_user_group(user_group_name: str) -> str: - """Prefixes a user group name to eliminate collision with user IDs. - This assumes that user ids are prefixed with a different prefix.""" - return f"group:{user_group_name}" diff --git a/backend/danswer/auth/__init__.py b/backend/danswer/auth/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/auth/invited_users.py b/backend/danswer/auth/invited_users.py deleted file mode 100644 index efce858f265..00000000000 --- a/backend/danswer/auth/invited_users.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import cast - -from danswer.configs.constants import KV_USER_STORE_KEY -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.dynamic_configs.interface import JSON_ro - - -def get_invited_users() -> list[str]: - try: - store = get_dynamic_config_store() - return cast(list, store.load(KV_USER_STORE_KEY)) - except ConfigNotFoundError: - return list() - - -def write_invited_users(emails: list[str]) -> int: - store = get_dynamic_config_store() - store.store(KV_USER_STORE_KEY, cast(JSON_ro, emails)) - return len(emails) diff --git a/backend/danswer/auth/noauth_user.py b/backend/danswer/auth/noauth_user.py deleted file mode 100644 index 9520ef41c23..00000000000 --- a/backend/danswer/auth/noauth_user.py +++ /dev/null @@ -1,38 +0,0 @@ -from collections.abc import Mapping -from typing import Any -from typing import cast - -from danswer.auth.schemas import UserRole -from danswer.configs.constants import KV_NO_AUTH_USER_PREFERENCES_KEY -from danswer.dynamic_configs.store import ConfigNotFoundError -from danswer.dynamic_configs.store import DynamicConfigStore -from danswer.server.manage.models import UserInfo -from danswer.server.manage.models import UserPreferences - - -def set_no_auth_user_preferences( - store: DynamicConfigStore, preferences: UserPreferences -) -> None: - store.store(KV_NO_AUTH_USER_PREFERENCES_KEY, preferences.model_dump()) - - -def load_no_auth_user_preferences(store: DynamicConfigStore) -> UserPreferences: - try: - preferences_data = cast( - Mapping[str, Any], store.load(KV_NO_AUTH_USER_PREFERENCES_KEY) - ) - return UserPreferences(**preferences_data) - except ConfigNotFoundError: - return UserPreferences(chosen_assistants=None, default_model=None) - - -def fetch_no_auth_user(store: DynamicConfigStore) -> UserInfo: - return UserInfo( - id="__no_auth_user__", - email="anonymous@danswer.ai", - is_active=True, - is_superuser=False, - is_verified=True, - role=UserRole.ADMIN, - preferences=load_no_auth_user_preferences(store), - ) diff --git a/backend/danswer/auth/schemas.py b/backend/danswer/auth/schemas.py deleted file mode 100644 index 9e0553991cc..00000000000 --- a/backend/danswer/auth/schemas.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from enum import Enum - -from fastapi_users import schemas - - -class UserRole(str, Enum): - """ - User roles - - Basic can't perform any admin actions - - Admin can perform all admin actions - - Curator can perform admin actions for - groups they are curators of - - Global Curator can perform admin actions - for all groups they are a member of - """ - - BASIC = "basic" - ADMIN = "admin" - CURATOR = "curator" - GLOBAL_CURATOR = "global_curator" - - -class UserStatus(str, Enum): - LIVE = "live" - INVITED = "invited" - DEACTIVATED = "deactivated" - - -class UserRead(schemas.BaseUser[uuid.UUID]): - role: UserRole - - -class UserCreate(schemas.BaseUserCreate): - role: UserRole = UserRole.BASIC - - -class UserUpdate(schemas.BaseUserUpdate): - role: UserRole diff --git a/backend/danswer/auth/users.py b/backend/danswer/auth/users.py deleted file mode 100644 index dff6a60363c..00000000000 --- a/backend/danswer/auth/users.py +++ /dev/null @@ -1,452 +0,0 @@ -import smtplib -import uuid -from collections.abc import AsyncGenerator -from datetime import datetime -from datetime import timezone -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from typing import Optional -from typing import Tuple - -from email_validator import EmailNotValidError -from email_validator import validate_email -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Request -from fastapi import Response -from fastapi import status -from fastapi_users import BaseUserManager -from fastapi_users import FastAPIUsers -from fastapi_users import models -from fastapi_users import schemas -from fastapi_users import UUIDIDMixin -from fastapi_users.authentication import AuthenticationBackend -from fastapi_users.authentication import CookieTransport -from fastapi_users.authentication import Strategy -from fastapi_users.authentication.strategy.db import AccessTokenDatabase -from fastapi_users.authentication.strategy.db import DatabaseStrategy -from fastapi_users.openapi import OpenAPIResponseType -from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase -from sqlalchemy.orm import Session - -from danswer.auth.invited_users import get_invited_users -from danswer.auth.schemas import UserCreate -from danswer.auth.schemas import UserRole -from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.app_configs import DISABLE_AUTH -from danswer.configs.app_configs import EMAIL_FROM -from danswer.configs.app_configs import REQUIRE_EMAIL_VERIFICATION -from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS -from danswer.configs.app_configs import SMTP_PASS -from danswer.configs.app_configs import SMTP_PORT -from danswer.configs.app_configs import SMTP_SERVER -from danswer.configs.app_configs import SMTP_USER -from danswer.configs.app_configs import TRACK_EXTERNAL_IDP_EXPIRY -from danswer.configs.app_configs import USER_AUTH_SECRET -from danswer.configs.app_configs import VALID_EMAIL_DOMAINS -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import AuthType -from danswer.configs.constants import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN -from danswer.configs.constants import DANSWER_API_KEY_PREFIX -from danswer.configs.constants import UNNAMED_KEY_PLACEHOLDER -from danswer.db.auth import get_access_token_db -from danswer.db.auth import get_default_admin_user_emails -from danswer.db.auth import get_user_count -from danswer.db.auth import get_user_db -from danswer.db.engine import get_session -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.models import AccessToken -from danswer.db.models import User -from danswer.db.users import get_user_by_email -from danswer.utils.logger import setup_logger -from danswer.utils.telemetry import optional_telemetry -from danswer.utils.telemetry import RecordType -from danswer.utils.variable_functionality import fetch_versioned_implementation - -logger = setup_logger() - - -def validate_curator_request(groups: list | None, is_public: bool) -> None: - if is_public: - detail = "Curators cannot create public objects" - logger.error(detail) - raise HTTPException( - status_code=401, - detail=detail, - ) - if not groups: - detail = "Curators must specify 1+ groups" - logger.error(detail) - raise HTTPException( - status_code=401, - detail=detail, - ) - - -def is_user_admin(user: User | None) -> bool: - if AUTH_TYPE == AuthType.DISABLED: - return True - if user and user.role == UserRole.ADMIN: - return True - return False - - -def verify_auth_setting() -> None: - if AUTH_TYPE not in [AuthType.DISABLED, AuthType.BASIC, AuthType.GOOGLE_OAUTH]: - raise ValueError( - "User must choose a valid user authentication method: " - "disabled, basic, or google_oauth" - ) - logger.notice(f"Using Auth Type: {AUTH_TYPE.value}") - - -def get_display_email(email: str | None, space_less: bool = False) -> str: - if email and email.endswith(DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN): - name = email.split("@")[0] - if name == DANSWER_API_KEY_PREFIX + UNNAMED_KEY_PLACEHOLDER: - return "Unnamed API Key" - - if space_less: - return name - - return name.replace("API_KEY__", "API Key: ") - - return email or "" - - -def user_needs_to_be_verified() -> bool: - # all other auth types besides basic should require users to be - # verified - return AUTH_TYPE != AuthType.BASIC or REQUIRE_EMAIL_VERIFICATION - - -def verify_email_is_invited(email: str) -> None: - whitelist = get_invited_users() - if not whitelist: - return - - if not email: - raise PermissionError("Email must be specified") - - email_info = validate_email(email) # can raise EmailNotValidError - - for email_whitelist in whitelist: - try: - # normalized emails are now being inserted into the db - # we can remove this normalization on read after some time has passed - email_info_whitelist = validate_email(email_whitelist) - except EmailNotValidError: - continue - - # oddly, normalization does not include lowercasing the user part of the - # email address ... which we want to allow - if email_info.normalized.lower() == email_info_whitelist.normalized.lower(): - return - - raise PermissionError("User not on allowed user whitelist") - - -def verify_email_in_whitelist(email: str) -> None: - with Session(get_sqlalchemy_engine()) as db_session: - if not get_user_by_email(email, db_session): - verify_email_is_invited(email) - - -def verify_email_domain(email: str) -> None: - if VALID_EMAIL_DOMAINS: - if email.count("@") != 1: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email is not valid", - ) - domain = email.split("@")[-1] - if domain not in VALID_EMAIL_DOMAINS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email domain is not valid", - ) - - -def send_user_verification_email( - user_email: str, - token: str, - mail_from: str = EMAIL_FROM, -) -> None: - msg = MIMEMultipart() - msg["Subject"] = "Danswer Email Verification" - msg["To"] = user_email - if mail_from: - msg["From"] = mail_from - - link = f"{WEB_DOMAIN}/auth/verify-email?token={token}" - - body = MIMEText(f"Click the following link to verify your email address: {link}") - msg.attach(body) - - with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as s: - s.starttls() - # If credentials fails with gmail, check (You need an app password, not just the basic email password) - # https://support.google.com/accounts/answer/185833?sjid=8512343437447396151-NA - s.login(SMTP_USER, SMTP_PASS) - s.send_message(msg) - - -class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): - reset_password_token_secret = USER_AUTH_SECRET - verification_token_secret = USER_AUTH_SECRET - - async def create( - self, - user_create: schemas.UC | UserCreate, - safe: bool = False, - request: Optional[Request] = None, - ) -> models.UP: - verify_email_is_invited(user_create.email) - verify_email_domain(user_create.email) - if hasattr(user_create, "role"): - user_count = await get_user_count() - if user_count == 0 or user_create.email in get_default_admin_user_emails(): - user_create.role = UserRole.ADMIN - else: - user_create.role = UserRole.BASIC - return await super().create(user_create, safe=safe, request=request) # type: ignore - - async def oauth_callback( - self: "BaseUserManager[models.UOAP, models.ID]", - oauth_name: str, - access_token: str, - account_id: str, - account_email: str, - expires_at: Optional[int] = None, - refresh_token: Optional[str] = None, - request: Optional[Request] = None, - *, - associate_by_email: bool = False, - is_verified_by_default: bool = False, - ) -> models.UOAP: - verify_email_in_whitelist(account_email) - verify_email_domain(account_email) - - user = await super().oauth_callback( # type: ignore - oauth_name=oauth_name, - access_token=access_token, - account_id=account_id, - account_email=account_email, - expires_at=expires_at, - refresh_token=refresh_token, - request=request, - associate_by_email=associate_by_email, - is_verified_by_default=is_verified_by_default, - ) - - # NOTE: Most IdPs have very short expiry times, and we don't want to force the user to - # re-authenticate that frequently, so by default this is disabled - if expires_at and TRACK_EXTERNAL_IDP_EXPIRY: - oidc_expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) - await self.user_db.update(user, update_dict={"oidc_expiry": oidc_expiry}) - - # this is needed if an organization goes from `TRACK_EXTERNAL_IDP_EXPIRY=true` to `false` - # otherwise, the oidc expiry will always be old, and the user will never be able to login - if user.oidc_expiry and not TRACK_EXTERNAL_IDP_EXPIRY: - await self.user_db.update(user, update_dict={"oidc_expiry": None}) - - return user - - async def on_after_register( - self, user: User, request: Optional[Request] = None - ) -> None: - logger.notice(f"User {user.id} has registered.") - optional_telemetry( - record_type=RecordType.SIGN_UP, - data={"action": "create"}, - user_id=str(user.id), - ) - - async def on_after_forgot_password( - self, user: User, token: str, request: Optional[Request] = None - ) -> None: - logger.notice(f"User {user.id} has forgot their password. Reset token: {token}") - - async def on_after_request_verify( - self, user: User, token: str, request: Optional[Request] = None - ) -> None: - verify_email_domain(user.email) - - logger.notice( - f"Verification requested for user {user.id}. Verification token: {token}" - ) - - send_user_verification_email(user.email, token) - - -async def get_user_manager( - user_db: SQLAlchemyUserDatabase = Depends(get_user_db), -) -> AsyncGenerator[UserManager, None]: - yield UserManager(user_db) - - -cookie_transport = CookieTransport( - cookie_max_age=SESSION_EXPIRE_TIME_SECONDS, - cookie_secure=WEB_DOMAIN.startswith("https"), -) - - -def get_database_strategy( - access_token_db: AccessTokenDatabase[AccessToken] = Depends(get_access_token_db), -) -> DatabaseStrategy: - strategy = DatabaseStrategy( - access_token_db, lifetime_seconds=SESSION_EXPIRE_TIME_SECONDS # type: ignore - ) - - return strategy - - -auth_backend = AuthenticationBackend( - name="database", - transport=cookie_transport, - get_strategy=get_database_strategy, -) - - -class FastAPIUserWithLogoutRouter(FastAPIUsers[models.UP, models.ID]): - def get_logout_router( - self, - backend: AuthenticationBackend, - requires_verification: bool = REQUIRE_EMAIL_VERIFICATION, - ) -> APIRouter: - """ - Provide a router for logout only for OAuth/OIDC Flows. - This way the login router does not need to be included - """ - router = APIRouter() - get_current_user_token = self.authenticator.current_user_token( - active=True, verified=requires_verification - ) - logout_responses: OpenAPIResponseType = { - **{ - status.HTTP_401_UNAUTHORIZED: { - "description": "Missing token or inactive user." - } - }, - **backend.transport.get_openapi_logout_responses_success(), - } - - @router.post( - "/logout", name=f"auth:{backend.name}.logout", responses=logout_responses - ) - async def logout( - user_token: Tuple[models.UP, str] = Depends(get_current_user_token), - strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy), - ) -> Response: - user, token = user_token - return await backend.logout(strategy, user, token) - - return router - - -fastapi_users = FastAPIUserWithLogoutRouter[User, uuid.UUID]( - get_user_manager, [auth_backend] -) - - -# NOTE: verified=REQUIRE_EMAIL_VERIFICATION is not used here since we -# take care of that in `double_check_user` ourself. This is needed, since -# we want the /me endpoint to still return a user even if they are not -# yet verified, so that the frontend knows they exist -optional_fastapi_current_user = fastapi_users.current_user(active=True, optional=True) - - -async def optional_user_( - request: Request, - user: User | None, - db_session: Session, -) -> User | None: - """NOTE: `request` and `db_session` are not used here, but are included - for the EE version of this function.""" - return user - - -async def optional_user( - request: Request, - user: User | None = Depends(optional_fastapi_current_user), - db_session: Session = Depends(get_session), -) -> User | None: - versioned_fetch_user = fetch_versioned_implementation( - "danswer.auth.users", "optional_user_" - ) - return await versioned_fetch_user(request, user, db_session) - - -async def double_check_user( - user: User | None, - optional: bool = DISABLE_AUTH, -) -> User | None: - if optional: - return None - - if user is None: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User is not authenticated.", - ) - - if user_needs_to_be_verified() and not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User is not verified.", - ) - - if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User's OIDC token has expired.", - ) - - return user - - -async def current_user( - user: User | None = Depends(optional_user), -) -> User | None: - return await double_check_user(user) - - -async def current_curator_or_admin_user( - user: User | None = Depends(current_user), -) -> User | None: - if DISABLE_AUTH: - return None - - if not user or not hasattr(user, "role"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User is not authenticated or lacks role information.", - ) - - allowed_roles = {UserRole.GLOBAL_CURATOR, UserRole.CURATOR, UserRole.ADMIN} - if user.role not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User is not a curator or admin.", - ) - - return user - - -async def current_admin_user(user: User | None = Depends(current_user)) -> User | None: - if DISABLE_AUTH: - return None - - if not user or not hasattr(user, "role") or user.role != UserRole.ADMIN: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User must be an admin to perform this action.", - ) - - return user - - -def get_default_admin_user_emails_() -> list[str]: - # No default seeding available for Danswer MIT - return [] diff --git a/backend/danswer/background/celery/celery_app.py b/backend/danswer/background/celery/celery_app.py deleted file mode 100644 index ffd805c2986..00000000000 --- a/backend/danswer/background/celery/celery_app.py +++ /dev/null @@ -1,472 +0,0 @@ -import json -from datetime import timedelta -from typing import Any -from typing import cast - -from celery import Celery # type: ignore -from celery.contrib.abortable import AbortableTask # type: ignore -from celery.exceptions import TaskRevokedError -from sqlalchemy import text -from sqlalchemy.orm import Session - -from danswer.background.celery.celery_utils import extract_ids_from_runnable_connector -from danswer.background.celery.celery_utils import should_kick_off_deletion_of_cc_pair -from danswer.background.celery.celery_utils import should_prune_cc_pair -from danswer.background.celery.celery_utils import should_sync_doc_set -from danswer.background.connector_deletion import delete_connector_credential_pair -from danswer.background.connector_deletion import delete_connector_credential_pair_batch -from danswer.background.task_utils import build_celery_task_wrapper -from danswer.background.task_utils import name_cc_cleanup_task -from danswer.background.task_utils import name_cc_prune_task -from danswer.background.task_utils import name_document_set_sync_task -from danswer.configs.app_configs import JOB_TIMEOUT -from danswer.configs.constants import POSTGRES_CELERY_APP_NAME -from danswer.configs.constants import PostgresAdvisoryLocks -from danswer.connectors.factory import instantiate_connector -from danswer.connectors.models import InputType -from danswer.db.connector_credential_pair import get_connector_credential_pair -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.deletion_attempt import check_deletion_attempt_is_allowed -from danswer.db.document import get_documents_for_connector_credential_pair -from danswer.db.document import prepare_to_modify_documents -from danswer.db.document_set import delete_document_set -from danswer.db.document_set import fetch_document_sets -from danswer.db.document_set import fetch_document_sets_for_documents -from danswer.db.document_set import fetch_documents_for_document_set_paginated -from danswer.db.document_set import get_document_set_by_id -from danswer.db.document_set import mark_document_set_as_synced -from danswer.db.engine import build_connection_string -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.engine import SYNC_DB_API -from danswer.db.models import DocumentSet -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.document_index.factory import get_default_document_index -from danswer.document_index.interfaces import UpdateRequest -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -connection_string = build_connection_string( - db_api=SYNC_DB_API, app_name=POSTGRES_CELERY_APP_NAME -) -celery_broker_url = f"sqla+{connection_string}" -celery_backend_url = f"db+{connection_string}" -celery_app = Celery(__name__, broker=celery_broker_url, backend=celery_backend_url) - - -_SYNC_BATCH_SIZE = 100 - - -##### -# Tasks that need to be run in job queue, registered via APIs -# -# If imports from this module are needed, use local imports to avoid circular importing -##### -@build_celery_task_wrapper(name_cc_cleanup_task) -@celery_app.task(soft_time_limit=JOB_TIMEOUT) -def cleanup_connector_credential_pair_task( - connector_id: int, - credential_id: int, -) -> int: - """Connector deletion task. This is run as an async task because it is a somewhat slow job. - Needs to potentially update a large number of Postgres and Vespa docs, including deleting them - or updating the ACL""" - engine = get_sqlalchemy_engine() - with Session(engine) as db_session: - # validate that the connector / credential pair is deletable - cc_pair = get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - if not cc_pair: - raise ValueError( - f"Cannot run deletion attempt - connector_credential_pair with Connector ID: " - f"{connector_id} and Credential ID: {credential_id} does not exist." - ) - - deletion_attempt_disallowed_reason = check_deletion_attempt_is_allowed( - connector_credential_pair=cc_pair, db_session=db_session - ) - if deletion_attempt_disallowed_reason: - raise ValueError(deletion_attempt_disallowed_reason) - - try: - # The bulk of the work is in here, updates Postgres and Vespa - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - return delete_connector_credential_pair( - db_session=db_session, - document_index=document_index, - cc_pair=cc_pair, - ) - except Exception as e: - logger.exception(f"Failed to run connector_deletion due to {e}") - raise e - - -@build_celery_task_wrapper(name_cc_prune_task) -@celery_app.task(soft_time_limit=JOB_TIMEOUT) -def prune_documents_task(connector_id: int, credential_id: int) -> None: - """connector pruning task. For a cc pair, this task pulls all document IDs from the source - and compares those IDs to locally stored documents and deletes all locally stored IDs missing - from the most recently pulled document ID list""" - with Session(get_sqlalchemy_engine()) as db_session: - try: - cc_pair = get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - - if not cc_pair: - logger.warning(f"ccpair not found for {connector_id} {credential_id}") - return - - runnable_connector = instantiate_connector( - cc_pair.connector.source, - InputType.PRUNE, - cc_pair.connector.connector_specific_config, - cc_pair.credential, - db_session, - ) - - all_connector_doc_ids: set[str] = extract_ids_from_runnable_connector( - runnable_connector - ) - - all_indexed_document_ids = { - doc.id - for doc in get_documents_for_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - } - - doc_ids_to_remove = list(all_indexed_document_ids - all_connector_doc_ids) - - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - if len(doc_ids_to_remove) == 0: - logger.info( - f"No docs to prune from {cc_pair.connector.source} connector" - ) - return - - logger.info( - f"pruning {len(doc_ids_to_remove)} doc(s) from {cc_pair.connector.source} connector" - ) - delete_connector_credential_pair_batch( - document_ids=doc_ids_to_remove, - connector_id=connector_id, - credential_id=credential_id, - document_index=document_index, - ) - except Exception as e: - logger.exception( - f"Failed to run pruning for connector id {connector_id} due to {e}" - ) - raise e - - -@build_celery_task_wrapper(name_document_set_sync_task) -@celery_app.task(soft_time_limit=JOB_TIMEOUT) -def sync_document_set_task(document_set_id: int) -> None: - """For document sets marked as not up to date, sync the state from postgres - into the datastore. Also handles deletions.""" - - def _sync_document_batch(document_ids: list[str], db_session: Session) -> None: - logger.debug(f"Syncing document sets for: {document_ids}") - - # Acquires a lock on the documents so that no other process can modify them - with prepare_to_modify_documents( - db_session=db_session, document_ids=document_ids - ): - # get current state of document sets for these documents - document_set_map = { - document_id: document_sets - for document_id, document_sets in fetch_document_sets_for_documents( - document_ids=document_ids, db_session=db_session - ) - } - - # update Vespa - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - update_requests = [ - UpdateRequest( - document_ids=[document_id], - document_sets=set(document_set_map.get(document_id, [])), - ) - for document_id in document_ids - ] - document_index.update(update_requests=update_requests) - - with Session(get_sqlalchemy_engine()) as db_session: - try: - cursor = None - while True: - document_batch, cursor = fetch_documents_for_document_set_paginated( - document_set_id=document_set_id, - db_session=db_session, - current_only=False, - last_document_id=cursor, - limit=_SYNC_BATCH_SIZE, - ) - _sync_document_batch( - document_ids=[document.id for document in document_batch], - db_session=db_session, - ) - if cursor is None: - break - - # if there are no connectors, then delete the document set. Otherwise, just - # mark it as successfully synced. - document_set = cast( - DocumentSet, - get_document_set_by_id( - db_session=db_session, document_set_id=document_set_id - ), - ) # casting since we "know" a document set with this ID exists - if not document_set.connector_credential_pairs: - delete_document_set( - document_set_row=document_set, db_session=db_session - ) - logger.info( - f"Successfully deleted document set with ID: '{document_set_id}'!" - ) - else: - mark_document_set_as_synced( - document_set_id=document_set_id, db_session=db_session - ) - logger.info(f"Document set sync for '{document_set_id}' complete!") - - except Exception: - logger.exception("Failed to sync document set %s", document_set_id) - raise - - -##### -# Periodic Tasks -##### -@celery_app.task( - name="check_for_document_sets_sync_task", - soft_time_limit=JOB_TIMEOUT, -) -def check_for_document_sets_sync_task() -> None: - """Runs periodically to check if any sync tasks should be run and adds them - to the queue""" - with Session(get_sqlalchemy_engine()) as db_session: - # check if any document sets are not synced - document_set_info = fetch_document_sets( - user_id=None, db_session=db_session, include_outdated=True - ) - for document_set, _ in document_set_info: - if should_sync_doc_set(document_set, db_session): - logger.info(f"Syncing the {document_set.name} document set") - sync_document_set_task.apply_async( - kwargs=dict(document_set_id=document_set.id), - ) - - -@celery_app.task( - name="check_for_cc_pair_deletion_task", - soft_time_limit=JOB_TIMEOUT, -) -def check_for_cc_pair_deletion_task() -> None: - """Runs periodically to check if any deletion tasks should be run""" - with Session(get_sqlalchemy_engine()) as db_session: - # check if any document sets are not synced - cc_pairs = get_connector_credential_pairs(db_session) - for cc_pair in cc_pairs: - if should_kick_off_deletion_of_cc_pair(cc_pair, db_session): - logger.notice(f"Deleting the {cc_pair.name} connector credential pair") - cleanup_connector_credential_pair_task.apply_async( - kwargs=dict( - connector_id=cc_pair.connector.id, - credential_id=cc_pair.credential.id, - ), - ) - - -@celery_app.task( - name="kombu_message_cleanup_task", - soft_time_limit=JOB_TIMEOUT, - bind=True, - base=AbortableTask, -) -def kombu_message_cleanup_task(self: Any) -> int: - """Runs periodically to clean up the kombu_message table""" - - # we will select messages older than this amount to clean up - KOMBU_MESSAGE_CLEANUP_AGE = 7 # days - KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT = 1000 - - ctx = {} - ctx["last_processed_id"] = 0 - ctx["deleted"] = 0 - ctx["cleanup_age"] = KOMBU_MESSAGE_CLEANUP_AGE - ctx["page_limit"] = KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT - with Session(get_sqlalchemy_engine()) as db_session: - # Exit the task if we can't take the advisory lock - result = db_session.execute( - text("SELECT pg_try_advisory_lock(:id)"), - {"id": PostgresAdvisoryLocks.KOMBU_MESSAGE_CLEANUP_LOCK_ID.value}, - ).scalar() - if not result: - return 0 - - while True: - if self.is_aborted(): - raise TaskRevokedError("kombu_message_cleanup_task was aborted.") - - b = kombu_message_cleanup_task_helper(ctx, db_session) - if not b: - break - - db_session.commit() - - if ctx["deleted"] > 0: - logger.info(f"Deleted {ctx['deleted']} orphaned messages from kombu_message.") - - return ctx["deleted"] - - -def kombu_message_cleanup_task_helper(ctx: dict, db_session: Session) -> bool: - """ - Helper function to clean up old messages from the `kombu_message` table that are no longer relevant. - - This function retrieves messages from the `kombu_message` table that are no longer visible and - older than a specified interval. It checks if the corresponding task_id exists in the - `celery_taskmeta` table. If the task_id does not exist, the message is deleted. - - Args: - ctx (dict): A context dictionary containing configuration parameters such as: - - 'cleanup_age' (int): The age in days after which messages are considered old. - - 'page_limit' (int): The maximum number of messages to process in one batch. - - 'last_processed_id' (int): The ID of the last processed message to handle pagination. - - 'deleted' (int): A counter to track the number of deleted messages. - db_session (Session): The SQLAlchemy database session for executing queries. - - Returns: - bool: Returns True if there are more rows to process, False if not. - """ - - query = text( - """ - SELECT id, timestamp, payload - FROM kombu_message WHERE visible = 'false' - AND timestamp < CURRENT_TIMESTAMP - INTERVAL :interval_days - AND id > :last_processed_id - ORDER BY id - LIMIT :page_limit -""" - ) - kombu_messages = db_session.execute( - query, - { - "interval_days": f"{ctx['cleanup_age']} days", - "page_limit": ctx["page_limit"], - "last_processed_id": ctx["last_processed_id"], - }, - ).fetchall() - - if len(kombu_messages) == 0: - return False - - for msg in kombu_messages: - payload = json.loads(msg[2]) - task_id = payload["headers"]["id"] - - # Check if task_id exists in celery_taskmeta - task_exists = db_session.execute( - text("SELECT 1 FROM celery_taskmeta WHERE task_id = :task_id"), - {"task_id": task_id}, - ).fetchone() - - # If task_id does not exist, delete the message - if not task_exists: - result = db_session.execute( - text("DELETE FROM kombu_message WHERE id = :message_id"), - {"message_id": msg[0]}, - ) - if result.rowcount > 0: # type: ignore - ctx["deleted"] += 1 - else: - task_name = payload["headers"]["task"] - logger.warning( - f"Message found for task older than {ctx['cleanup_age']} days. " - f"id={task_id} name={task_name}" - ) - - ctx["last_processed_id"] = msg[0] - - return True - - -@celery_app.task( - name="check_for_prune_task", - soft_time_limit=JOB_TIMEOUT, -) -def check_for_prune_task() -> None: - """Runs periodically to check if any prune tasks should be run and adds them - to the queue""" - - with Session(get_sqlalchemy_engine()) as db_session: - all_cc_pairs = get_connector_credential_pairs(db_session) - - for cc_pair in all_cc_pairs: - if should_prune_cc_pair( - connector=cc_pair.connector, - credential=cc_pair.credential, - db_session=db_session, - ): - logger.info(f"Pruning the {cc_pair.connector.name} connector") - - prune_documents_task.apply_async( - kwargs=dict( - connector_id=cc_pair.connector.id, - credential_id=cc_pair.credential.id, - ) - ) - - -##### -# Celery Beat (Periodic Tasks) Settings -##### -celery_app.conf.beat_schedule = { - "check-for-document-set-sync": { - "task": "check_for_document_sets_sync_task", - "schedule": timedelta(seconds=5), - }, - "check-for-cc-pair-deletion": { - "task": "check_for_cc_pair_deletion_task", - # don't need to check too often, since we kick off a deletion initially - # during the API call that actually marks the CC pair for deletion - "schedule": timedelta(minutes=1), - }, -} -celery_app.conf.beat_schedule.update( - { - "check-for-prune": { - "task": "check_for_prune_task", - "schedule": timedelta(seconds=5), - }, - } -) -celery_app.conf.beat_schedule.update( - { - "kombu-message-cleanup": { - "task": "kombu_message_cleanup_task", - "schedule": timedelta(seconds=3600), - }, - } -) diff --git a/backend/danswer/background/celery/celery_run.py b/backend/danswer/background/celery/celery_run.py deleted file mode 100644 index 0fdb2f044a8..00000000000 --- a/backend/danswer/background/celery/celery_run.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Entry point for running celery worker / celery beat.""" -from danswer.utils.variable_functionality import fetch_versioned_implementation -from danswer.utils.variable_functionality import set_is_ee_based_on_env_variable - - -set_is_ee_based_on_env_variable() -celery_app = fetch_versioned_implementation( - "danswer.background.celery.celery_app", "celery_app" -) diff --git a/backend/danswer/background/celery/celery_utils.py b/backend/danswer/background/celery/celery_utils.py deleted file mode 100644 index e4d4d13bb1d..00000000000 --- a/backend/danswer/background/celery/celery_utils.py +++ /dev/null @@ -1,170 +0,0 @@ -from datetime import datetime -from datetime import timezone - -from sqlalchemy.orm import Session - -from danswer.background.task_utils import name_cc_cleanup_task -from danswer.background.task_utils import name_cc_prune_task -from danswer.background.task_utils import name_document_set_sync_task -from danswer.configs.app_configs import ALLOW_SIMULTANEOUS_PRUNING -from danswer.configs.app_configs import MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) -from danswer.connectors.interfaces import BaseConnector -from danswer.connectors.interfaces import IdConnector -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.models import Document -from danswer.db.deletion_attempt import check_deletion_attempt_is_allowed -from danswer.db.engine import get_db_current_time -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.models import Connector -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Credential -from danswer.db.models import DocumentSet -from danswer.db.models import TaskQueueState -from danswer.db.tasks import check_task_is_live_and_not_timed_out -from danswer.db.tasks import get_latest_task -from danswer.db.tasks import get_latest_task_by_type -from danswer.server.documents.models import DeletionAttemptSnapshot -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _get_deletion_status( - connector_id: int, credential_id: int, db_session: Session -) -> TaskQueueState | None: - cleanup_task_name = name_cc_cleanup_task( - connector_id=connector_id, credential_id=credential_id - ) - return get_latest_task(task_name=cleanup_task_name, db_session=db_session) - - -def get_deletion_attempt_snapshot( - connector_id: int, credential_id: int, db_session: Session -) -> DeletionAttemptSnapshot | None: - deletion_task = _get_deletion_status(connector_id, credential_id, db_session) - if not deletion_task: - return None - - return DeletionAttemptSnapshot( - connector_id=connector_id, - credential_id=credential_id, - status=deletion_task.status, - ) - - -def should_kick_off_deletion_of_cc_pair( - cc_pair: ConnectorCredentialPair, db_session: Session -) -> bool: - if cc_pair.status != ConnectorCredentialPairStatus.DELETING: - return False - - if check_deletion_attempt_is_allowed(cc_pair, db_session): - return False - - deletion_task = _get_deletion_status( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - db_session=db_session, - ) - if deletion_task and check_task_is_live_and_not_timed_out( - deletion_task, - db_session, - # 1 hour timeout - timeout=60 * 60, - ): - return False - - return True - - -def should_sync_doc_set(document_set: DocumentSet, db_session: Session) -> bool: - if document_set.is_up_to_date: - return False - - task_name = name_document_set_sync_task(document_set.id) - latest_sync = get_latest_task(task_name, db_session) - - if latest_sync and check_task_is_live_and_not_timed_out(latest_sync, db_session): - logger.info(f"Document set '{document_set.id}' is already syncing. Skipping.") - return False - - logger.info(f"Document set {document_set.id} syncing now.") - return True - - -def should_prune_cc_pair( - connector: Connector, credential: Credential, db_session: Session -) -> bool: - if not connector.prune_freq: - return False - - pruning_task_name = name_cc_prune_task( - connector_id=connector.id, credential_id=credential.id - ) - last_pruning_task = get_latest_task(pruning_task_name, db_session) - current_db_time = get_db_current_time(db_session) - - if not last_pruning_task: - time_since_initialization = current_db_time - connector.time_created - if time_since_initialization.total_seconds() >= connector.prune_freq: - return True - return False - - if not ALLOW_SIMULTANEOUS_PRUNING: - pruning_type_task_name = name_cc_prune_task() - last_pruning_type_task = get_latest_task_by_type( - pruning_type_task_name, db_session - ) - - if last_pruning_type_task and check_task_is_live_and_not_timed_out( - last_pruning_type_task, db_session - ): - return False - - if check_task_is_live_and_not_timed_out(last_pruning_task, db_session): - return False - - if not last_pruning_task.start_time: - return False - - time_since_last_pruning = current_db_time - last_pruning_task.start_time - return time_since_last_pruning.total_seconds() >= connector.prune_freq - - -def document_batch_to_ids(doc_batch: list[Document]) -> set[str]: - return {doc.id for doc in doc_batch} - - -def extract_ids_from_runnable_connector(runnable_connector: BaseConnector) -> set[str]: - """ - If the PruneConnector hasnt been implemented for the given connector, just pull - all docs using the load_from_state and grab out the IDs - """ - all_connector_doc_ids: set[str] = set() - - doc_batch_generator = None - if isinstance(runnable_connector, IdConnector): - all_connector_doc_ids = runnable_connector.retrieve_all_source_ids() - elif isinstance(runnable_connector, LoadConnector): - doc_batch_generator = runnable_connector.load_from_state() - elif isinstance(runnable_connector, PollConnector): - start = datetime(1970, 1, 1, tzinfo=timezone.utc).timestamp() - end = datetime.now(timezone.utc).timestamp() - doc_batch_generator = runnable_connector.poll_source(start=start, end=end) - else: - raise RuntimeError("Pruning job could not find a valid runnable_connector.") - - if doc_batch_generator: - doc_batch_processing_func = document_batch_to_ids - if MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE: - doc_batch_processing_func = rate_limit_builder( - max_calls=MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE, period=60 - )(document_batch_to_ids) - for doc_batch in doc_batch_generator: - all_connector_doc_ids.update(doc_batch_processing_func(doc_batch)) - - return all_connector_doc_ids diff --git a/backend/danswer/background/connector_deletion.py b/backend/danswer/background/connector_deletion.py deleted file mode 100644 index 90883564910..00000000000 --- a/backend/danswer/background/connector_deletion.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -To delete a connector / credential pair: -(1) find all documents associated with connector / credential pair where there -this the is only connector / credential pair that has indexed it -(2) delete all documents from document stores -(3) delete all entries from postgres -(4) find all documents associated with connector / credential pair where there -are multiple connector / credential pairs that have indexed it -(5) update document store entries to remove access associated with the -connector / credential pair from the access list -(6) delete all relevant entries from postgres -""" -from sqlalchemy.orm import Session - -from danswer.access.access import get_access_for_documents -from danswer.db.connector import fetch_connector_by_id -from danswer.db.connector_credential_pair import ( - delete_connector_credential_pair__no_commit, -) -from danswer.db.document import delete_document_by_connector_credential_pair__no_commit -from danswer.db.document import delete_documents_complete__no_commit -from danswer.db.document import get_document_connector_cnts -from danswer.db.document import get_documents_for_connector_credential_pair -from danswer.db.document import prepare_to_modify_documents -from danswer.db.document_set import delete_document_set_cc_pair_relationship__no_commit -from danswer.db.document_set import fetch_document_sets_for_documents -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.index_attempt import delete_index_attempts -from danswer.db.models import ConnectorCredentialPair -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import UpdateRequest -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import ( - fetch_versioned_implementation_with_fallback, -) -from danswer.utils.variable_functionality import noop_fallback - -logger = setup_logger() - -_DELETION_BATCH_SIZE = 1000 - - -def delete_connector_credential_pair_batch( - document_ids: list[str], - connector_id: int, - credential_id: int, - document_index: DocumentIndex, -) -> None: - """ - Removes a batch of documents ids from a cc-pair. If no other cc-pair uses a document anymore - it gets permanently deleted. - """ - with Session(get_sqlalchemy_engine()) as db_session: - # acquire lock for all documents in this batch so that indexing can't - # override the deletion - with prepare_to_modify_documents( - db_session=db_session, document_ids=document_ids - ): - document_connector_cnts = get_document_connector_cnts( - db_session=db_session, document_ids=document_ids - ) - - # figure out which docs need to be completely deleted - document_ids_to_delete = [ - document_id for document_id, cnt in document_connector_cnts if cnt == 1 - ] - logger.debug(f"Deleting documents: {document_ids_to_delete}") - - document_index.delete(doc_ids=document_ids_to_delete) - - delete_documents_complete__no_commit( - db_session=db_session, - document_ids=document_ids_to_delete, - ) - - # figure out which docs need to be updated - document_ids_to_update = [ - document_id for document_id, cnt in document_connector_cnts if cnt > 1 - ] - - # maps document id to list of document set names - new_doc_sets_for_documents: dict[str, set[str]] = { - document_id_and_document_set_names_tuple[0]: set( - document_id_and_document_set_names_tuple[1] - ) - for document_id_and_document_set_names_tuple in fetch_document_sets_for_documents( - db_session=db_session, - document_ids=document_ids_to_update, - ) - } - - # determine future ACLs for documents in batch - access_for_documents = get_access_for_documents( - document_ids=document_ids_to_update, - db_session=db_session, - ) - - # update Vespa - logger.debug(f"Updating documents: {document_ids_to_update}") - update_requests = [ - UpdateRequest( - document_ids=[document_id], - access=access, - document_sets=new_doc_sets_for_documents[document_id], - ) - for document_id, access in access_for_documents.items() - ] - document_index.update(update_requests=update_requests) - - # clean up Postgres - delete_document_by_connector_credential_pair__no_commit( - db_session=db_session, - document_ids=document_ids_to_update, - connector_credential_pair_identifier=ConnectorCredentialPairIdentifier( - connector_id=connector_id, - credential_id=credential_id, - ), - ) - db_session.commit() - - -def delete_connector_credential_pair( - db_session: Session, - document_index: DocumentIndex, - cc_pair: ConnectorCredentialPair, -) -> int: - connector_id = cc_pair.connector_id - credential_id = cc_pair.credential_id - - num_docs_deleted = 0 - while True: - documents = get_documents_for_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - limit=_DELETION_BATCH_SIZE, - ) - if not documents: - break - - delete_connector_credential_pair_batch( - document_ids=[document.id for document in documents], - connector_id=connector_id, - credential_id=credential_id, - document_index=document_index, - ) - num_docs_deleted += len(documents) - - # clean up the rest of the related Postgres entities - # index attempts - delete_index_attempts( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - - # document sets - delete_document_set_cc_pair_relationship__no_commit( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - - # user groups - cleanup_user_groups = fetch_versioned_implementation_with_fallback( - "danswer.db.user_group", - "delete_user_group_cc_pair_relationship__no_commit", - noop_fallback, - ) - cleanup_user_groups( - cc_pair_id=cc_pair.id, - db_session=db_session, - ) - - # finally, delete the cc-pair - delete_connector_credential_pair__no_commit( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - # if there are no credentials left, delete the connector - connector = fetch_connector_by_id( - db_session=db_session, - connector_id=connector_id, - ) - if not connector or not len(connector.credentials): - logger.info("Found no credentials left for connector, deleting connector") - db_session.delete(connector) - db_session.commit() - - logger.notice( - "Successfully deleted connector_credential_pair with connector_id:" - f" '{connector_id}' and credential_id: '{credential_id}'. Deleted {num_docs_deleted} docs." - ) - return num_docs_deleted diff --git a/backend/danswer/background/indexing/checkpointing.py b/backend/danswer/background/indexing/checkpointing.py deleted file mode 100644 index ec3ce5c8ffa..00000000000 --- a/backend/danswer/background/indexing/checkpointing.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Experimental functionality related to splitting up indexing -into a series of checkpoints to better handle intermittent failures -/ jobs being killed by cloud providers.""" -import datetime - -from danswer.configs.app_configs import EXPERIMENTAL_CHECKPOINTING_ENABLED -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import datetime_to_utc - - -def _2010_dt() -> datetime.datetime: - return datetime.datetime(year=2010, month=1, day=1, tzinfo=datetime.timezone.utc) - - -def _2020_dt() -> datetime.datetime: - return datetime.datetime(year=2020, month=1, day=1, tzinfo=datetime.timezone.utc) - - -def _default_end_time( - last_successful_run: datetime.datetime | None, -) -> datetime.datetime: - """If year is before 2010, go to the beginning of 2010. - If year is 2010-2020, go in 5 year increments. - If year > 2020, then go in 180 day increments. - - For connectors that don't support a `filter_by` and instead rely on `sort_by` - for polling, then this will cause a massive duplication of fetches. For these - connectors, you may want to override this function to return a more reasonable - plan (e.g. extending the 2020+ windows to 6 months, 1 year, or higher).""" - last_successful_run = ( - datetime_to_utc(last_successful_run) if last_successful_run else None - ) - if last_successful_run is None or last_successful_run < _2010_dt(): - return _2010_dt() - - if last_successful_run < _2020_dt(): - return min(last_successful_run + datetime.timedelta(days=365 * 5), _2020_dt()) - - return last_successful_run + datetime.timedelta(days=180) - - -def find_end_time_for_indexing_attempt( - last_successful_run: datetime.datetime | None, - # source_type can be used to override the default for certain connectors, currently unused - source_type: DocumentSource, -) -> datetime.datetime | None: - """Is the current time unless the connector is run over a large period, in which case it is - split up into large time segments that become smaller as it approaches the present - """ - # NOTE: source_type can be used to override the default for certain connectors - end_of_window = _default_end_time(last_successful_run) - now = datetime.datetime.now(tz=datetime.timezone.utc) - if end_of_window < now: - return end_of_window - - # None signals that we should index up to current time - return None - - -def get_time_windows_for_index_attempt( - last_successful_run: datetime.datetime, source_type: DocumentSource -) -> list[tuple[datetime.datetime, datetime.datetime]]: - if not EXPERIMENTAL_CHECKPOINTING_ENABLED: - return [(last_successful_run, datetime.datetime.now(tz=datetime.timezone.utc))] - - time_windows: list[tuple[datetime.datetime, datetime.datetime]] = [] - start_of_window: datetime.datetime | None = last_successful_run - while start_of_window: - end_of_window = find_end_time_for_indexing_attempt( - last_successful_run=start_of_window, source_type=source_type - ) - time_windows.append( - ( - start_of_window, - end_of_window or datetime.datetime.now(tz=datetime.timezone.utc), - ) - ) - start_of_window = end_of_window - - return time_windows diff --git a/backend/danswer/background/indexing/dask_utils.py b/backend/danswer/background/indexing/dask_utils.py deleted file mode 100644 index 84335041dc4..00000000000 --- a/backend/danswer/background/indexing/dask_utils.py +++ /dev/null @@ -1,33 +0,0 @@ -import asyncio - -import psutil -from dask.distributed import WorkerPlugin -from distributed import Worker - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class ResourceLogger(WorkerPlugin): - def __init__(self, log_interval: int = 60 * 5): - self.log_interval = log_interval - - def setup(self, worker: Worker) -> None: - """This method will be called when the plugin is attached to a worker.""" - self.worker = worker - worker.loop.add_callback(self.log_resources) - - async def log_resources(self) -> None: - """Periodically log CPU and memory usage. - - NOTE: must be async or else will clog up the worker indefinitely due to the fact that - Dask uses Tornado under the hood (which is async)""" - while True: - cpu_percent = psutil.cpu_percent(interval=None) - memory_available_gb = psutil.virtual_memory().available / (1024.0**3) - # You can now log these values or send them to a monitoring service - logger.debug( - f"Worker {self.worker.address}: CPU usage {cpu_percent}%, Memory available {memory_available_gb}GB" - ) - await asyncio.sleep(self.log_interval) diff --git a/backend/danswer/background/indexing/job_client.py b/backend/danswer/background/indexing/job_client.py deleted file mode 100644 index 68d706895fd..00000000000 --- a/backend/danswer/background/indexing/job_client.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Custom client that works similarly to Dask, but simpler and more lightweight. -Dask jobs behaved very strangely - they would die all the time, retries would -not follow the expected behavior, etc. - -NOTE: cannot use Celery directly due to -https://github.com/celery/celery/issues/7007#issuecomment-1740139367""" -from collections.abc import Callable -from dataclasses import dataclass -from multiprocessing import Process -from typing import Any -from typing import Literal -from typing import Optional - -from danswer.db.engine import get_sqlalchemy_engine -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -JobStatusType = ( - Literal["error"] - | Literal["finished"] - | Literal["pending"] - | Literal["running"] - | Literal["cancelled"] -) - - -def _initializer( - func: Callable, args: list | tuple, kwargs: dict[str, Any] | None = None -) -> Any: - """Ensure the parent proc's database connections are not touched - in the new connection pool - - Based on the recommended approach in the SQLAlchemy docs found: - https://docs.sqlalchemy.org/en/20/core/pooling.html#using-connection-pools-with-multiprocessing-or-os-fork - """ - if kwargs is None: - kwargs = {} - - get_sqlalchemy_engine().dispose(close=False) - return func(*args, **kwargs) - - -def _run_in_process( - func: Callable, args: list | tuple, kwargs: dict[str, Any] | None = None -) -> None: - _initializer(func, args, kwargs) - - -@dataclass -class SimpleJob: - """Drop in replacement for `dask.distributed.Future`""" - - id: int - process: Optional["Process"] = None - - def cancel(self) -> bool: - return self.release() - - def release(self) -> bool: - if self.process is not None and self.process.is_alive(): - self.process.terminate() - return True - return False - - @property - def status(self) -> JobStatusType: - if not self.process: - return "pending" - elif self.process.is_alive(): - return "running" - elif self.process.exitcode is None: - return "cancelled" - elif self.process.exitcode > 0: - return "error" - else: - return "finished" - - def done(self) -> bool: - return ( - self.status == "finished" - or self.status == "cancelled" - or self.status == "error" - ) - - def exception(self) -> str: - """Needed to match the Dask API, but not implemented since we don't currently - have a way to get back the exception information from the child process.""" - return ( - f"Job with ID '{self.id}' was killed or encountered an unhandled exception." - ) - - -class SimpleJobClient: - """Drop in replacement for `dask.distributed.Client`""" - - def __init__(self, n_workers: int = 1) -> None: - self.n_workers = n_workers - self.job_id_counter = 0 - self.jobs: dict[int, SimpleJob] = {} - - def _cleanup_completed_jobs(self) -> None: - current_job_ids = list(self.jobs.keys()) - for job_id in current_job_ids: - job = self.jobs.get(job_id) - if job and job.done(): - logger.debug(f"Cleaning up job with id: '{job.id}'") - del self.jobs[job.id] - - def submit(self, func: Callable, *args: Any, pure: bool = True) -> SimpleJob | None: - """NOTE: `pure` arg is needed so this can be a drop in replacement for Dask""" - self._cleanup_completed_jobs() - if len(self.jobs) >= self.n_workers: - logger.debug( - f"No available workers to run job. Currently running '{len(self.jobs)}' jobs, with a limit of '{self.n_workers}'." - ) - return None - - job_id = self.job_id_counter - self.job_id_counter += 1 - - process = Process(target=_run_in_process, args=(func, args), daemon=True) - job = SimpleJob(id=job_id, process=process) - process.start() - - self.jobs[job_id] = job - - return job diff --git a/backend/danswer/background/indexing/run_indexing.py b/backend/danswer/background/indexing/run_indexing.py deleted file mode 100644 index a98f4e1f5ad..00000000000 --- a/backend/danswer/background/indexing/run_indexing.py +++ /dev/null @@ -1,420 +0,0 @@ -import time -import traceback -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -from sqlalchemy.orm import Session - -from danswer.background.indexing.checkpointing import get_time_windows_for_index_attempt -from danswer.background.indexing.tracer import DanswerTracer -from danswer.configs.app_configs import INDEXING_SIZE_WARNING_THRESHOLD -from danswer.configs.app_configs import INDEXING_TRACER_INTERVAL -from danswer.configs.app_configs import POLL_CONNECTOR_OFFSET -from danswer.connectors.connector_runner import ConnectorRunner -from danswer.connectors.factory import instantiate_connector -from danswer.connectors.models import IndexAttemptMetadata -from danswer.db.connector_credential_pair import get_last_successful_attempt_time -from danswer.db.connector_credential_pair import update_connector_credential_pair -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.index_attempt import get_index_attempt -from danswer.db.index_attempt import mark_attempt_failed -from danswer.db.index_attempt import mark_attempt_in_progress -from danswer.db.index_attempt import mark_attempt_partially_succeeded -from danswer.db.index_attempt import mark_attempt_succeeded -from danswer.db.index_attempt import update_docs_indexed -from danswer.db.models import IndexAttempt -from danswer.db.models import IndexingStatus -from danswer.db.models import IndexModelStatus -from danswer.document_index.factory import get_default_document_index -from danswer.indexing.embedder import DefaultIndexingEmbedder -from danswer.indexing.indexing_pipeline import build_indexing_pipeline -from danswer.utils.logger import IndexAttemptSingleton -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import global_version - -logger = setup_logger() - -INDEXING_TRACER_NUM_PRINT_ENTRIES = 5 - - -def _get_connector_runner( - db_session: Session, - attempt: IndexAttempt, - start_time: datetime, - end_time: datetime, -) -> ConnectorRunner: - """ - NOTE: `start_time` and `end_time` are only used for poll connectors - - Returns an interator of document batches and whether the returned documents - are the complete list of existing documents of the connector. If the task - of type LOAD_STATE, the list will be considered complete and otherwise incomplete. - """ - task = attempt.connector_credential_pair.connector.input_type - - try: - runnable_connector = instantiate_connector( - attempt.connector_credential_pair.connector.source, - task, - attempt.connector_credential_pair.connector.connector_specific_config, - attempt.connector_credential_pair.credential, - db_session, - ) - except Exception as e: - logger.exception(f"Unable to instantiate connector due to {e}") - # since we failed to even instantiate the connector, we pause the CCPair since - # it will never succeed - update_connector_credential_pair( - db_session=db_session, - connector_id=attempt.connector_credential_pair.connector.id, - credential_id=attempt.connector_credential_pair.credential.id, - status=ConnectorCredentialPairStatus.PAUSED, - ) - raise e - - return ConnectorRunner( - connector=runnable_connector, time_range=(start_time, end_time) - ) - - -def _run_indexing( - db_session: Session, - index_attempt: IndexAttempt, -) -> None: - """ - 1. Get documents which are either new or updated from specified application - 2. Embed and index these documents into the chosen datastore (vespa) - 3. Updates Postgres to record the indexed documents + the outcome of this run - """ - start_time = time.time() - - search_settings = index_attempt.search_settings - index_name = search_settings.index_name - - # Only update cc-pair status for primary index jobs - # Secondary index syncs at the end when swapping - is_primary = search_settings.status == IndexModelStatus.PRESENT - - # Indexing is only done into one index at a time - document_index = get_default_document_index( - primary_index_name=index_name, secondary_index_name=None - ) - - embedding_model = DefaultIndexingEmbedder.from_db_search_settings( - search_settings=search_settings - ) - - indexing_pipeline = build_indexing_pipeline( - attempt_id=index_attempt.id, - embedder=embedding_model, - document_index=document_index, - ignore_time_skip=index_attempt.from_beginning - or (search_settings.status == IndexModelStatus.FUTURE), - db_session=db_session, - ) - - db_cc_pair = index_attempt.connector_credential_pair - db_connector = index_attempt.connector_credential_pair.connector - db_credential = index_attempt.connector_credential_pair.credential - - last_successful_index_time = ( - db_connector.indexing_start.timestamp() - if index_attempt.from_beginning and db_connector.indexing_start is not None - else ( - 0.0 - if index_attempt.from_beginning - else get_last_successful_attempt_time( - connector_id=db_connector.id, - credential_id=db_credential.id, - search_settings=index_attempt.search_settings, - db_session=db_session, - ) - ) - ) - - if INDEXING_TRACER_INTERVAL > 0: - logger.debug(f"Memory tracer starting: interval={INDEXING_TRACER_INTERVAL}") - tracer = DanswerTracer() - tracer.start() - tracer.snap() - - index_attempt_md = IndexAttemptMetadata( - connector_id=db_connector.id, - credential_id=db_credential.id, - ) - - batch_num = 0 - net_doc_change = 0 - document_count = 0 - chunk_count = 0 - run_end_dt = None - for ind, (window_start, window_end) in enumerate( - get_time_windows_for_index_attempt( - last_successful_run=datetime.fromtimestamp( - last_successful_index_time, tz=timezone.utc - ), - source_type=db_connector.source, - ) - ): - try: - window_start = max( - window_start - timedelta(minutes=POLL_CONNECTOR_OFFSET), - datetime(1970, 1, 1, tzinfo=timezone.utc), - ) - - connector_runner = _get_connector_runner( - db_session=db_session, - attempt=index_attempt, - start_time=window_start, - end_time=window_end, - ) - - all_connector_doc_ids: set[str] = set() - - tracer_counter = 0 - if INDEXING_TRACER_INTERVAL > 0: - tracer.snap() - for doc_batch in connector_runner.run(): - # Check if connector is disabled mid run and stop if so unless it's the secondary - # index being built. We want to populate it even for paused connectors - # Often paused connectors are sources that aren't updated frequently but the - # contents still need to be initially pulled. - db_session.refresh(db_connector) - if ( - ( - db_cc_pair.status == ConnectorCredentialPairStatus.PAUSED - and search_settings.status != IndexModelStatus.FUTURE - ) - # if it's deleting, we don't care if this is a secondary index - or db_cc_pair.status == ConnectorCredentialPairStatus.DELETING - ): - # let the `except` block handle this - raise RuntimeError("Connector was disabled mid run") - - db_session.refresh(index_attempt) - if index_attempt.status != IndexingStatus.IN_PROGRESS: - # Likely due to user manually disabling it or model swap - raise RuntimeError("Index Attempt was canceled") - - batch_description = [] - for doc in doc_batch: - batch_description.append(doc.to_short_descriptor()) - - doc_size = 0 - for section in doc.sections: - doc_size += len(section.text) - - if doc_size > INDEXING_SIZE_WARNING_THRESHOLD: - logger.warning( - f"Document size: doc='{doc.to_short_descriptor()}' " - f"size={doc_size} " - f"threshold={INDEXING_SIZE_WARNING_THRESHOLD}" - ) - - logger.debug(f"Indexing batch of documents: {batch_description}") - - index_attempt_md.batch_num = batch_num + 1 # use 1-index for this - new_docs, total_batch_chunks = indexing_pipeline( - document_batch=doc_batch, - index_attempt_metadata=index_attempt_md, - ) - - batch_num += 1 - net_doc_change += new_docs - chunk_count += total_batch_chunks - document_count += len(doc_batch) - all_connector_doc_ids.update(doc.id for doc in doc_batch) - - # commit transaction so that the `update` below begins - # with a brand new transaction. Postgres uses the start - # of the transactions when computing `NOW()`, so if we have - # a long running transaction, the `time_updated` field will - # be inaccurate - db_session.commit() - - # This new value is updated every batch, so UI can refresh per batch update - update_docs_indexed( - db_session=db_session, - index_attempt=index_attempt, - total_docs_indexed=document_count, - new_docs_indexed=net_doc_change, - docs_removed_from_index=0, - ) - - tracer_counter += 1 - if ( - INDEXING_TRACER_INTERVAL > 0 - and tracer_counter % INDEXING_TRACER_INTERVAL == 0 - ): - logger.debug( - f"Running trace comparison for batch {tracer_counter}. interval={INDEXING_TRACER_INTERVAL}" - ) - tracer.snap() - tracer.log_previous_diff(INDEXING_TRACER_NUM_PRINT_ENTRIES) - - run_end_dt = window_end - if is_primary: - update_connector_credential_pair( - db_session=db_session, - connector_id=db_connector.id, - credential_id=db_credential.id, - net_docs=net_doc_change, - run_dt=run_end_dt, - ) - except Exception as e: - logger.exception( - f"Connector run ran into exception after elapsed time: {time.time() - start_time} seconds" - ) - # Only mark the attempt as a complete failure if this is the first indexing window. - # Otherwise, some progress was made - the next run will not start from the beginning. - # In this case, it is not accurate to mark it as a failure. When the next run begins, - # if that fails immediately, it will be marked as a failure. - # - # NOTE: if the connector is manually disabled, we should mark it as a failure regardless - # to give better clarity in the UI, as the next run will never happen. - if ( - ind == 0 - or not db_cc_pair.status.is_active() - or index_attempt.status != IndexingStatus.IN_PROGRESS - ): - mark_attempt_failed( - index_attempt, - db_session, - failure_reason=str(e), - full_exception_trace=traceback.format_exc(), - ) - if is_primary: - update_connector_credential_pair( - db_session=db_session, - connector_id=db_connector.id, - credential_id=db_credential.id, - net_docs=net_doc_change, - ) - - if INDEXING_TRACER_INTERVAL > 0: - tracer.stop() - raise e - - # break => similar to success case. As mentioned above, if the next run fails for the same - # reason it will then be marked as a failure - break - - if INDEXING_TRACER_INTERVAL > 0: - logger.debug( - f"Running trace comparison between start and end of indexing. {tracer_counter} batches processed." - ) - tracer.snap() - tracer.log_first_diff(INDEXING_TRACER_NUM_PRINT_ENTRIES) - tracer.stop() - logger.debug("Memory tracer stopped.") - - if ( - index_attempt_md.num_exceptions > 0 - and index_attempt_md.num_exceptions >= batch_num - ): - mark_attempt_failed( - index_attempt, - db_session, - failure_reason="All batches exceptioned.", - ) - if is_primary: - update_connector_credential_pair( - db_session=db_session, - connector_id=index_attempt.connector_credential_pair.connector.id, - credential_id=index_attempt.connector_credential_pair.credential.id, - ) - raise Exception( - f"Connector failed - All batches exceptioned: batches={batch_num}" - ) - - elapsed_time = time.time() - start_time - - if index_attempt_md.num_exceptions == 0: - mark_attempt_succeeded(index_attempt, db_session) - logger.info( - f"Connector succeeded: " - f"docs={document_count} chunks={chunk_count} elapsed={elapsed_time:.2f}s" - ) - else: - mark_attempt_partially_succeeded(index_attempt, db_session) - logger.info( - f"Connector completed with some errors: " - f"exceptions={index_attempt_md.num_exceptions} " - f"batches={batch_num} " - f"docs={document_count} " - f"chunks={chunk_count} " - f"elapsed={elapsed_time:.2f}s" - ) - - if is_primary: - update_connector_credential_pair( - db_session=db_session, - connector_id=db_connector.id, - credential_id=db_credential.id, - run_dt=run_end_dt, - ) - - -def _prepare_index_attempt(db_session: Session, index_attempt_id: int) -> IndexAttempt: - # make sure that the index attempt can't change in between checking the - # status and marking it as in_progress. This setting will be discarded - # after the next commit: - # https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#setting-isolation-for-individual-transactions - db_session.connection(execution_options={"isolation_level": "SERIALIZABLE"}) # type: ignore - - attempt = get_index_attempt( - db_session=db_session, - index_attempt_id=index_attempt_id, - ) - - if attempt is None: - raise RuntimeError(f"Unable to find IndexAttempt for ID '{index_attempt_id}'") - - if attempt.status != IndexingStatus.NOT_STARTED: - raise RuntimeError( - f"Indexing attempt with ID '{index_attempt_id}' is not in NOT_STARTED status. " - f"Current status is '{attempt.status}'." - ) - - # only commit once, to make sure this all happens in a single transaction - mark_attempt_in_progress(attempt, db_session) - - return attempt - - -def run_indexing_entrypoint(index_attempt_id: int, is_ee: bool = False) -> None: - """Entrypoint for indexing run when using dask distributed. - Wraps the actual logic in a `try` block so that we can catch any exceptions - and mark the attempt as failed.""" - try: - if is_ee: - global_version.set_ee() - - # set the indexing attempt ID so that all log messages from this process - # will have it added as a prefix - IndexAttemptSingleton.set_index_attempt_id(index_attempt_id) - - with Session(get_sqlalchemy_engine()) as db_session: - # make sure that it is valid to run this indexing attempt + mark it - # as in progress - attempt = _prepare_index_attempt(db_session, index_attempt_id) - - logger.info( - f"Indexing starting: " - f"connector='{attempt.connector_credential_pair.connector.name}' " - f"config='{attempt.connector_credential_pair.connector.connector_specific_config}' " - f"credentials='{attempt.connector_credential_pair.connector_id}'" - ) - - _run_indexing(db_session, attempt) - - logger.info( - f"Indexing finished: " - f"connector='{attempt.connector_credential_pair.connector.name}' " - f"config='{attempt.connector_credential_pair.connector.connector_specific_config}' " - f"credentials='{attempt.connector_credential_pair.connector_id}'" - ) - except Exception as e: - logger.exception(f"Indexing job with ID '{index_attempt_id}' failed due to {e}") diff --git a/backend/danswer/background/indexing/tracer.py b/backend/danswer/background/indexing/tracer.py deleted file mode 100644 index baad9623087..00000000000 --- a/backend/danswer/background/indexing/tracer.py +++ /dev/null @@ -1,77 +0,0 @@ -import tracemalloc - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -DANSWER_TRACEMALLOC_FRAMES = 10 - - -class DanswerTracer: - def __init__(self) -> None: - self.snapshot_first: tracemalloc.Snapshot | None = None - self.snapshot_prev: tracemalloc.Snapshot | None = None - self.snapshot: tracemalloc.Snapshot | None = None - - def start(self) -> None: - tracemalloc.start(DANSWER_TRACEMALLOC_FRAMES) - - def stop(self) -> None: - tracemalloc.stop() - - def snap(self) -> None: - snapshot = tracemalloc.take_snapshot() - # Filter out irrelevant frames (e.g., from tracemalloc itself or importlib) - snapshot = snapshot.filter_traces( - ( - tracemalloc.Filter(False, tracemalloc.__file__), # Exclude tracemalloc - tracemalloc.Filter( - False, "" - ), # Exclude importlib - tracemalloc.Filter( - False, "" - ), # Exclude external importlib - ) - ) - - if not self.snapshot_first: - self.snapshot_first = snapshot - - if self.snapshot: - self.snapshot_prev = self.snapshot - - self.snapshot = snapshot - - def log_snapshot(self, numEntries: int) -> None: - if not self.snapshot: - return - - stats = self.snapshot.statistics("traceback") - for s in stats[:numEntries]: - logger.debug(f"Tracer snap: {s}") - for line in s.traceback: - logger.debug(f"* {line}") - - @staticmethod - def log_diff( - snap_current: tracemalloc.Snapshot, - snap_previous: tracemalloc.Snapshot, - numEntries: int, - ) -> None: - stats = snap_current.compare_to(snap_previous, "traceback") - for s in stats[:numEntries]: - logger.debug(f"Tracer diff: {s}") - for line in s.traceback.format(): - logger.debug(f"* {line}") - - def log_previous_diff(self, numEntries: int) -> None: - if not self.snapshot or not self.snapshot_prev: - return - - DanswerTracer.log_diff(self.snapshot, self.snapshot_prev, numEntries) - - def log_first_diff(self, numEntries: int) -> None: - if not self.snapshot or not self.snapshot_first: - return - - DanswerTracer.log_diff(self.snapshot, self.snapshot_first, numEntries) diff --git a/backend/danswer/background/task_utils.py b/backend/danswer/background/task_utils.py deleted file mode 100644 index 6e122678813..00000000000 --- a/backend/danswer/background/task_utils.py +++ /dev/null @@ -1,126 +0,0 @@ -from collections.abc import Callable -from functools import wraps -from typing import Any -from typing import cast -from typing import TypeVar - -from celery import Task -from celery.result import AsyncResult -from sqlalchemy.orm import Session - -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.tasks import mark_task_finished -from danswer.db.tasks import mark_task_start -from danswer.db.tasks import register_task - - -def name_cc_cleanup_task(connector_id: int, credential_id: int) -> str: - return f"cleanup_connector_credential_pair_{connector_id}_{credential_id}" - - -def name_document_set_sync_task(document_set_id: int) -> str: - return f"sync_doc_set_{document_set_id}" - - -def name_cc_prune_task( - connector_id: int | None = None, credential_id: int | None = None -) -> str: - task_name = f"prune_connector_credential_pair_{connector_id}_{credential_id}" - if not connector_id or not credential_id: - task_name = "prune_connector_credential_pair" - return task_name - - -T = TypeVar("T", bound=Callable) - - -def build_run_wrapper(build_name_fn: Callable[..., str]) -> Callable[[T], T]: - """Utility meant to wrap the celery task `run` function in order to - automatically update our custom `task_queue_jobs` table appropriately""" - - def wrap_task_fn(task_fn: T) -> T: - @wraps(task_fn) - def wrapped_task_fn(*args: list, **kwargs: dict) -> Any: - engine = get_sqlalchemy_engine() - - task_name = build_name_fn(*args, **kwargs) - with Session(engine) as db_session: - # mark the task as started - mark_task_start(task_name=task_name, db_session=db_session) - - result = None - exception = None - try: - result = task_fn(*args, **kwargs) - except Exception as e: - exception = e - - with Session(engine) as db_session: - mark_task_finished( - task_name=task_name, - db_session=db_session, - success=exception is None, - ) - - if not exception: - return result - else: - raise exception - - return cast(T, wrapped_task_fn) - - return wrap_task_fn - - -# rough type signature for `apply_async` -AA = TypeVar("AA", bound=Callable[..., AsyncResult]) - - -def build_apply_async_wrapper(build_name_fn: Callable[..., str]) -> Callable[[AA], AA]: - """Utility meant to wrap celery `apply_async` function in order to automatically - update create an entry in our `task_queue_jobs` table""" - - def wrapper(fn: AA) -> AA: - @wraps(fn) - def wrapped_fn( - args: tuple | None = None, - kwargs: dict[str, Any] | None = None, - *other_args: list, - **other_kwargs: dict[str, Any], - ) -> Any: - # `apply_async` takes in args / kwargs directly as arguments - args_for_build_name = args or tuple() - kwargs_for_build_name = kwargs or {} - task_name = build_name_fn(*args_for_build_name, **kwargs_for_build_name) - with Session(get_sqlalchemy_engine()) as db_session: - # mark the task as started - task = fn(args, kwargs, *other_args, **other_kwargs) - register_task(task.id, task_name, db_session) - - return task - - return cast(AA, wrapped_fn) - - return wrapper - - -def build_celery_task_wrapper( - build_name_fn: Callable[..., str] -) -> Callable[[Task], Task]: - """Utility meant to wrap celery task functions in order to automatically - update our custom `task_queue_jobs` table appropriately. - - On task creation (e.g. `apply_async`), a row is inserted into the table with - status `PENDING`. - On task start, the latest row is updated to have status `STARTED`. - On task success, the latest row is updated to have status `SUCCESS`. - On the task raising an unhandled exception, the latest row is updated to have - status `FAILURE`. - """ - - def wrap_task(task: Task) -> Task: - task.run = build_run_wrapper(build_name_fn)(task.run) # type: ignore - task.apply_async = build_apply_async_wrapper(build_name_fn)(task.apply_async) # type: ignore - return task - - return wrap_task diff --git a/backend/danswer/background/update.py b/backend/danswer/background/update.py deleted file mode 100755 index 28abb481143..00000000000 --- a/backend/danswer/background/update.py +++ /dev/null @@ -1,469 +0,0 @@ -import logging -import time -from datetime import datetime - -import dask -from dask.distributed import Client -from dask.distributed import Future -from distributed import LocalCluster -from sqlalchemy.orm import Session - -from danswer.background.indexing.dask_utils import ResourceLogger -from danswer.background.indexing.job_client import SimpleJob -from danswer.background.indexing.job_client import SimpleJobClient -from danswer.background.indexing.run_indexing import run_indexing_entrypoint -from danswer.configs.app_configs import CLEANUP_INDEXING_JOBS_TIMEOUT -from danswer.configs.app_configs import DASK_JOB_CLIENT_ENABLED -from danswer.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP -from danswer.configs.app_configs import NUM_INDEXING_WORKERS -from danswer.configs.app_configs import NUM_SECONDARY_INDEXING_WORKERS -from danswer.configs.constants import POSTGRES_INDEXER_APP_NAME -from danswer.db.connector import fetch_connectors -from danswer.db.connector_credential_pair import fetch_connector_credential_pairs -from danswer.db.engine import get_db_current_time -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.engine import init_sqlalchemy_engine -from danswer.db.index_attempt import create_index_attempt -from danswer.db.index_attempt import get_index_attempt -from danswer.db.index_attempt import get_inprogress_index_attempts -from danswer.db.index_attempt import get_last_attempt_for_cc_pair -from danswer.db.index_attempt import get_not_started_index_attempts -from danswer.db.index_attempt import mark_attempt_failed -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import IndexAttempt -from danswer.db.models import IndexingStatus -from danswer.db.models import IndexModelStatus -from danswer.db.models import SearchSettings -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.db.swap_index import check_index_swap -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.natural_language_processing.search_nlp_models import warm_up_bi_encoder -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import global_version -from danswer.utils.variable_functionality import set_is_ee_based_on_env_variable -from shared_configs.configs import INDEXING_MODEL_SERVER_HOST -from shared_configs.configs import LOG_LEVEL -from shared_configs.configs import MODEL_SERVER_PORT - - -logger = setup_logger() - -# If the indexing dies, it's most likely due to resource constraints, -# restarting just delays the eventual failure, not useful to the user -dask.config.set({"distributed.scheduler.allowed-failures": 0}) - -_UNEXPECTED_STATE_FAILURE_REASON = ( - "Stopped mid run, likely due to the background process being killed" -) - - -def _should_create_new_indexing( - cc_pair: ConnectorCredentialPair, - last_index: IndexAttempt | None, - search_settings_instance: SearchSettings, - secondary_index_building: bool, - db_session: Session, -) -> bool: - connector = cc_pair.connector - - # User can still manually create single indexing attempts via the UI for the - # currently in use index - if DISABLE_INDEX_UPDATE_ON_SWAP: - if ( - search_settings_instance.status == IndexModelStatus.PRESENT - and secondary_index_building - ): - return False - - # When switching over models, always index at least once - if search_settings_instance.status == IndexModelStatus.FUTURE: - if last_index: - # No new index if the last index attempt succeeded - # Once is enough. The model will never be able to swap otherwise. - if last_index.status == IndexingStatus.SUCCESS: - return False - - # No new index if the last index attempt is waiting to start - if last_index.status == IndexingStatus.NOT_STARTED: - return False - - # No new index if the last index attempt is running - if last_index.status == IndexingStatus.IN_PROGRESS: - return False - else: - if connector.id == 0: # Ingestion API - return False - return True - - # If the connector is paused or is the ingestion API, don't index - # NOTE: during an embedding model switch over, the following logic - # is bypassed by the above check for a future model - if not cc_pair.status.is_active() or connector.id == 0: - return False - - if not last_index: - return True - - if connector.refresh_freq is None: - return False - - # Only one scheduled/ongoing job per connector at a time - # this prevents cases where - # (1) the "latest" index_attempt is scheduled so we show - # that in the UI despite another index_attempt being in-progress - # (2) multiple scheduled index_attempts at a time - if ( - last_index.status == IndexingStatus.NOT_STARTED - or last_index.status == IndexingStatus.IN_PROGRESS - ): - return False - - current_db_time = get_db_current_time(db_session) - time_since_index = current_db_time - last_index.time_updated - return time_since_index.total_seconds() >= connector.refresh_freq - - -def _mark_run_failed( - db_session: Session, index_attempt: IndexAttempt, failure_reason: str -) -> None: - """Marks the `index_attempt` row as failed + updates the ` - connector_credential_pair` to reflect that the run failed""" - logger.warning( - f"Marking in-progress attempt 'connector: {index_attempt.connector_credential_pair.connector_id}, " - f"credential: {index_attempt.connector_credential_pair.credential_id}' as failed due to {failure_reason}" - ) - mark_attempt_failed( - index_attempt=index_attempt, - db_session=db_session, - failure_reason=failure_reason, - ) - - -"""Main funcs""" - - -def create_indexing_jobs(existing_jobs: dict[int, Future | SimpleJob]) -> None: - """Creates new indexing jobs for each connector / credential pair which is: - 1. Enabled - 2. `refresh_frequency` time has passed since the last indexing run for this pair - 3. There is not already an ongoing indexing attempt for this pair - """ - with Session(get_sqlalchemy_engine()) as db_session: - ongoing: set[tuple[int | None, int]] = set() - for attempt_id in existing_jobs: - attempt = get_index_attempt( - db_session=db_session, index_attempt_id=attempt_id - ) - if attempt is None: - logger.error( - f"Unable to find IndexAttempt for ID '{attempt_id}' when creating " - "indexing jobs" - ) - continue - ongoing.add( - ( - attempt.connector_credential_pair_id, - attempt.search_settings_id, - ) - ) - - # Get the primary search settings - primary_search_settings = get_current_search_settings(db_session) - search_settings = [primary_search_settings] - - # Check for secondary search settings - secondary_search_settings = get_secondary_search_settings(db_session) - if secondary_search_settings is not None: - # If secondary settings exist, add them to the list - search_settings.append(secondary_search_settings) - - all_connector_credential_pairs = fetch_connector_credential_pairs(db_session) - for cc_pair in all_connector_credential_pairs: - for search_settings_instance in search_settings: - # Check if there is an ongoing indexing attempt for this connector credential pair - if (cc_pair.id, search_settings_instance.id) in ongoing: - continue - - last_attempt = get_last_attempt_for_cc_pair( - cc_pair.id, search_settings_instance.id, db_session - ) - if not _should_create_new_indexing( - cc_pair=cc_pair, - last_index=last_attempt, - search_settings_instance=search_settings_instance, - secondary_index_building=len(search_settings) > 1, - db_session=db_session, - ): - continue - - create_index_attempt( - cc_pair.id, search_settings_instance.id, db_session - ) - - -def cleanup_indexing_jobs( - existing_jobs: dict[int, Future | SimpleJob], - timeout_hours: int = CLEANUP_INDEXING_JOBS_TIMEOUT, -) -> dict[int, Future | SimpleJob]: - existing_jobs_copy = existing_jobs.copy() - - # clean up completed jobs - with Session(get_sqlalchemy_engine()) as db_session: - for attempt_id, job in existing_jobs.items(): - index_attempt = get_index_attempt( - db_session=db_session, index_attempt_id=attempt_id - ) - - # do nothing for ongoing jobs that haven't been stopped - if not job.done(): - if not index_attempt: - continue - - if not index_attempt.is_finished(): - continue - - if job.status == "error": - logger.error(job.exception()) - - job.release() - del existing_jobs_copy[attempt_id] - - if not index_attempt: - logger.error( - f"Unable to find IndexAttempt for ID '{attempt_id}' when cleaning " - "up indexing jobs" - ) - continue - - if ( - index_attempt.status == IndexingStatus.IN_PROGRESS - or job.status == "error" - ): - _mark_run_failed( - db_session=db_session, - index_attempt=index_attempt, - failure_reason=_UNEXPECTED_STATE_FAILURE_REASON, - ) - - # clean up in-progress jobs that were never completed - connectors = fetch_connectors(db_session) - for connector in connectors: - in_progress_indexing_attempts = get_inprogress_index_attempts( - connector.id, db_session - ) - for index_attempt in in_progress_indexing_attempts: - if index_attempt.id in existing_jobs: - # If index attempt is canceled, stop the run - if index_attempt.status == IndexingStatus.FAILED: - existing_jobs[index_attempt.id].cancel() - # check to see if the job has been updated in last `timeout_hours` hours, if not - # assume it to frozen in some bad state and just mark it as failed. Note: this relies - # on the fact that the `time_updated` field is constantly updated every - # batch of documents indexed - current_db_time = get_db_current_time(db_session=db_session) - time_since_update = current_db_time - index_attempt.time_updated - if time_since_update.total_seconds() > 60 * 60 * timeout_hours: - existing_jobs[index_attempt.id].cancel() - _mark_run_failed( - db_session=db_session, - index_attempt=index_attempt, - failure_reason="Indexing run frozen - no updates in the last three hours. " - "The run will be re-attempted at next scheduled indexing time.", - ) - else: - # If job isn't known, simply mark it as failed - _mark_run_failed( - db_session=db_session, - index_attempt=index_attempt, - failure_reason=_UNEXPECTED_STATE_FAILURE_REASON, - ) - - return existing_jobs_copy - - -def kickoff_indexing_jobs( - existing_jobs: dict[int, Future | SimpleJob], - client: Client | SimpleJobClient, - secondary_client: Client | SimpleJobClient, -) -> dict[int, Future | SimpleJob]: - existing_jobs_copy = existing_jobs.copy() - engine = get_sqlalchemy_engine() - - # Don't include jobs waiting in the Dask queue that just haven't started running - # Also (rarely) don't include for jobs that started but haven't updated the indexing tables yet - with Session(engine) as db_session: - # get_not_started_index_attempts orders its returned results from oldest to newest - # we must process attempts in a FIFO manner to prevent connector starvation - new_indexing_attempts = [ - (attempt, attempt.search_settings) - for attempt in get_not_started_index_attempts(db_session) - if attempt.id not in existing_jobs - ] - - logger.debug(f"Found {len(new_indexing_attempts)} new indexing task(s).") - - if not new_indexing_attempts: - return existing_jobs - - indexing_attempt_count = 0 - - for attempt, search_settings in new_indexing_attempts: - use_secondary_index = ( - search_settings.status == IndexModelStatus.FUTURE - if search_settings is not None - else False - ) - if attempt.connector_credential_pair.connector is None: - logger.warning( - f"Skipping index attempt as Connector has been deleted: {attempt}" - ) - with Session(engine) as db_session: - mark_attempt_failed( - attempt, db_session, failure_reason="Connector is null" - ) - continue - if attempt.connector_credential_pair.credential is None: - logger.warning( - f"Skipping index attempt as Credential has been deleted: {attempt}" - ) - with Session(engine) as db_session: - mark_attempt_failed( - attempt, db_session, failure_reason="Credential is null" - ) - continue - - if use_secondary_index: - run = secondary_client.submit( - run_indexing_entrypoint, - attempt.id, - global_version.get_is_ee_version(), - pure=False, - ) - else: - run = client.submit( - run_indexing_entrypoint, - attempt.id, - global_version.get_is_ee_version(), - pure=False, - ) - - if run: - if indexing_attempt_count == 0: - logger.info( - f"Indexing dispatch starts: pending={len(new_indexing_attempts)}" - ) - - indexing_attempt_count += 1 - secondary_str = " (secondary index)" if use_secondary_index else "" - logger.info( - f"Indexing dispatched{secondary_str}: " - f"attempt_id={attempt.id} " - f"connector='{attempt.connector_credential_pair.connector.name}' " - f"config='{attempt.connector_credential_pair.connector.connector_specific_config}' " - f"credentials='{attempt.connector_credential_pair.credential_id}'" - ) - existing_jobs_copy[attempt.id] = run - - if indexing_attempt_count > 0: - logger.info( - f"Indexing dispatch results: " - f"initial_pending={len(new_indexing_attempts)} " - f"started={indexing_attempt_count} " - f"remaining={len(new_indexing_attempts) - indexing_attempt_count}" - ) - - return existing_jobs_copy - - -def update_loop( - delay: int = 10, - num_workers: int = NUM_INDEXING_WORKERS, - num_secondary_workers: int = NUM_SECONDARY_INDEXING_WORKERS, -) -> None: - engine = get_sqlalchemy_engine() - with Session(engine) as db_session: - check_index_swap(db_session=db_session) - search_settings = get_current_search_settings(db_session) - - # So that the first time users aren't surprised by really slow speed of first - # batch of documents indexed - - if search_settings.provider_type is None: - logger.notice("Running a first inference to warm up embedding model") - embedding_model = EmbeddingModel.from_db_model( - search_settings=search_settings, - server_host=INDEXING_MODEL_SERVER_HOST, - server_port=MODEL_SERVER_PORT, - ) - - warm_up_bi_encoder( - embedding_model=embedding_model, - ) - - client_primary: Client | SimpleJobClient - client_secondary: Client | SimpleJobClient - if DASK_JOB_CLIENT_ENABLED: - cluster_primary = LocalCluster( - n_workers=num_workers, - threads_per_worker=1, - # there are warning about high memory usage + "Event loop unresponsive" - # which are not relevant to us since our workers are expected to use a - # lot of memory + involve CPU intensive tasks that will not relinquish - # the event loop - silence_logs=logging.ERROR, - ) - cluster_secondary = LocalCluster( - n_workers=num_secondary_workers, - threads_per_worker=1, - silence_logs=logging.ERROR, - ) - client_primary = Client(cluster_primary) - client_secondary = Client(cluster_secondary) - if LOG_LEVEL.lower() == "debug": - client_primary.register_worker_plugin(ResourceLogger()) - else: - client_primary = SimpleJobClient(n_workers=num_workers) - client_secondary = SimpleJobClient(n_workers=num_secondary_workers) - - existing_jobs: dict[int, Future | SimpleJob] = {} - - while True: - start = time.time() - start_time_utc = datetime.utcfromtimestamp(start).strftime("%Y-%m-%d %H:%M:%S") - logger.debug(f"Running update, current UTC time: {start_time_utc}") - - if existing_jobs: - # TODO: make this debug level once the "no jobs are being scheduled" issue is resolved - logger.debug( - "Found existing indexing jobs: " - f"{[(attempt_id, job.status) for attempt_id, job in existing_jobs.items()]}" - ) - - try: - with Session(get_sqlalchemy_engine()) as db_session: - check_index_swap(db_session) - existing_jobs = cleanup_indexing_jobs(existing_jobs=existing_jobs) - create_indexing_jobs(existing_jobs=existing_jobs) - existing_jobs = kickoff_indexing_jobs( - existing_jobs=existing_jobs, - client=client_primary, - secondary_client=client_secondary, - ) - except Exception as e: - logger.exception(f"Failed to run update due to {e}") - sleep_time = delay - (time.time() - start) - if sleep_time > 0: - time.sleep(sleep_time) - - -def update__main() -> None: - set_is_ee_based_on_env_variable() - init_sqlalchemy_engine(POSTGRES_INDEXER_APP_NAME) - - logger.notice("Starting indexing service") - update_loop() - - -if __name__ == "__main__": - update__main() diff --git a/backend/danswer/chat/__init__.py b/backend/danswer/chat/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/chat/chat_utils.py b/backend/danswer/chat/chat_utils.py deleted file mode 100644 index b1e4132779b..00000000000 --- a/backend/danswer/chat/chat_utils.py +++ /dev/null @@ -1,168 +0,0 @@ -import re -from typing import cast - -from sqlalchemy.orm import Session - -from danswer.chat.models import CitationInfo -from danswer.chat.models import LlmDoc -from danswer.db.chat import get_chat_messages_by_session -from danswer.db.models import ChatMessage -from danswer.llm.answering.models import PreviousMessage -from danswer.search.models import InferenceSection -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def llm_doc_from_inference_section(inference_section: InferenceSection) -> LlmDoc: - return LlmDoc( - document_id=inference_section.center_chunk.document_id, - # This one is using the combined content of all the chunks of the section - # In default settings, this is the same as just the content of base chunk - content=inference_section.combined_content, - blurb=inference_section.center_chunk.blurb, - semantic_identifier=inference_section.center_chunk.semantic_identifier, - source_type=inference_section.center_chunk.source_type, - metadata=inference_section.center_chunk.metadata, - updated_at=inference_section.center_chunk.updated_at, - link=inference_section.center_chunk.source_links[0] - if inference_section.center_chunk.source_links - else None, - source_links=inference_section.center_chunk.source_links, - ) - - -def create_chat_chain( - chat_session_id: int, - db_session: Session, - prefetch_tool_calls: bool = True, - # Optional id at which we finish processing - stop_at_message_id: int | None = None, -) -> tuple[ChatMessage, list[ChatMessage]]: - """Build the linear chain of messages without including the root message""" - mainline_messages: list[ChatMessage] = [] - - all_chat_messages = get_chat_messages_by_session( - chat_session_id=chat_session_id, - user_id=None, - db_session=db_session, - skip_permission_check=True, - prefetch_tool_calls=prefetch_tool_calls, - ) - id_to_msg = {msg.id: msg for msg in all_chat_messages} - - if not all_chat_messages: - raise RuntimeError("No messages in Chat Session") - - root_message = all_chat_messages[0] - if root_message.parent_message is not None: - raise RuntimeError( - "Invalid root message, unable to fetch valid chat message sequence" - ) - - current_message: ChatMessage | None = root_message - while current_message is not None: - child_msg = current_message.latest_child_message - - # Break if at the end of the chain - # or have reached the `final_id` of the submitted message - if not child_msg or ( - stop_at_message_id and current_message.id == stop_at_message_id - ): - break - current_message = id_to_msg.get(child_msg) - - if current_message is None: - raise RuntimeError( - "Invalid message chain," - "could not find next message in the same session" - ) - - mainline_messages.append(current_message) - - if not mainline_messages: - raise RuntimeError("Could not trace chat message history") - - return mainline_messages[-1], mainline_messages[:-1] - - -def combine_message_chain( - messages: list[ChatMessage] | list[PreviousMessage], - token_limit: int, - msg_limit: int | None = None, -) -> str: - """Used for secondary LLM flows that require the chat history,""" - message_strs: list[str] = [] - total_token_count = 0 - - if msg_limit is not None: - messages = messages[-msg_limit:] - - for message in cast(list[ChatMessage] | list[PreviousMessage], reversed(messages)): - message_token_count = message.token_count - - if total_token_count + message_token_count > token_limit: - break - - role = message.message_type.value.upper() - message_strs.insert(0, f"{role}:\n{message.message}") - total_token_count += message_token_count - - return "\n\n".join(message_strs) - - -def reorganize_citations( - answer: str, citations: list[CitationInfo] -) -> tuple[str, list[CitationInfo]]: - """For a complete, citation-aware response, we want to reorganize the citations so that - they are in the order of the documents that were used in the response. This just looks nicer / avoids - confusion ("Why is there [7] when only 2 documents are cited?").""" - - # Regular expression to find all instances of [[x]](LINK) - pattern = r"\[\[(.*?)\]\]\((.*?)\)" - - all_citation_matches = re.findall(pattern, answer) - - new_citation_info: dict[int, CitationInfo] = {} - for citation_match in all_citation_matches: - try: - citation_num = int(citation_match[0]) - if citation_num in new_citation_info: - continue - - matching_citation = next( - iter([c for c in citations if c.citation_num == int(citation_num)]), - None, - ) - if matching_citation is None: - continue - - new_citation_info[citation_num] = CitationInfo( - citation_num=len(new_citation_info) + 1, - document_id=matching_citation.document_id, - ) - except Exception: - pass - - # Function to replace citations with their new number - def slack_link_format(match: re.Match) -> str: - link_text = match.group(1) - try: - citation_num = int(link_text) - if citation_num in new_citation_info: - link_text = new_citation_info[citation_num].citation_num - except Exception: - pass - - link_url = match.group(2) - return f"[[{link_text}]]({link_url})" - - # Substitute all matches in the input text - new_answer = re.sub(pattern, slack_link_format, answer) - - # if any citations weren't parsable, just add them back to be safe - for citation in citations: - if citation.citation_num not in new_citation_info: - new_citation_info[citation.citation_num] = citation - - return new_answer, list(new_citation_info.values()) diff --git a/backend/danswer/chat/input_prompts.yaml b/backend/danswer/chat/input_prompts.yaml deleted file mode 100644 index cc7dbe78ea1..00000000000 --- a/backend/danswer/chat/input_prompts.yaml +++ /dev/null @@ -1,24 +0,0 @@ -input_prompts: - - id: -5 - prompt: "Elaborate" - content: "Elaborate on the above, give me a more in depth explanation." - active: true - is_public: true - - - id: -4 - prompt: "Reword" - content: "Help me rewrite the following politely and concisely for professional communication:\n" - active: true - is_public: true - - - id: -3 - prompt: "Email" - content: "Write a professional email for me including a subject line, signature, etc. Template the parts that need editing with [ ]. The email should cover the following points:\n" - active: true - is_public: true - - - id: -2 - prompt: "Debug" - content: "Provide step-by-step troubleshooting instructions for the following issue:\n" - active: true - is_public: true diff --git a/backend/danswer/chat/load_yamls.py b/backend/danswer/chat/load_yamls.py deleted file mode 100644 index 0690f08b759..00000000000 --- a/backend/danswer/chat/load_yamls.py +++ /dev/null @@ -1,165 +0,0 @@ -import yaml -from sqlalchemy.orm import Session - -from danswer.configs.chat_configs import INPUT_PROMPT_YAML -from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT -from danswer.configs.chat_configs import PERSONAS_YAML -from danswer.configs.chat_configs import PROMPTS_YAML -from danswer.db.document_set import get_or_create_document_set_by_name -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.input_prompt import insert_input_prompt_if_not_exists -from danswer.db.models import DocumentSet as DocumentSetDBModel -from danswer.db.models import Persona -from danswer.db.models import Prompt as PromptDBModel -from danswer.db.models import Tool as ToolDBModel -from danswer.db.persona import get_prompt_by_name -from danswer.db.persona import upsert_persona -from danswer.db.persona import upsert_prompt -from danswer.search.enums import RecencyBiasSetting - - -def load_prompts_from_yaml(prompts_yaml: str = PROMPTS_YAML) -> None: - with open(prompts_yaml, "r") as file: - data = yaml.safe_load(file) - - all_prompts = data.get("prompts", []) - with Session(get_sqlalchemy_engine()) as db_session: - for prompt in all_prompts: - upsert_prompt( - user=None, - prompt_id=prompt.get("id"), - name=prompt["name"], - description=prompt["description"].strip(), - system_prompt=prompt["system"].strip(), - task_prompt=prompt["task"].strip(), - include_citations=prompt["include_citations"], - datetime_aware=prompt.get("datetime_aware", True), - default_prompt=True, - personas=None, - db_session=db_session, - commit=True, - ) - - -def load_personas_from_yaml( - personas_yaml: str = PERSONAS_YAML, - default_chunks: float = MAX_CHUNKS_FED_TO_CHAT, -) -> None: - with open(personas_yaml, "r") as file: - data = yaml.safe_load(file) - - all_personas = data.get("personas", []) - with Session(get_sqlalchemy_engine()) as db_session: - for persona in all_personas: - doc_set_names = persona["document_sets"] - doc_sets: list[DocumentSetDBModel] = [ - get_or_create_document_set_by_name(db_session, name) - for name in doc_set_names - ] - - # Assume if user hasn't set any document sets for the persona, the user may want - # to later attach document sets to the persona manually, therefore, don't overwrite/reset - # the document sets for the persona - doc_set_ids: list[int] | None = None - if doc_sets: - doc_set_ids = [doc_set.id for doc_set in doc_sets] - else: - doc_set_ids = None - - prompt_ids: list[int] | None = None - prompt_set_names = persona["prompts"] - if prompt_set_names: - prompts: list[PromptDBModel | None] = [ - get_prompt_by_name(prompt_name, user=None, db_session=db_session) - for prompt_name in prompt_set_names - ] - if any([prompt is None for prompt in prompts]): - raise ValueError("Invalid Persona configs, not all prompts exist") - - if prompts: - prompt_ids = [prompt.id for prompt in prompts if prompt is not None] - - p_id = persona.get("id") - tool_ids = [] - if persona.get("image_generation"): - image_gen_tool = ( - db_session.query(ToolDBModel) - .filter(ToolDBModel.name == "ImageGenerationTool") - .first() - ) - if image_gen_tool: - tool_ids.append(image_gen_tool.id) - - llm_model_provider_override = persona.get("llm_model_provider_override") - llm_model_version_override = persona.get("llm_model_version_override") - - # Set specific overrides for image generation persona - if persona.get("image_generation"): - llm_model_version_override = "gpt-4o" - - existing_persona = ( - db_session.query(Persona) - .filter(Persona.name == persona["name"]) - .first() - ) - - upsert_persona( - user=None, - persona_id=(-1 * p_id) if p_id is not None else None, - name=persona["name"], - description=persona["description"], - num_chunks=persona.get("num_chunks") - if persona.get("num_chunks") is not None - else default_chunks, - llm_relevance_filter=persona.get("llm_relevance_filter"), - starter_messages=persona.get("starter_messages"), - llm_filter_extraction=persona.get("llm_filter_extraction"), - icon_shape=persona.get("icon_shape"), - icon_color=persona.get("icon_color"), - llm_model_provider_override=llm_model_provider_override, - llm_model_version_override=llm_model_version_override, - recency_bias=RecencyBiasSetting(persona["recency_bias"]), - prompt_ids=prompt_ids, - document_set_ids=doc_set_ids, - tool_ids=tool_ids, - default_persona=True, - is_public=True, - display_priority=existing_persona.display_priority - if existing_persona is not None - else persona.get("display_priority"), - is_visible=existing_persona.is_visible - if existing_persona is not None - else persona.get("is_visible"), - db_session=db_session, - ) - - -def load_input_prompts_from_yaml(input_prompts_yaml: str = INPUT_PROMPT_YAML) -> None: - with open(input_prompts_yaml, "r") as file: - data = yaml.safe_load(file) - - all_input_prompts = data.get("input_prompts", []) - with Session(get_sqlalchemy_engine()) as db_session: - for input_prompt in all_input_prompts: - # If these prompts are deleted (which is a hard delete in the DB), on server startup - # they will be recreated, but the user can always just deactivate them, just a light inconvenience - insert_input_prompt_if_not_exists( - user=None, - input_prompt_id=input_prompt.get("id"), - prompt=input_prompt["prompt"], - content=input_prompt["content"], - is_public=input_prompt["is_public"], - active=input_prompt.get("active", True), - db_session=db_session, - commit=True, - ) - - -def load_chat_yamls( - prompt_yaml: str = PROMPTS_YAML, - personas_yaml: str = PERSONAS_YAML, - input_prompts_yaml: str = INPUT_PROMPT_YAML, -) -> None: - load_prompts_from_yaml(prompt_yaml) - load_personas_from_yaml(personas_yaml) - load_input_prompts_from_yaml(input_prompts_yaml) diff --git a/backend/danswer/chat/models.py b/backend/danswer/chat/models.py deleted file mode 100644 index 6d12d68df08..00000000000 --- a/backend/danswer/chat/models.py +++ /dev/null @@ -1,155 +0,0 @@ -from collections.abc import Iterator -from datetime import datetime -from typing import Any - -from pydantic import BaseModel - -from danswer.configs.constants import DocumentSource -from danswer.search.enums import QueryFlow -from danswer.search.enums import SearchType -from danswer.search.models import RetrievalDocs -from danswer.search.models import SearchResponse -from danswer.tools.custom.base_tool_types import ToolResultType - - -class LlmDoc(BaseModel): - """This contains the minimal set information for the LLM portion including citations""" - - document_id: str - content: str - blurb: str - semantic_identifier: str - source_type: DocumentSource - metadata: dict[str, str | list[str]] - updated_at: datetime | None - link: str | None - source_links: dict[int, str] | None - - -# First chunk of info for streaming QA -class QADocsResponse(RetrievalDocs): - rephrased_query: str | None = None - predicted_flow: QueryFlow | None - predicted_search: SearchType | None - applied_source_filters: list[DocumentSource] | None - applied_time_cutoff: datetime | None - recency_bias_multiplier: float - - def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore - initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore - initial_dict["applied_time_cutoff"] = ( - self.applied_time_cutoff.isoformat() if self.applied_time_cutoff else None - ) - - return initial_dict - - -class LLMRelevanceFilterResponse(BaseModel): - relevant_chunk_indices: list[int] - - -class RelevanceAnalysis(BaseModel): - relevant: bool - content: str | None = None - - -class SectionRelevancePiece(RelevanceAnalysis): - """LLM analysis mapped to an Inference Section""" - - document_id: str - chunk_id: int # ID of the center chunk for a given inference section - - -class DocumentRelevance(BaseModel): - """Contains all relevance information for a given search""" - - relevance_summaries: dict[str, RelevanceAnalysis] - - -class DanswerAnswerPiece(BaseModel): - # A small piece of a complete answer. Used for streaming back answers. - answer_piece: str | None # if None, specifies the end of an Answer - - -# An intermediate representation of citations, later translated into -# a mapping of the citation [n] number to SearchDoc -class CitationInfo(BaseModel): - citation_num: int - document_id: str - - -class MessageResponseIDInfo(BaseModel): - user_message_id: int | None - reserved_assistant_message_id: int - - -class StreamingError(BaseModel): - error: str - stack_trace: str | None = None - - -class DanswerQuote(BaseModel): - # This is during inference so everything is a string by this point - quote: str - document_id: str - link: str | None - source_type: str - semantic_identifier: str - blurb: str - - -class DanswerQuotes(BaseModel): - quotes: list[DanswerQuote] - - -class DanswerContext(BaseModel): - content: str - document_id: str - semantic_identifier: str - blurb: str - - -class DanswerContexts(BaseModel): - contexts: list[DanswerContext] - - -class DanswerAnswer(BaseModel): - answer: str | None - - -class QAResponse(SearchResponse, DanswerAnswer): - quotes: list[DanswerQuote] | None - contexts: list[DanswerContexts] | None - predicted_flow: QueryFlow - predicted_search: SearchType - eval_res_valid: bool | None = None - llm_chunks_indices: list[int] | None = None - error_msg: str | None = None - - -class ImageGenerationDisplay(BaseModel): - file_ids: list[str] - - -class CustomToolResponse(BaseModel): - response: ToolResultType - tool_name: str - - -AnswerQuestionPossibleReturn = ( - DanswerAnswerPiece - | DanswerQuotes - | CitationInfo - | DanswerContexts - | ImageGenerationDisplay - | CustomToolResponse - | StreamingError -) - - -AnswerQuestionStreamReturn = Iterator[AnswerQuestionPossibleReturn] - - -class LLMMetricsContainer(BaseModel): - prompt_tokens: int - response_tokens: int diff --git a/backend/danswer/chat/personas.yaml b/backend/danswer/chat/personas.yaml deleted file mode 100644 index 0aececcee6c..00000000000 --- a/backend/danswer/chat/personas.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# Currently in the UI, each Persona only has one prompt, which is why there are 3 very similar personas defined below. - -personas: - # This id field can be left blank for other default personas, however an id 0 persona must exist - # this is for DanswerBot to use when tagged in a non-configured channel - # Careful setting specific IDs, this won't autoincrement the next ID value for postgres - - id: 0 - name: "Knowledge" - description: > - Assistant with access to documents from your Connected Sources. - # Default Prompt objects attached to the persona, see prompts.yaml - prompts: - - "Answer-Question" - # Default number of chunks to include as context, set to 0 to disable retrieval - # Remove the field to set to the system default number of chunks/tokens to pass to Gen AI - # Each chunk is 512 tokens long - num_chunks: 10 - # Enable/Disable usage of the LLM chunk filter feature whereby each chunk is passed to the LLM to determine - # if the chunk is useful or not towards the latest user query - # This feature can be overriden for all personas via DISABLE_LLM_DOC_RELEVANCE env variable - llm_relevance_filter: true - # Enable/Disable usage of the LLM to extract query time filters including source type and time range filters - llm_filter_extraction: true - # Decay documents priority as they age, options are: - # - favor_recent (2x base by default, configurable) - # - base_decay - # - no_decay - # - auto (model chooses between favor_recent and base_decay based on user query) - recency_bias: "auto" - # Default Document Sets for this persona, specified as a list of names here. - # If the document set by the name exists, it will be attached to the persona - # If the document set by the name does not exist, it will be created as an empty document set with no connectors - # The admin can then use the UI to add new connectors to the document set - # Example: - # document_sets: - # - "HR Resources" - # - "Engineer Onboarding" - # - "Benefits" - document_sets: [] - icon_shape: 23013 - icon_color: "#6FB1FF" - display_priority: 1 - is_visible: true - - - id: 1 - name: "General" - description: > - Assistant with no access to documents. Chat with just the Large Language Model. - prompts: - - "OnlyLLM" - num_chunks: 0 - llm_relevance_filter: true - llm_filter_extraction: true - recency_bias: "auto" - document_sets: [] - icon_shape: 50910 - icon_color: "#FF6F6F" - display_priority: 0 - is_visible: true - - - id: 2 - name: "Paraphrase" - description: > - Assistant that is heavily constrained and only provides exact quotes from Connected Sources. - prompts: - - "Paraphrase" - num_chunks: 10 - llm_relevance_filter: true - llm_filter_extraction: true - recency_bias: "auto" - document_sets: [] - icon_shape: 45519 - icon_color: "#6FFF8D" - display_priority: 2 - is_visible: false - - - - id: 3 - name: "Art" - description: > - Assistant for generating images based on descriptions. - prompts: - - "ImageGeneration" - num_chunks: 0 - llm_relevance_filter: false - llm_filter_extraction: false - recency_bias: "no_decay" - document_sets: [] - icon_shape: 234124 - icon_color: "#9B59B6" - image_generation: true - display_priority: 3 - is_visible: true diff --git a/backend/danswer/chat/process_message.py b/backend/danswer/chat/process_message.py deleted file mode 100644 index 2eea2cfc20f..00000000000 --- a/backend/danswer/chat/process_message.py +++ /dev/null @@ -1,816 +0,0 @@ -import traceback -from collections.abc import Callable -from collections.abc import Iterator -from functools import partial -from typing import cast - -from sqlalchemy.orm import Session - -from danswer.chat.chat_utils import create_chat_chain -from danswer.chat.models import CitationInfo -from danswer.chat.models import CustomToolResponse -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import ImageGenerationDisplay -from danswer.chat.models import LLMRelevanceFilterResponse -from danswer.chat.models import MessageResponseIDInfo -from danswer.chat.models import QADocsResponse -from danswer.chat.models import StreamingError -from danswer.configs.chat_configs import BING_API_KEY -from danswer.configs.chat_configs import CHAT_TARGET_CHUNK_PERCENTAGE -from danswer.configs.chat_configs import DISABLE_LLM_CHOOSE_SEARCH -from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT -from danswer.configs.constants import MessageType -from danswer.configs.model_configs import GEN_AI_TEMPERATURE -from danswer.db.chat import attach_files_to_chat_message -from danswer.db.chat import create_db_search_doc -from danswer.db.chat import create_new_chat_message -from danswer.db.chat import get_chat_message -from danswer.db.chat import get_chat_session_by_id -from danswer.db.chat import get_db_search_doc_by_id -from danswer.db.chat import get_doc_query_identifiers_from_model -from danswer.db.chat import get_or_create_root_message -from danswer.db.chat import reserve_message_id -from danswer.db.chat import translate_db_message_to_chat_message_detail -from danswer.db.chat import translate_db_search_doc_to_server_search_doc -from danswer.db.engine import get_session_context_manager -from danswer.db.llm import fetch_existing_llm_providers -from danswer.db.models import SearchDoc as DbSearchDoc -from danswer.db.models import ToolCall -from danswer.db.models import User -from danswer.db.persona import get_persona_by_id -from danswer.db.search_settings import get_current_search_settings -from danswer.document_index.factory import get_default_document_index -from danswer.file_store.models import ChatFileType -from danswer.file_store.models import FileDescriptor -from danswer.file_store.utils import load_all_chat_files -from danswer.file_store.utils import save_files_from_urls -from danswer.llm.answering.answer import Answer -from danswer.llm.answering.models import AnswerStyleConfig -from danswer.llm.answering.models import CitationConfig -from danswer.llm.answering.models import DocumentPruningConfig -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.answering.models import PromptConfig -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.factory import get_main_llm_from_tuple -from danswer.llm.interfaces import LLMConfig -from danswer.llm.utils import litellm_exception_to_error_msg -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import OptionalSearchSetting -from danswer.search.enums import QueryFlow -from danswer.search.enums import SearchType -from danswer.search.models import InferenceSection -from danswer.search.retrieval.search_runner import inference_sections_from_ids -from danswer.search.utils import chunks_or_sections_to_search_docs -from danswer.search.utils import dedupe_documents -from danswer.search.utils import drop_llm_indices -from danswer.search.utils import relevant_sections_to_indices -from danswer.server.query_and_chat.models import ChatMessageDetail -from danswer.server.query_and_chat.models import CreateChatMessageRequest -from danswer.server.utils import get_json_line -from danswer.tools.built_in_tools import get_built_in_tool_by_id -from danswer.tools.custom.custom_tool import build_custom_tools_from_openapi_schema -from danswer.tools.custom.custom_tool import CUSTOM_TOOL_RESPONSE_ID -from danswer.tools.custom.custom_tool import CustomToolCallSummary -from danswer.tools.force import ForceUseTool -from danswer.tools.images.image_generation_tool import IMAGE_GENERATION_RESPONSE_ID -from danswer.tools.images.image_generation_tool import ImageGenerationResponse -from danswer.tools.images.image_generation_tool import ImageGenerationTool -from danswer.tools.internet_search.internet_search_tool import ( - INTERNET_SEARCH_RESPONSE_ID, -) -from danswer.tools.internet_search.internet_search_tool import ( - internet_search_response_to_search_docs, -) -from danswer.tools.internet_search.internet_search_tool import InternetSearchResponse -from danswer.tools.internet_search.internet_search_tool import InternetSearchTool -from danswer.tools.search.search_tool import SEARCH_RESPONSE_SUMMARY_ID -from danswer.tools.search.search_tool import SearchResponseSummary -from danswer.tools.search.search_tool import SearchTool -from danswer.tools.search.search_tool import SECTION_RELEVANCE_LIST_ID -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.tools.tool_runner import ToolCallFinalResult -from danswer.tools.utils import compute_all_tool_tokens -from danswer.tools.utils import explicit_tool_calling_supported -from danswer.utils.logger import setup_logger -from danswer.utils.timing import log_generator_function_time - -logger = setup_logger() - - -def translate_citations( - citations_list: list[CitationInfo], db_docs: list[DbSearchDoc] -) -> dict[int, int]: - """Always cites the first instance of the document_id, assumes the db_docs - are sorted in the order displayed in the UI""" - doc_id_to_saved_doc_id_map: dict[str, int] = {} - for db_doc in db_docs: - if db_doc.document_id not in doc_id_to_saved_doc_id_map: - doc_id_to_saved_doc_id_map[db_doc.document_id] = db_doc.id - - citation_to_saved_doc_id_map: dict[int, int] = {} - for citation in citations_list: - if citation.citation_num not in citation_to_saved_doc_id_map: - citation_to_saved_doc_id_map[ - citation.citation_num - ] = doc_id_to_saved_doc_id_map[citation.document_id] - - return citation_to_saved_doc_id_map - - -def _handle_search_tool_response_summary( - packet: ToolResponse, - db_session: Session, - selected_search_docs: list[DbSearchDoc] | None, - dedupe_docs: bool = False, -) -> tuple[QADocsResponse, list[DbSearchDoc], list[int] | None]: - response_sumary = cast(SearchResponseSummary, packet.response) - - dropped_inds = None - if not selected_search_docs: - top_docs = chunks_or_sections_to_search_docs(response_sumary.top_sections) - - deduped_docs = top_docs - if dedupe_docs: - deduped_docs, dropped_inds = dedupe_documents(top_docs) - - reference_db_search_docs = [ - create_db_search_doc(server_search_doc=doc, db_session=db_session) - for doc in deduped_docs - ] - else: - reference_db_search_docs = selected_search_docs - - response_docs = [ - translate_db_search_doc_to_server_search_doc(db_search_doc) - for db_search_doc in reference_db_search_docs - ] - return ( - QADocsResponse( - rephrased_query=response_sumary.rephrased_query, - top_documents=response_docs, - predicted_flow=response_sumary.predicted_flow, - predicted_search=response_sumary.predicted_search, - applied_source_filters=response_sumary.final_filters.source_type, - applied_time_cutoff=response_sumary.final_filters.time_cutoff, - recency_bias_multiplier=response_sumary.recency_bias_multiplier, - ), - reference_db_search_docs, - dropped_inds, - ) - - -def _handle_internet_search_tool_response_summary( - packet: ToolResponse, - db_session: Session, -) -> tuple[QADocsResponse, list[DbSearchDoc]]: - internet_search_response = cast(InternetSearchResponse, packet.response) - server_search_docs = internet_search_response_to_search_docs( - internet_search_response - ) - - reference_db_search_docs = [ - create_db_search_doc(server_search_doc=doc, db_session=db_session) - for doc in server_search_docs - ] - response_docs = [ - translate_db_search_doc_to_server_search_doc(db_search_doc) - for db_search_doc in reference_db_search_docs - ] - return ( - QADocsResponse( - rephrased_query=internet_search_response.revised_query, - top_documents=response_docs, - predicted_flow=QueryFlow.QUESTION_ANSWER, - predicted_search=SearchType.SEMANTIC, - applied_source_filters=[], - applied_time_cutoff=None, - recency_bias_multiplier=1.0, - ), - reference_db_search_docs, - ) - - -def _get_force_search_settings( - new_msg_req: CreateChatMessageRequest, tools: list[Tool] -) -> ForceUseTool: - internet_search_available = any( - isinstance(tool, InternetSearchTool) for tool in tools - ) - search_tool_available = any(isinstance(tool, SearchTool) for tool in tools) - - if not internet_search_available and not search_tool_available: - # Does not matter much which tool is set here as force is false and neither tool is available - return ForceUseTool(force_use=False, tool_name=SearchTool._NAME) - - tool_name = SearchTool._NAME if search_tool_available else InternetSearchTool._NAME - # Currently, the internet search tool does not support query override - args = ( - {"query": new_msg_req.query_override} - if new_msg_req.query_override and tool_name == SearchTool._NAME - else None - ) - - if new_msg_req.file_descriptors: - # If user has uploaded files they're using, don't run any of the search tools - return ForceUseTool(force_use=False, tool_name=tool_name) - - should_force_search = any( - [ - new_msg_req.retrieval_options - and new_msg_req.retrieval_options.run_search - == OptionalSearchSetting.ALWAYS, - new_msg_req.search_doc_ids, - DISABLE_LLM_CHOOSE_SEARCH, - ] - ) - - if should_force_search: - # If we are using selected docs, just put something here so the Tool doesn't need to build its own args via an LLM call - args = {"query": new_msg_req.message} if new_msg_req.search_doc_ids else args - return ForceUseTool(force_use=True, tool_name=tool_name, args=args) - - return ForceUseTool(force_use=False, tool_name=tool_name, args=args) - - -ChatPacket = ( - StreamingError - | QADocsResponse - | LLMRelevanceFilterResponse - | ChatMessageDetail - | DanswerAnswerPiece - | CitationInfo - | ImageGenerationDisplay - | CustomToolResponse - | MessageResponseIDInfo -) -ChatPacketStream = Iterator[ChatPacket] - - -def stream_chat_message_objects( - new_msg_req: CreateChatMessageRequest, - user: User | None, - db_session: Session, - # Needed to translate persona num_chunks to tokens to the LLM - default_num_chunks: float = MAX_CHUNKS_FED_TO_CHAT, - # For flow with search, don't include as many chunks as possible since we need to leave space - # for the chat history, for smaller models, we likely won't get MAX_CHUNKS_FED_TO_CHAT chunks - max_document_percentage: float = CHAT_TARGET_CHUNK_PERCENTAGE, - # if specified, uses the last user message and does not create a new user message based - # on the `new_msg_req.message`. Currently, requires a state where the last message is a - use_existing_user_message: bool = False, - litellm_additional_headers: dict[str, str] | None = None, - is_connected: Callable[[], bool] | None = None, -) -> ChatPacketStream: - """Streams in order: - 1. [conditional] Retrieved documents if a search needs to be run - 2. [conditional] LLM selected chunk indices if LLM chunk filtering is turned on - 3. [always] A set of streamed LLM tokens or an error anywhere along the line if something fails - 4. [always] Details on the final AI response message that is created - """ - # Currently surrounding context is not supported for chat - # Chat is already token heavy and harder for the model to process plus it would roll history over much faster - new_msg_req.chunks_above = 0 - new_msg_req.chunks_below = 0 - - try: - user_id = user.id if user is not None else None - - chat_session = get_chat_session_by_id( - chat_session_id=new_msg_req.chat_session_id, - user_id=user_id, - db_session=db_session, - ) - - message_text = new_msg_req.message - chat_session_id = new_msg_req.chat_session_id - parent_id = new_msg_req.parent_message_id - reference_doc_ids = new_msg_req.search_doc_ids - retrieval_options = new_msg_req.retrieval_options - alternate_assistant_id = new_msg_req.alternate_assistant_id - - # use alternate persona if alternative assistant id is passed in - if alternate_assistant_id is not None: - persona = get_persona_by_id( - alternate_assistant_id, - user=user, - db_session=db_session, - is_for_edit=False, - ) - else: - persona = chat_session.persona - - prompt_id = new_msg_req.prompt_id - if prompt_id is None and persona.prompts: - prompt_id = sorted(persona.prompts, key=lambda x: x.id)[-1].id - - if reference_doc_ids is None and retrieval_options is None: - raise RuntimeError( - "Must specify a set of documents for chat or specify search options" - ) - - try: - llm, fast_llm = get_llms_for_persona( - persona=persona, - llm_override=new_msg_req.llm_override or chat_session.llm_override, - additional_headers=litellm_additional_headers, - ) - except GenAIDisabledException: - raise RuntimeError("LLM is disabled. Can't use chat flow without LLM.") - - llm_provider = llm.config.model_provider - llm_model_name = llm.config.model_name - - llm_tokenizer = get_tokenizer( - model_name=llm_model_name, - provider_type=llm_provider, - ) - llm_tokenizer_encode_func = cast( - Callable[[str], list[int]], llm_tokenizer.encode - ) - - search_settings = get_current_search_settings(db_session) - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, secondary_index_name=None - ) - - # Every chat Session begins with an empty root message - root_message = get_or_create_root_message( - chat_session_id=chat_session_id, db_session=db_session - ) - - if parent_id is not None: - parent_message = get_chat_message( - chat_message_id=parent_id, - user_id=user_id, - db_session=db_session, - ) - else: - parent_message = root_message - - user_message = None - - if new_msg_req.regenerate: - final_msg, history_msgs = create_chat_chain( - stop_at_message_id=parent_id, - chat_session_id=chat_session_id, - db_session=db_session, - ) - - elif not use_existing_user_message: - # Create new message at the right place in the tree and update the parent's child pointer - # Don't commit yet until we verify the chat message chain - user_message = create_new_chat_message( - chat_session_id=chat_session_id, - parent_message=parent_message, - prompt_id=prompt_id, - message=message_text, - token_count=len(llm_tokenizer_encode_func(message_text)), - message_type=MessageType.USER, - files=None, # Need to attach later for optimization to only load files once in parallel - db_session=db_session, - commit=False, - ) - # re-create linear history of messages - final_msg, history_msgs = create_chat_chain( - chat_session_id=chat_session_id, db_session=db_session - ) - if final_msg.id != user_message.id: - db_session.rollback() - raise RuntimeError( - "The new message was not on the mainline. " - "Be sure to update the chat pointers before calling this." - ) - - # NOTE: do not commit user message - it will be committed when the - # assistant message is successfully generated - else: - # re-create linear history of messages - final_msg, history_msgs = create_chat_chain( - chat_session_id=chat_session_id, db_session=db_session - ) - if final_msg.message_type != MessageType.USER: - raise RuntimeError( - "The last message was not a user message. Cannot call " - "`stream_chat_message_objects` with `is_regenerate=True` " - "when the last message is not a user message." - ) - - # Disable Query Rephrasing for the first message - # This leads to a better first response since the LLM rephrasing the question - # leads to worst search quality - if not history_msgs: - new_msg_req.query_override = ( - new_msg_req.query_override or new_msg_req.message - ) - - # load all files needed for this chat chain in memory - files = load_all_chat_files( - history_msgs, new_msg_req.file_descriptors, db_session - ) - latest_query_files = [ - file - for file in files - if file.file_id in [f["id"] for f in new_msg_req.file_descriptors] - ] - - if user_message: - attach_files_to_chat_message( - chat_message=user_message, - files=[ - new_file.to_file_descriptor() for new_file in latest_query_files - ], - db_session=db_session, - commit=False, - ) - - selected_db_search_docs = None - selected_sections: list[InferenceSection] | None = None - if reference_doc_ids: - identifier_tuples = get_doc_query_identifiers_from_model( - search_doc_ids=reference_doc_ids, - chat_session=chat_session, - user_id=user_id, - db_session=db_session, - ) - - # Generates full documents currently - # May extend to use sections instead in the future - selected_sections = inference_sections_from_ids( - doc_identifiers=identifier_tuples, - document_index=document_index, - ) - document_pruning_config = DocumentPruningConfig( - is_manually_selected_docs=True - ) - - # In case the search doc is deleted, just don't include it - # though this should never happen - db_search_docs_or_none = [ - get_db_search_doc_by_id(doc_id=doc_id, db_session=db_session) - for doc_id in reference_doc_ids - ] - - selected_db_search_docs = [ - db_sd for db_sd in db_search_docs_or_none if db_sd - ] - - else: - document_pruning_config = DocumentPruningConfig( - max_chunks=int( - persona.num_chunks - if persona.num_chunks is not None - else default_num_chunks - ), - max_window_percentage=max_document_percentage, - ) - reserved_message_id = reserve_message_id( - db_session=db_session, - chat_session_id=chat_session_id, - parent_message=user_message.id - if user_message is not None - else parent_message.id, - message_type=MessageType.ASSISTANT, - ) - yield MessageResponseIDInfo( - user_message_id=user_message.id if user_message else None, - reserved_assistant_message_id=reserved_message_id, - ) - - overridden_model = ( - new_msg_req.llm_override.model_version if new_msg_req.llm_override else None - ) - - # Cannot determine these without the LLM step or breaking out early - partial_response = partial( - create_new_chat_message, - chat_session_id=chat_session_id, - parent_message=final_msg, - prompt_id=prompt_id, - overridden_model=overridden_model, - # message=, - # rephrased_query=, - # token_count=, - message_type=MessageType.ASSISTANT, - alternate_assistant_id=new_msg_req.alternate_assistant_id, - # error=, - # reference_docs=, - db_session=db_session, - commit=False, - ) - - if not final_msg.prompt: - raise RuntimeError("No Prompt found") - - prompt_config = ( - PromptConfig.from_model( - final_msg.prompt, - prompt_override=( - new_msg_req.prompt_override or chat_session.prompt_override - ), - ) - if not persona - else PromptConfig.from_model(persona.prompts[0]) - ) - - # find out what tools to use - search_tool: SearchTool | None = None - tool_dict: dict[int, list[Tool]] = {} # tool_id to tool - for db_tool_model in persona.tools: - # handle in-code tools specially - if db_tool_model.in_code_tool_id: - tool_cls = get_built_in_tool_by_id(db_tool_model.id, db_session) - if tool_cls.__name__ == SearchTool.__name__ and not latest_query_files: - search_tool = SearchTool( - db_session=db_session, - user=user, - persona=persona, - retrieval_options=retrieval_options, - prompt_config=prompt_config, - llm=llm, - fast_llm=fast_llm, - pruning_config=document_pruning_config, - selected_sections=selected_sections, - chunks_above=new_msg_req.chunks_above, - chunks_below=new_msg_req.chunks_below, - full_doc=new_msg_req.full_doc, - evaluation_type=LLMEvaluationType.BASIC - if persona.llm_relevance_filter - else LLMEvaluationType.SKIP, - ) - tool_dict[db_tool_model.id] = [search_tool] - elif tool_cls.__name__ == ImageGenerationTool.__name__: - img_generation_llm_config: LLMConfig | None = None - if ( - llm - and llm.config.api_key - and llm.config.model_provider == "openai" - ): - img_generation_llm_config = llm.config - else: - llm_providers = fetch_existing_llm_providers(db_session) - openai_provider = next( - iter( - [ - llm_provider - for llm_provider in llm_providers - if llm_provider.provider == "openai" - ] - ), - None, - ) - if not openai_provider or not openai_provider.api_key: - raise ValueError( - "Image generation tool requires an OpenAI API key" - ) - img_generation_llm_config = LLMConfig( - model_provider=openai_provider.provider, - model_name=openai_provider.default_model_name, - temperature=GEN_AI_TEMPERATURE, - api_key=openai_provider.api_key, - api_base=openai_provider.api_base, - api_version=openai_provider.api_version, - ) - tool_dict[db_tool_model.id] = [ - ImageGenerationTool( - api_key=cast(str, img_generation_llm_config.api_key), - api_base=img_generation_llm_config.api_base, - api_version=img_generation_llm_config.api_version, - additional_headers=litellm_additional_headers, - ) - ] - elif tool_cls.__name__ == InternetSearchTool.__name__: - bing_api_key = BING_API_KEY - if not bing_api_key: - raise ValueError( - "Internet search tool requires a Bing API key, please contact your Danswer admin to get it added!" - ) - tool_dict[db_tool_model.id] = [ - InternetSearchTool(api_key=bing_api_key) - ] - - continue - - # handle all custom tools - if db_tool_model.openapi_schema: - tool_dict[db_tool_model.id] = cast( - list[Tool], - build_custom_tools_from_openapi_schema( - db_tool_model.openapi_schema - ), - ) - - tools: list[Tool] = [] - for tool_list in tool_dict.values(): - tools.extend(tool_list) - - # factor in tool definition size when pruning - document_pruning_config.tool_num_tokens = compute_all_tool_tokens( - tools, llm_tokenizer - ) - document_pruning_config.using_tool_message = explicit_tool_calling_supported( - llm_provider, llm_model_name - ) - - # LLM prompt building, response capturing, etc. - answer = Answer( - is_connected=is_connected, - question=final_msg.message, - latest_query_files=latest_query_files, - answer_style_config=AnswerStyleConfig( - citation_config=CitationConfig( - all_docs_useful=selected_db_search_docs is not None - ), - document_pruning_config=document_pruning_config, - ), - prompt_config=prompt_config, - llm=( - llm - or get_main_llm_from_tuple( - get_llms_for_persona( - persona=persona, - llm_override=( - new_msg_req.llm_override or chat_session.llm_override - ), - additional_headers=litellm_additional_headers, - ) - ) - ), - message_history=[ - PreviousMessage.from_chat_message(msg, files) for msg in history_msgs - ], - tools=tools, - force_use_tool=_get_force_search_settings(new_msg_req, tools), - ) - - reference_db_search_docs = None - qa_docs_response = None - ai_message_files = None # any files to associate with the AI message e.g. dall-e generated images - dropped_indices = None - tool_result = None - - for packet in answer.processed_streamed_output: - if isinstance(packet, ToolResponse): - if packet.id == SEARCH_RESPONSE_SUMMARY_ID: - ( - qa_docs_response, - reference_db_search_docs, - dropped_indices, - ) = _handle_search_tool_response_summary( - packet=packet, - db_session=db_session, - selected_search_docs=selected_db_search_docs, - # Deduping happens at the last step to avoid harming quality by dropping content early on - dedupe_docs=retrieval_options.dedupe_docs - if retrieval_options - else False, - ) - yield qa_docs_response - elif packet.id == SECTION_RELEVANCE_LIST_ID: - relevance_sections = packet.response - - if reference_db_search_docs is not None: - llm_indices = relevant_sections_to_indices( - relevance_sections=relevance_sections, - items=[ - translate_db_search_doc_to_server_search_doc(doc) - for doc in reference_db_search_docs - ], - ) - - if dropped_indices: - llm_indices = drop_llm_indices( - llm_indices=llm_indices, - search_docs=reference_db_search_docs, - dropped_indices=dropped_indices, - ) - - yield LLMRelevanceFilterResponse( - relevant_chunk_indices=llm_indices - ) - - elif packet.id == IMAGE_GENERATION_RESPONSE_ID: - img_generation_response = cast( - list[ImageGenerationResponse], packet.response - ) - - file_ids = save_files_from_urls( - [img.url for img in img_generation_response] - ) - ai_message_files = [ - FileDescriptor(id=str(file_id), type=ChatFileType.IMAGE) - for file_id in file_ids - ] - yield ImageGenerationDisplay( - file_ids=[str(file_id) for file_id in file_ids] - ) - elif packet.id == INTERNET_SEARCH_RESPONSE_ID: - ( - qa_docs_response, - reference_db_search_docs, - ) = _handle_internet_search_tool_response_summary( - packet=packet, - db_session=db_session, - ) - yield qa_docs_response - elif packet.id == CUSTOM_TOOL_RESPONSE_ID: - custom_tool_response = cast(CustomToolCallSummary, packet.response) - yield CustomToolResponse( - response=custom_tool_response.tool_result, - tool_name=custom_tool_response.tool_name, - ) - - else: - if isinstance(packet, ToolCallFinalResult): - tool_result = packet - yield cast(ChatPacket, packet) - logger.debug("Reached end of stream") - except Exception as e: - error_msg = str(e) - logger.exception(f"Failed to process chat message: {error_msg}") - - stack_trace = traceback.format_exc() - client_error_msg = litellm_exception_to_error_msg(e, llm) - if llm.config.api_key and len(llm.config.api_key) > 2: - error_msg = error_msg.replace(llm.config.api_key, "[REDACTED_API_KEY]") - stack_trace = stack_trace.replace(llm.config.api_key, "[REDACTED_API_KEY]") - - yield StreamingError(error=client_error_msg, stack_trace=stack_trace) - db_session.rollback() - return - - # Post-LLM answer processing - try: - db_citations = None - if reference_db_search_docs: - db_citations = translate_citations( - citations_list=answer.citations, - db_docs=reference_db_search_docs, - ) - - # Saving Gen AI answer and responding with message info - tool_name_to_tool_id: dict[str, int] = {} - for tool_id, tool_list in tool_dict.items(): - for tool in tool_list: - tool_name_to_tool_id[tool.name] = tool_id - - gen_ai_response_message = partial_response( - reserved_message_id=reserved_message_id, - message=answer.llm_answer, - rephrased_query=( - qa_docs_response.rephrased_query if qa_docs_response else None - ), - reference_docs=reference_db_search_docs, - files=ai_message_files, - token_count=len(llm_tokenizer_encode_func(answer.llm_answer)), - citations=db_citations, - error=None, - tool_calls=[ - ToolCall( - tool_id=tool_name_to_tool_id[tool_result.tool_name], - tool_name=tool_result.tool_name, - tool_arguments=tool_result.tool_args, - tool_result=tool_result.tool_result, - ) - ] - if tool_result - else [], - ) - - logger.debug("Committing messages") - db_session.commit() # actually save user / assistant message - - msg_detail_response = translate_db_message_to_chat_message_detail( - gen_ai_response_message - ) - - yield msg_detail_response - except Exception as e: - error_msg = str(e) - logger.exception(error_msg) - - # Frontend will erase whatever answer and show this instead - yield StreamingError(error="Failed to parse LLM output") - - -@log_generator_function_time() -def stream_chat_message( - new_msg_req: CreateChatMessageRequest, - user: User | None, - use_existing_user_message: bool = False, - litellm_additional_headers: dict[str, str] | None = None, - is_connected: Callable[[], bool] | None = None, -) -> Iterator[str]: - with get_session_context_manager() as db_session: - objects = stream_chat_message_objects( - new_msg_req=new_msg_req, - user=user, - db_session=db_session, - use_existing_user_message=use_existing_user_message, - litellm_additional_headers=litellm_additional_headers, - is_connected=is_connected, - ) - for obj in objects: - yield get_json_line(obj.model_dump()) diff --git a/backend/danswer/chat/prompts.yaml b/backend/danswer/chat/prompts.yaml deleted file mode 100644 index d83d4ede4b5..00000000000 --- a/backend/danswer/chat/prompts.yaml +++ /dev/null @@ -1,105 +0,0 @@ -prompts: - # This id field can be left blank for other default prompts, however an id 0 prompt must exist - # This is to act as a default - # Careful setting specific IDs, this won't autoincrement the next ID value for postgres - - id: 0 - name: "Answer-Question" - description: "Answers user questions using retrieved context!" - # System Prompt (as shown in UI) - system: > - You are a question answering system that is constantly learning and improving. - The current date is DANSWER_DATETIME_REPLACEMENT. - - You can process and comprehend vast amounts of text and utilize this knowledge to provide - grounded, accurate, and concise answers to diverse queries. - - You always clearly communicate ANY UNCERTAINTY in your answer. - # Task Prompt (as shown in UI) - task: > - Answer my query based on the documents provided. - The documents may not all be relevant, ignore any documents that are not directly relevant - to the most recent user query. - - I have not read or seen any of the documents and do not want to read them. - - If there are no relevant documents, refer to the chat history and your internal knowledge. - # Inject a statement at the end of system prompt to inform the LLM of the current date/time - # If the DANSWER_DATETIME_REPLACEMENT is set, the date/time is inserted there instead - # Format looks like: "October 16, 2023 14:30" - datetime_aware: true - # Prompts the LLM to include citations in the for [1], [2] etc. - # which get parsed to match the passed in sources - include_citations: true - - - name: "ImageGeneration" - description: "Generates images based on user prompts!" - system: > - You are an advanced image generation system capable of creating diverse and detailed images. - - You can interpret user prompts and generate high-quality, creative images that match their descriptions. - - You always strive to create safe and appropriate content, avoiding any harmful or offensive imagery. - task: > - Generate an image based on the user's description. - - Provide a detailed description of the generated image, including key elements, colors, and composition. - - If the request is not possible or appropriate, explain why and suggest alternatives. - datetime_aware: true - include_citations: false - - - name: "OnlyLLM" - description: "Chat directly with the LLM!" - system: > - You are a helpful AI assistant. The current date is DANSWER_DATETIME_REPLACEMENT - - - You give concise responses to very simple questions, but provide more thorough responses to - more complex and open-ended questions. - - - You are happy to help with writing, analysis, question answering, math, coding and all sorts - of other tasks. You use markdown where reasonable and also for coding. - task: "" - datetime_aware: true - include_citations: true - - - - name: "Summarize" - description: "Summarize relevant information from retrieved context!" - system: > - You are a text summarizing assistant that highlights the most important knowledge from the - context provided, prioritizing the information that relates to the user query. - The current date is DANSWER_DATETIME_REPLACEMENT. - - You ARE NOT creative and always stick to the provided documents. - If there are no documents, refer to the conversation history. - - IMPORTANT: YOU ONLY SUMMARIZE THE IMPORTANT INFORMATION FROM THE PROVIDED DOCUMENTS, - NEVER USE YOUR OWN KNOWLEDGE. - task: > - Summarize the documents provided in relation to the query below. - NEVER refer to the documents by number, I do not have them in the same order as you. - Do not make up any facts, only use what is in the documents. - datetime_aware: true - include_citations: true - - - - name: "Paraphrase" - description: "Recites information from retrieved context! Least creative but most safe!" - system: > - Quote and cite relevant information from provided context based on the user query. - The current date is DANSWER_DATETIME_REPLACEMENT. - - You only provide quotes that are EXACT substrings from provided documents! - - If there are no documents provided, - simply tell the user that there are no documents to reference. - - You NEVER generate new text or phrases outside of the citation. - DO NOT explain your responses, only provide the quotes and NOTHING ELSE. - task: > - Provide EXACT quotes from the provided documents above. Do not generate any new text that is not - directly from the documents. - datetime_aware: true - include_citations: true diff --git a/backend/danswer/chat/tools.py b/backend/danswer/chat/tools.py deleted file mode 100644 index 11b40592973..00000000000 --- a/backend/danswer/chat/tools.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing_extensions import TypedDict # noreorder - -from pydantic import BaseModel - -from danswer.prompts.chat_tools import DANSWER_TOOL_DESCRIPTION -from danswer.prompts.chat_tools import DANSWER_TOOL_NAME -from danswer.prompts.chat_tools import TOOL_FOLLOWUP -from danswer.prompts.chat_tools import TOOL_LESS_FOLLOWUP -from danswer.prompts.chat_tools import TOOL_LESS_PROMPT -from danswer.prompts.chat_tools import TOOL_TEMPLATE -from danswer.prompts.chat_tools import USER_INPUT - - -class ToolInfo(TypedDict): - name: str - description: str - - -class DanswerChatModelOut(BaseModel): - model_raw: str - action: str - action_input: str - - -def call_tool( - model_actions: DanswerChatModelOut, -) -> str: - raise NotImplementedError("There are no additional tool integrations right now") - - -def form_user_prompt_text( - query: str, - tool_text: str | None, - hint_text: str | None, - user_input_prompt: str = USER_INPUT, - tool_less_prompt: str = TOOL_LESS_PROMPT, -) -> str: - user_prompt = tool_text or tool_less_prompt - - user_prompt += user_input_prompt.format(user_input=query) - - if hint_text: - if user_prompt[-1] != "\n": - user_prompt += "\n" - user_prompt += "\nHint: " + hint_text - - return user_prompt.strip() - - -def form_tool_section_text( - tools: list[ToolInfo] | None, retrieval_enabled: bool, template: str = TOOL_TEMPLATE -) -> str | None: - if not tools and not retrieval_enabled: - return None - - if retrieval_enabled and tools: - tools.append( - {"name": DANSWER_TOOL_NAME, "description": DANSWER_TOOL_DESCRIPTION} - ) - - tools_intro = [] - if tools: - num_tools = len(tools) - for tool in tools: - description_formatted = tool["description"].replace("\n", " ") - tools_intro.append(f"> {tool['name']}: {description_formatted}") - - prefix = "Must be one of " if num_tools > 1 else "Must be " - - tools_intro_text = "\n".join(tools_intro) - tool_names_text = prefix + ", ".join([tool["name"] for tool in tools]) - - else: - return None - - return template.format( - tool_overviews=tools_intro_text, tool_names=tool_names_text - ).strip() - - -def form_tool_followup_text( - tool_output: str, - query: str, - hint_text: str | None, - tool_followup_prompt: str = TOOL_FOLLOWUP, - ignore_hint: bool = False, -) -> str: - # If multi-line query, it likely confuses the model more than helps - if "\n" not in query: - optional_reminder = f"\nAs a reminder, my query was: {query}\n" - else: - optional_reminder = "" - - if not ignore_hint and hint_text: - hint_text_spaced = f"\nHint: {hint_text}\n" - else: - hint_text_spaced = "" - - return tool_followup_prompt.format( - tool_output=tool_output, - optional_reminder=optional_reminder, - hint=hint_text_spaced, - ).strip() - - -def form_tool_less_followup_text( - tool_output: str, - query: str, - hint_text: str | None, - tool_followup_prompt: str = TOOL_LESS_FOLLOWUP, -) -> str: - hint = f"Hint: {hint_text}" if hint_text else "" - return tool_followup_prompt.format( - context_str=tool_output, user_query=query, hint_text=hint - ).strip() diff --git a/backend/danswer/configs/__init__.py b/backend/danswer/configs/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py deleted file mode 100644 index f6b218c5f56..00000000000 --- a/backend/danswer/configs/app_configs.py +++ /dev/null @@ -1,368 +0,0 @@ -import json -import os -import urllib.parse - -from danswer.configs.constants import AuthType -from danswer.configs.constants import DocumentIndexType -from danswer.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy - -##### -# App Configs -##### -APP_HOST = "0.0.0.0" -APP_PORT = 8080 -# API_PREFIX is used to prepend a base path for all API routes -# generally used if using a reverse proxy which doesn't support stripping the `/api` -# prefix from requests directed towards the API server. In these cases, set this to `/api` -APP_API_PREFIX = os.environ.get("API_PREFIX", "") - - -##### -# User Facing Features Configs -##### -BLURB_SIZE = 128 # Number Encoder Tokens included in the chunk blurb -GENERATIVE_MODEL_ACCESS_CHECK_FREQ = int( - os.environ.get("GENERATIVE_MODEL_ACCESS_CHECK_FREQ") or 86400 -) # 1 day -DISABLE_GENERATIVE_AI = os.environ.get("DISABLE_GENERATIVE_AI", "").lower() == "true" - - -##### -# Web Configs -##### -# WEB_DOMAIN is used to set the redirect_uri after login flows -# NOTE: if you are having problems accessing the Danswer web UI locally (especially -# on Windows, try setting this to `http://127.0.0.1:3000` instead and see if that -# fixes it) -WEB_DOMAIN = os.environ.get("WEB_DOMAIN") or "http://localhost:3000" - - -##### -# Auth Configs -##### -AUTH_TYPE = AuthType((os.environ.get("AUTH_TYPE") or AuthType.DISABLED.value).lower()) -DISABLE_AUTH = AUTH_TYPE == AuthType.DISABLED - -# Encryption key secret is used to encrypt connector credentials, api keys, and other sensitive -# information. This provides an extra layer of security on top of Postgres access controls -# and is available in Danswer EE -ENCRYPTION_KEY_SECRET = os.environ.get("ENCRYPTION_KEY_SECRET") or "" - -# Turn off mask if admin users should see full credentials for data connectors. -MASK_CREDENTIAL_PREFIX = ( - os.environ.get("MASK_CREDENTIAL_PREFIX", "True").lower() != "false" -) - - -SESSION_EXPIRE_TIME_SECONDS = int( - os.environ.get("SESSION_EXPIRE_TIME_SECONDS") or 86400 * 7 -) # 7 days - -# set `VALID_EMAIL_DOMAINS` to a comma seperated list of domains in order to -# restrict access to Danswer to only users with emails from those domains. -# E.g. `VALID_EMAIL_DOMAINS=example.com,example.org` will restrict Danswer -# signups to users with either an @example.com or an @example.org email. -# NOTE: maintaining `VALID_EMAIL_DOMAIN` to keep backwards compatibility -_VALID_EMAIL_DOMAIN = os.environ.get("VALID_EMAIL_DOMAIN", "") -_VALID_EMAIL_DOMAINS_STR = ( - os.environ.get("VALID_EMAIL_DOMAINS", "") or _VALID_EMAIL_DOMAIN -) -VALID_EMAIL_DOMAINS = ( - [domain.strip() for domain in _VALID_EMAIL_DOMAINS_STR.split(",")] - if _VALID_EMAIL_DOMAINS_STR - else [] -) -# OAuth Login Flow -# Used for both Google OAuth2 and OIDC flows -OAUTH_CLIENT_ID = ( - os.environ.get("OAUTH_CLIENT_ID", os.environ.get("GOOGLE_OAUTH_CLIENT_ID")) or "" -) -OAUTH_CLIENT_SECRET = ( - os.environ.get("OAUTH_CLIENT_SECRET", os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")) - or "" -) - -USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "") -# for basic auth -REQUIRE_EMAIL_VERIFICATION = ( - os.environ.get("REQUIRE_EMAIL_VERIFICATION", "").lower() == "true" -) -SMTP_SERVER = os.environ.get("SMTP_SERVER") or "smtp.gmail.com" -SMTP_PORT = int(os.environ.get("SMTP_PORT") or "587") -SMTP_USER = os.environ.get("SMTP_USER", "your-email@gmail.com") -SMTP_PASS = os.environ.get("SMTP_PASS", "your-gmail-password") -EMAIL_FROM = os.environ.get("EMAIL_FROM") or SMTP_USER - -# If set, Danswer will listen to the `expires_at` returned by the identity -# provider (e.g. Okta, Google, etc.) and force the user to re-authenticate -# after this time has elapsed. Disabled since by default many auth providers -# have very short expiry times (e.g. 1 hour) which provide a poor user experience -TRACK_EXTERNAL_IDP_EXPIRY = ( - os.environ.get("TRACK_EXTERNAL_IDP_EXPIRY", "").lower() == "true" -) - - -##### -# DB Configs -##### -DOCUMENT_INDEX_NAME = "danswer_index" -# Vespa is now the default document index store for both keyword and vector -DOCUMENT_INDEX_TYPE = os.environ.get( - "DOCUMENT_INDEX_TYPE", DocumentIndexType.COMBINED.value -) -VESPA_HOST = os.environ.get("VESPA_HOST") or "localhost" -# NOTE: this is used if and only if the vespa config server is accessible via a -# different host than the main vespa application -VESPA_CONFIG_SERVER_HOST = os.environ.get("VESPA_CONFIG_SERVER_HOST") or VESPA_HOST -VESPA_PORT = os.environ.get("VESPA_PORT") or "8081" -VESPA_TENANT_PORT = os.environ.get("VESPA_TENANT_PORT") or "19071" -# The default below is for dockerized deployment -VESPA_DEPLOYMENT_ZIP = ( - os.environ.get("VESPA_DEPLOYMENT_ZIP") or "/app/danswer/vespa-app.zip" -) -# Number of documents in a batch during indexing (further batching done by chunks before passing to bi-encoder) -try: - INDEX_BATCH_SIZE = int(os.environ.get("INDEX_BATCH_SIZE", 16)) -except ValueError: - INDEX_BATCH_SIZE = 16 - -# Below are intended to match the env variables names used by the official postgres docker image -# https://hub.docker.com/_/postgres -POSTGRES_USER = os.environ.get("POSTGRES_USER") or "postgres" -# URL-encode the password for asyncpg to avoid issues with special characters on some machines. -POSTGRES_PASSWORD = urllib.parse.quote_plus( - os.environ.get("POSTGRES_PASSWORD") or "password" -) -POSTGRES_HOST = os.environ.get("POSTGRES_HOST") or "localhost" -POSTGRES_PORT = os.environ.get("POSTGRES_PORT") or "5432" -POSTGRES_DB = os.environ.get("POSTGRES_DB") or "postgres" - -# defaults to False -POSTGRES_POOL_PRE_PING = os.environ.get("POSTGRES_POOL_PRE_PING", "").lower() == "true" - -# recycle timeout in seconds -POSTGRES_POOL_RECYCLE_DEFAULT = 60 * 20 # 20 minutes -try: - POSTGRES_POOL_RECYCLE = int( - os.environ.get("POSTGRES_POOL_RECYCLE", POSTGRES_POOL_RECYCLE_DEFAULT) - ) -except ValueError: - POSTGRES_POOL_RECYCLE = POSTGRES_POOL_RECYCLE_DEFAULT - -##### -# Connector Configs -##### -POLL_CONNECTOR_OFFSET = 30 # Minutes overlap between poll windows - -# View the list here: -# https://github.com/danswer-ai/danswer/blob/main/backend/danswer/connectors/factory.py -# If this is empty, all connectors are enabled, this is an option for security heavy orgs where -# only very select connectors are enabled and admins cannot add other connector types -ENABLED_CONNECTOR_TYPES = os.environ.get("ENABLED_CONNECTOR_TYPES") or "" - -# Some calls to get information on expert users are quite costly especially with rate limiting -# Since experts are not used in the actual user experience, currently it is turned off -# for some connectors -ENABLE_EXPENSIVE_EXPERT_CALLS = False - -GOOGLE_DRIVE_INCLUDE_SHARED = False -GOOGLE_DRIVE_FOLLOW_SHORTCUTS = False -GOOGLE_DRIVE_ONLY_ORG_PUBLIC = False - -# TODO these should be available for frontend configuration, via advanced options expandable -WEB_CONNECTOR_IGNORED_CLASSES = os.environ.get( - "WEB_CONNECTOR_IGNORED_CLASSES", "sidebar,footer" -).split(",") -WEB_CONNECTOR_IGNORED_ELEMENTS = os.environ.get( - "WEB_CONNECTOR_IGNORED_ELEMENTS", "nav,footer,meta,script,style,symbol,aside" -).split(",") -WEB_CONNECTOR_OAUTH_CLIENT_ID = os.environ.get("WEB_CONNECTOR_OAUTH_CLIENT_ID") -WEB_CONNECTOR_OAUTH_CLIENT_SECRET = os.environ.get("WEB_CONNECTOR_OAUTH_CLIENT_SECRET") -WEB_CONNECTOR_OAUTH_TOKEN_URL = os.environ.get("WEB_CONNECTOR_OAUTH_TOKEN_URL") -WEB_CONNECTOR_VALIDATE_URLS = os.environ.get("WEB_CONNECTOR_VALIDATE_URLS") - -HTML_BASED_CONNECTOR_TRANSFORM_LINKS_STRATEGY = os.environ.get( - "HTML_BASED_CONNECTOR_TRANSFORM_LINKS_STRATEGY", - HtmlBasedConnectorTransformLinksStrategy.STRIP, -) - -NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP = ( - os.environ.get("NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP", "").lower() - == "true" -) - -CONFLUENCE_CONNECTOR_LABELS_TO_SKIP = [ - ignored_tag - for ignored_tag in os.environ.get("CONFLUENCE_CONNECTOR_LABELS_TO_SKIP", "").split( - "," - ) - if ignored_tag -] - -# Avoid to get archived pages -CONFLUENCE_CONNECTOR_INDEX_ARCHIVED_PAGES = ( - os.environ.get("CONFLUENCE_CONNECTOR_INDEX_ARCHIVED_PAGES", "").lower() == "true" -) - -# Save pages labels as Danswer metadata tags -# The reason to skip this would be to reduce the number of calls to Confluence due to rate limit concerns -CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING = ( - os.environ.get("CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING", "").lower() == "true" -) - -# Attachments exceeding this size will not be retrieved (in bytes) -CONFLUENCE_CONNECTOR_ATTACHMENT_SIZE_THRESHOLD = int( - os.environ.get("CONFLUENCE_CONNECTOR_ATTACHMENT_SIZE_THRESHOLD", 10 * 1024 * 1024) -) -# Attachments with more chars than this will not be indexed. This is to prevent extremely -# large files from freezing indexing. 200,000 is ~100 google doc pages. -CONFLUENCE_CONNECTOR_ATTACHMENT_CHAR_COUNT_THRESHOLD = int( - os.environ.get("CONFLUENCE_CONNECTOR_ATTACHMENT_CHAR_COUNT_THRESHOLD", 200_000) -) - -JIRA_CONNECTOR_LABELS_TO_SKIP = [ - ignored_tag - for ignored_tag in os.environ.get("JIRA_CONNECTOR_LABELS_TO_SKIP", "").split(",") - if ignored_tag -] - -GONG_CONNECTOR_START_TIME = os.environ.get("GONG_CONNECTOR_START_TIME") - -GITHUB_CONNECTOR_BASE_URL = os.environ.get("GITHUB_CONNECTOR_BASE_URL") or None - -GITLAB_CONNECTOR_INCLUDE_CODE_FILES = ( - os.environ.get("GITLAB_CONNECTOR_INCLUDE_CODE_FILES", "").lower() == "true" -) - -DASK_JOB_CLIENT_ENABLED = ( - os.environ.get("DASK_JOB_CLIENT_ENABLED", "").lower() == "true" -) -EXPERIMENTAL_CHECKPOINTING_ENABLED = ( - os.environ.get("EXPERIMENTAL_CHECKPOINTING_ENABLED", "").lower() == "true" -) - -PRUNING_DISABLED = -1 -DEFAULT_PRUNING_FREQ = 60 * 60 * 24 # Once a day - -ALLOW_SIMULTANEOUS_PRUNING = ( - os.environ.get("ALLOW_SIMULTANEOUS_PRUNING", "").lower() == "true" -) - -# This is the maxiumum rate at which documents are queried for a pruning job. 0 disables the limitation. -MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE = int( - os.environ.get("MAX_PRUNING_DOCUMENT_RETRIEVAL_PER_MINUTE", 0) -) - -# comma delimited list of zendesk article labels to skip indexing for -ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS = os.environ.get( - "ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS", "" -).split(",") - - -##### -# Indexing Configs -##### -# NOTE: Currently only supported in the Confluence and Google Drive connectors + -# only handles some failures (Confluence = handles API call failures, Google -# Drive = handles failures pulling files / parsing them) -CONTINUE_ON_CONNECTOR_FAILURE = os.environ.get( - "CONTINUE_ON_CONNECTOR_FAILURE", "" -).lower() not in ["false", ""] -# When swapping to a new embedding model, a secondary index is created in the background, to conserve -# resources, we pause updates on the primary index by default while the secondary index is created -DISABLE_INDEX_UPDATE_ON_SWAP = ( - os.environ.get("DISABLE_INDEX_UPDATE_ON_SWAP", "").lower() == "true" -) -# Controls how many worker processes we spin up to index documents in the -# background. This is useful for speeding up indexing, but does require a -# fairly large amount of memory in order to increase substantially, since -# each worker loads the embedding models into memory. -NUM_INDEXING_WORKERS = int(os.environ.get("NUM_INDEXING_WORKERS") or 1) -NUM_SECONDARY_INDEXING_WORKERS = int( - os.environ.get("NUM_SECONDARY_INDEXING_WORKERS") or NUM_INDEXING_WORKERS -) -# More accurate results at the expense of indexing speed and index size (stores additional 4 MINI_CHUNK vectors) -ENABLE_MULTIPASS_INDEXING = ( - os.environ.get("ENABLE_MULTIPASS_INDEXING", "").lower() == "true" -) -# Finer grained chunking for more detail retention -# Slightly larger since the sentence aware split is a max cutoff so most minichunks will be under MINI_CHUNK_SIZE -# tokens. But we need it to be at least as big as 1/4th chunk size to avoid having a tiny mini-chunk at the end -MINI_CHUNK_SIZE = 150 - -# This is the number of regular chunks per large chunk -LARGE_CHUNK_RATIO = 4 - -# Include the document level metadata in each chunk. If the metadata is too long, then it is thrown out -# We don't want the metadata to overwhelm the actual contents of the chunk -SKIP_METADATA_IN_CHUNK = os.environ.get("SKIP_METADATA_IN_CHUNK", "").lower() == "true" -# Timeout to wait for job's last update before killing it, in hours -CLEANUP_INDEXING_JOBS_TIMEOUT = int(os.environ.get("CLEANUP_INDEXING_JOBS_TIMEOUT", 3)) - -# The indexer will warn in the logs whenver a document exceeds this threshold (in bytes) -INDEXING_SIZE_WARNING_THRESHOLD = int( - os.environ.get("INDEXING_SIZE_WARNING_THRESHOLD", 100 * 1024 * 1024) -) - -# during indexing, will log verbose memory diff stats every x batches and at the end. -# 0 disables this behavior and is the default. -INDEXING_TRACER_INTERVAL = int(os.environ.get("INDEXING_TRACER_INTERVAL", 0)) - -# During an indexing attempt, specifies the number of batches which are allowed to -# exception without aborting the attempt. -INDEXING_EXCEPTION_LIMIT = int(os.environ.get("INDEXING_EXCEPTION_LIMIT", 0)) - -##### -# Miscellaneous -##### -# File based Key Value store no longer used -DYNAMIC_CONFIG_STORE = "PostgresBackedDynamicConfigStore" - -JOB_TIMEOUT = 60 * 60 * 6 # 6 hours default -# used to allow the background indexing jobs to use a different embedding -# model server than the API server -CURRENT_PROCESS_IS_AN_INDEXING_JOB = ( - os.environ.get("CURRENT_PROCESS_IS_AN_INDEXING_JOB", "").lower() == "true" -) -# Sets LiteLLM to verbose logging -LOG_ALL_MODEL_INTERACTIONS = ( - os.environ.get("LOG_ALL_MODEL_INTERACTIONS", "").lower() == "true" -) -# Logs Danswer only model interactions like prompts, responses, messages etc. -LOG_DANSWER_MODEL_INTERACTIONS = ( - os.environ.get("LOG_DANSWER_MODEL_INTERACTIONS", "").lower() == "true" -) -# If set to `true` will enable additional logs about Vespa query performance -# (time spent on finding the right docs + time spent fetching summaries from disk) -LOG_VESPA_TIMING_INFORMATION = ( - os.environ.get("LOG_VESPA_TIMING_INFORMATION", "").lower() == "true" -) -LOG_ENDPOINT_LATENCY = os.environ.get("LOG_ENDPOINT_LATENCY", "").lower() == "true" -LOG_POSTGRES_LATENCY = os.environ.get("LOG_POSTGRES_LATENCY", "").lower() == "true" -LOG_POSTGRES_CONN_COUNTS = ( - os.environ.get("LOG_POSTGRES_CONN_COUNTS", "").lower() == "true" -) -# Anonymous usage telemetry -DISABLE_TELEMETRY = os.environ.get("DISABLE_TELEMETRY", "").lower() == "true" - -TOKEN_BUDGET_GLOBALLY_ENABLED = ( - os.environ.get("TOKEN_BUDGET_GLOBALLY_ENABLED", "").lower() == "true" -) - -# Defined custom query/answer conditions to validate the query and the LLM answer. -# Format: list of strings -CUSTOM_ANSWER_VALIDITY_CONDITIONS = json.loads( - os.environ.get("CUSTOM_ANSWER_VALIDITY_CONDITIONS", "[]") -) - - -##### -# Enterprise Edition Configs -##### -# NOTE: this should only be enabled if you have purchased an enterprise license. -# if you're interested in an enterprise license, please reach out to us at -# founders@danswer.ai OR message Chris Weaver or Yuhong Sun in the Danswer -# Slack community (https://join.slack.com/t/danswer/shared_invite/zt-1w76msxmd-HJHLe3KNFIAIzk_0dSOKaQ) -ENTERPRISE_EDITION_ENABLED = ( - os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() == "true" -) diff --git a/backend/danswer/configs/chat_configs.py b/backend/danswer/configs/chat_configs.py deleted file mode 100644 index 2b6b0990e1d..00000000000 --- a/backend/danswer/configs/chat_configs.py +++ /dev/null @@ -1,90 +0,0 @@ -import os - - -PROMPTS_YAML = "./danswer/chat/prompts.yaml" -PERSONAS_YAML = "./danswer/chat/personas.yaml" -INPUT_PROMPT_YAML = "./danswer/chat/input_prompts.yaml" - -NUM_RETURNED_HITS = 50 -# Used for LLM filtering and reranking -# We want this to be approximately the number of results we want to show on the first page -# It cannot be too large due to cost and latency implications -NUM_POSTPROCESSED_RESULTS = 20 - -# May be less depending on model -MAX_CHUNKS_FED_TO_CHAT = float(os.environ.get("MAX_CHUNKS_FED_TO_CHAT") or 10.0) -# For Chat, need to keep enough space for history and other prompt pieces -# ~3k input, half for docs, half for chat history + prompts -CHAT_TARGET_CHUNK_PERCENTAGE = 512 * 3 / 3072 - -# For selecting a different LLM question-answering prompt format -# Valid values: default, cot, weak -QA_PROMPT_OVERRIDE = os.environ.get("QA_PROMPT_OVERRIDE") or None -# 1 / (1 + DOC_TIME_DECAY * doc-age-in-years), set to 0 to have no decay -# Capped in Vespa at 0.5 -DOC_TIME_DECAY = float( - os.environ.get("DOC_TIME_DECAY") or 0.5 # Hits limit at 2 years by default -) -BASE_RECENCY_DECAY = 0.5 -FAVOR_RECENT_DECAY_MULTIPLIER = 2.0 -# Currently this next one is not configurable via env -DISABLE_LLM_QUERY_ANSWERABILITY = QA_PROMPT_OVERRIDE == "weak" -# For the highest matching base size chunk, how many chunks above and below do we pull in by default -# Note this is not in any of the deployment configs yet -# Currently only applies to search flow not chat -CONTEXT_CHUNKS_ABOVE = int(os.environ.get("CONTEXT_CHUNKS_ABOVE") or 1) -CONTEXT_CHUNKS_BELOW = int(os.environ.get("CONTEXT_CHUNKS_BELOW") or 1) -# Whether the LLM should be used to decide if a search would help given the chat history -DISABLE_LLM_CHOOSE_SEARCH = ( - os.environ.get("DISABLE_LLM_CHOOSE_SEARCH", "").lower() == "true" -) -DISABLE_LLM_QUERY_REPHRASE = ( - os.environ.get("DISABLE_LLM_QUERY_REPHRASE", "").lower() == "true" -) -# 1 edit per 20 characters, currently unused due to fuzzy match being too slow -QUOTE_ALLOWED_ERROR_PERCENT = 0.05 -QA_TIMEOUT = int(os.environ.get("QA_TIMEOUT") or "60") # 60 seconds -# Weighting factor between Vector and Keyword Search, 1 for completely vector search -HYBRID_ALPHA = max(0, min(1, float(os.environ.get("HYBRID_ALPHA") or 0.5))) -HYBRID_ALPHA_KEYWORD = max( - 0, min(1, float(os.environ.get("HYBRID_ALPHA_KEYWORD") or 0.4)) -) -# Weighting factor between Title and Content of documents during search, 1 for completely -# Title based. Default heavily favors Content because Title is also included at the top of -# Content. This is to avoid cases where the Content is very relevant but it may not be clear -# if the title is separated out. Title is most of a "boost" than a separate field. -TITLE_CONTENT_RATIO = max( - 0, min(1, float(os.environ.get("TITLE_CONTENT_RATIO") or 0.10)) -) - -# A list of languages passed to the LLM to rephase the query -# For example "English,French,Spanish", be sure to use the "," separator -MULTILINGUAL_QUERY_EXPANSION = os.environ.get("MULTILINGUAL_QUERY_EXPANSION") or None -LANGUAGE_HINT = "\n" + ( - os.environ.get("LANGUAGE_HINT") - or "IMPORTANT: Respond in the same language as my query!" -) -LANGUAGE_CHAT_NAMING_HINT = ( - os.environ.get("LANGUAGE_CHAT_NAMING_HINT") - or "The name of the conversation must be in the same language as the user query." -) - -# Agentic search takes significantly more tokens and therefore has much higher cost. -# This configuration allows users to get a search-only experience with instant results -# and no involvement from the LLM. -# Additionally, some LLM providers have strict rate limits which may prohibit -# sending many API requests at once (as is done in agentic search). -# Whether the LLM should evaluate all of the document chunks passed in for usefulness -# in relation to the user query -DISABLE_LLM_DOC_RELEVANCE = ( - os.environ.get("DISABLE_LLM_DOC_RELEVANCE", "").lower() == "true" -) - -# Stops streaming answers back to the UI if this pattern is seen: -STOP_STREAM_PAT = os.environ.get("STOP_STREAM_PAT") or None - -# The backend logic for this being True isn't fully supported yet -HARD_DELETE_CHATS = False - -# Internet Search -BING_API_KEY = os.environ.get("BING_API_KEY") or None diff --git a/backend/danswer/configs/constants.py b/backend/danswer/configs/constants.py deleted file mode 100644 index 64c162d7bef..00000000000 --- a/backend/danswer/configs/constants.py +++ /dev/null @@ -1,168 +0,0 @@ -from enum import auto -from enum import Enum - -SOURCE_TYPE = "source_type" -# stored in the `metadata` of a chunk. Used to signify that this chunk should -# not be used for QA. For example, Google Drive file types which can't be parsed -# are still useful as a search result but not for QA. -IGNORE_FOR_QA = "ignore_for_qa" -# NOTE: deprecated, only used for porting key from old system -GEN_AI_API_KEY_STORAGE_KEY = "genai_api_key" -PUBLIC_DOC_PAT = "PUBLIC" -ID_SEPARATOR = ":;:" -DEFAULT_BOOST = 0 -SESSION_KEY = "session" - -# For chunking/processing chunks -RETURN_SEPARATOR = "\n\r\n" -SECTION_SEPARATOR = "\n\n" -# For combining attributes, doesn't have to be unique/perfect to work -INDEX_SEPARATOR = "===" - -# For File Connector Metadata override file -DANSWER_METADATA_FILENAME = ".danswer_metadata.json" - -# Messages -DISABLED_GEN_AI_MSG = ( - "Your System Admin has disabled the Generative AI functionalities of Danswer.\n" - "Please contact them if you wish to have this enabled.\n" - "You can still use Danswer as a search engine." -) - -# Postgres connection constants for application_name -POSTGRES_WEB_APP_NAME = "web" -POSTGRES_INDEXER_APP_NAME = "indexer" -POSTGRES_CELERY_APP_NAME = "celery" -POSTGRES_CELERY_BEAT_APP_NAME = "celery_beat" -POSTGRES_CELERY_WORKER_APP_NAME = "celery_worker" -POSTGRES_PERMISSIONS_APP_NAME = "permissions" -POSTGRES_UNKNOWN_APP_NAME = "unknown" - -# API Keys -DANSWER_API_KEY_PREFIX = "API_KEY__" -DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN = "danswerapikey.ai" -UNNAMED_KEY_PLACEHOLDER = "Unnamed" - -# Key-Value store keys -KV_REINDEX_KEY = "needs_reindexing" -KV_SEARCH_SETTINGS = "search_settings" -KV_USER_STORE_KEY = "INVITED_USERS" -KV_NO_AUTH_USER_PREFERENCES_KEY = "no_auth_user_preferences" -KV_CRED_KEY = "credential_id_{}" -KV_GMAIL_CRED_KEY = "gmail_app_credential" -KV_GMAIL_SERVICE_ACCOUNT_KEY = "gmail_service_account_key" -KV_GOOGLE_DRIVE_CRED_KEY = "google_drive_app_credential" -KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY = "google_drive_service_account_key" -KV_SLACK_BOT_TOKENS_CONFIG_KEY = "slack_bot_tokens_config_key" -KV_GEN_AI_KEY_CHECK_TIME = "genai_api_key_last_check_time" -KV_SETTINGS_KEY = "danswer_settings" -KV_CUSTOMER_UUID_KEY = "customer_uuid" -KV_ENTERPRISE_SETTINGS_KEY = "danswer_enterprise_settings" -KV_CUSTOM_ANALYTICS_SCRIPT_KEY = "__custom_analytics_script__" - - -class DocumentSource(str, Enum): - # Special case, document passed in via Danswer APIs without specifying a source type - INGESTION_API = "ingestion_api" - SLACK = "slack" - WEB = "web" - GOOGLE_DRIVE = "google_drive" - GMAIL = "gmail" - REQUESTTRACKER = "requesttracker" - GITHUB = "github" - GITLAB = "gitlab" - GURU = "guru" - BOOKSTACK = "bookstack" - CONFLUENCE = "confluence" - SLAB = "slab" - JIRA = "jira" - PRODUCTBOARD = "productboard" - FILE = "file" - NOTION = "notion" - ZULIP = "zulip" - LINEAR = "linear" - HUBSPOT = "hubspot" - DOCUMENT360 = "document360" - GONG = "gong" - GOOGLE_SITES = "google_sites" - ZENDESK = "zendesk" - LOOPIO = "loopio" - DROPBOX = "dropbox" - SHAREPOINT = "sharepoint" - TEAMS = "teams" - SALESFORCE = "salesforce" - DISCOURSE = "discourse" - AXERO = "axero" - CLICKUP = "clickup" - MEDIAWIKI = "mediawiki" - WIKIPEDIA = "wikipedia" - S3 = "s3" - R2 = "r2" - GOOGLE_CLOUD_STORAGE = "google_cloud_storage" - OCI_STORAGE = "oci_storage" - NOT_APPLICABLE = "not_applicable" - - -class NotificationType(str, Enum): - REINDEX = "reindex" - - -class BlobType(str, Enum): - R2 = "r2" - S3 = "s3" - GOOGLE_CLOUD_STORAGE = "google_cloud_storage" - OCI_STORAGE = "oci_storage" - - # Special case, for internet search - NOT_APPLICABLE = "not_applicable" - - -class DocumentIndexType(str, Enum): - COMBINED = "combined" # Vespa - SPLIT = "split" # Typesense + Qdrant - - -class AuthType(str, Enum): - DISABLED = "disabled" - BASIC = "basic" - GOOGLE_OAUTH = "google_oauth" - OIDC = "oidc" - SAML = "saml" - - -class QAFeedbackType(str, Enum): - LIKE = "like" # User likes the answer, used for metrics - DISLIKE = "dislike" # User dislikes the answer, used for metrics - - -class SearchFeedbackType(str, Enum): - ENDORSE = "endorse" # boost this document for all future queries - REJECT = "reject" # down-boost this document for all future queries - HIDE = "hide" # mark this document as untrusted, hide from LLM - UNHIDE = "unhide" - - -class MessageType(str, Enum): - # Using OpenAI standards, Langchain equivalent shown in comment - # System message is always constructed on the fly, not saved - SYSTEM = "system" # SystemMessage - USER = "user" # HumanMessage - ASSISTANT = "assistant" # AIMessage - - -class TokenRateLimitScope(str, Enum): - USER = "user" - USER_GROUP = "user_group" - GLOBAL = "global" - - -class FileOrigin(str, Enum): - CHAT_UPLOAD = "chat_upload" - CHAT_IMAGE_GEN = "chat_image_gen" - CONNECTOR = "connector" - GENERATED_REPORT = "generated_report" - OTHER = "other" - - -class PostgresAdvisoryLocks(Enum): - KOMBU_MESSAGE_CLEANUP_LOCK_ID = auto() diff --git a/backend/danswer/configs/danswerbot_configs.py b/backend/danswer/configs/danswerbot_configs.py deleted file mode 100644 index 3fca9bc78b3..00000000000 --- a/backend/danswer/configs/danswerbot_configs.py +++ /dev/null @@ -1,87 +0,0 @@ -import os - -##### -# Danswer Slack Bot Configs -##### -DANSWER_BOT_NUM_RETRIES = int(os.environ.get("DANSWER_BOT_NUM_RETRIES", "5")) -DANSWER_BOT_ANSWER_GENERATION_TIMEOUT = int( - os.environ.get("DANSWER_BOT_ANSWER_GENERATION_TIMEOUT", "90") -) -# How much of the available input context can be used for thread context -DANSWER_BOT_TARGET_CHUNK_PERCENTAGE = 512 * 2 / 3072 -# Number of docs to display in "Reference Documents" -DANSWER_BOT_NUM_DOCS_TO_DISPLAY = int( - os.environ.get("DANSWER_BOT_NUM_DOCS_TO_DISPLAY", "5") -) -# If the LLM fails to answer, Danswer can still show the "Reference Documents" -DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER = os.environ.get( - "DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER", "" -).lower() not in ["false", ""] -# When Danswer is considering a message, what emoji does it react with -DANSWER_REACT_EMOJI = os.environ.get("DANSWER_REACT_EMOJI") or "eyes" -# When User needs more help, what should the emoji be -DANSWER_FOLLOWUP_EMOJI = os.environ.get("DANSWER_FOLLOWUP_EMOJI") or "sos" -# What kind of message should be shown when someone gives an AI answer feedback to DanswerBot -# Defaults to Private if not provided or invalid -# Private: Only visible to user clicking the feedback -# Anonymous: Public but anonymous -# Public: Visible with the user name who submitted the feedback -DANSWER_BOT_FEEDBACK_VISIBILITY = ( - os.environ.get("DANSWER_BOT_FEEDBACK_VISIBILITY") or "private" -) -# Should DanswerBot send an apology message if it's not able to find an answer -# That way the user isn't confused as to why DanswerBot reacted but then said nothing -# Off by default to be less intrusive (don't want to give a notif that just says we couldnt help) -NOTIFY_SLACKBOT_NO_ANSWER = ( - os.environ.get("NOTIFY_SLACKBOT_NO_ANSWER", "").lower() == "true" -) -# Mostly for debugging purposes but it's for explaining what went wrong -# if DanswerBot couldn't find an answer -DANSWER_BOT_DISPLAY_ERROR_MSGS = os.environ.get( - "DANSWER_BOT_DISPLAY_ERROR_MSGS", "" -).lower() not in [ - "false", - "", -] -# Default is only respond in channels that are included by a slack config set in the UI -DANSWER_BOT_RESPOND_EVERY_CHANNEL = ( - os.environ.get("DANSWER_BOT_RESPOND_EVERY_CHANNEL", "").lower() == "true" -) -# Add a second LLM call post Answer to verify if the Answer is valid -# Throws out answers that don't directly or fully answer the user query -# This is the default for all DanswerBot channels unless the channel is configured individually -# Set/unset by "Hide Non Answers" -ENABLE_DANSWERBOT_REFLEXION = ( - os.environ.get("ENABLE_DANSWERBOT_REFLEXION", "").lower() == "true" -) -# Currently not support chain of thought, probably will add back later -DANSWER_BOT_DISABLE_COT = True -# if set, will default DanswerBot to use quotes and reference documents -DANSWER_BOT_USE_QUOTES = os.environ.get("DANSWER_BOT_USE_QUOTES", "").lower() == "true" - -# Maximum Questions Per Minute, Default Uncapped -DANSWER_BOT_MAX_QPM = int(os.environ.get("DANSWER_BOT_MAX_QPM") or 0) or None -# Maximum time to wait when a question is queued -DANSWER_BOT_MAX_WAIT_TIME = int(os.environ.get("DANSWER_BOT_MAX_WAIT_TIME") or 180) - -# Time (in minutes) after which a Slack message is sent to the user to remind him to give feedback. -# Set to 0 to disable it (default) -DANSWER_BOT_FEEDBACK_REMINDER = int( - os.environ.get("DANSWER_BOT_FEEDBACK_REMINDER") or 0 -) -# Set to True to rephrase the Slack users messages -DANSWER_BOT_REPHRASE_MESSAGE = ( - os.environ.get("DANSWER_BOT_REPHRASE_MESSAGE", "").lower() == "true" -) - -# DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD is the number of -# responses DanswerBot can send in a given time period. -# Set to 0 to disable the limit. -DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD = int( - os.environ.get("DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD", "5000") -) -# DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS is the number -# of seconds until the response limit is reset. -DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS = int( - os.environ.get("DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS", "86400") -) diff --git a/backend/danswer/configs/model_configs.py b/backend/danswer/configs/model_configs.py deleted file mode 100644 index e5fa5e74a28..00000000000 --- a/backend/danswer/configs/model_configs.py +++ /dev/null @@ -1,141 +0,0 @@ -import json -import os - -##### -# Embedding/Reranking Model Configs -##### -# Important considerations when choosing models -# Max tokens count needs to be high considering use case (at least 512) -# Models used must be MIT or Apache license -# Inference/Indexing speed -# https://huggingface.co/DOCUMENT_ENCODER_MODEL -# The useable models configured as below must be SentenceTransformer compatible -# NOTE: DO NOT CHANGE SET THESE UNLESS YOU KNOW WHAT YOU ARE DOING -# IDEALLY, YOU SHOULD CHANGE EMBEDDING MODELS VIA THE UI -DEFAULT_DOCUMENT_ENCODER_MODEL = "nomic-ai/nomic-embed-text-v1" -DOCUMENT_ENCODER_MODEL = ( - os.environ.get("DOCUMENT_ENCODER_MODEL") or DEFAULT_DOCUMENT_ENCODER_MODEL -) -# If the below is changed, Vespa deployment must also be changed -DOC_EMBEDDING_DIM = int(os.environ.get("DOC_EMBEDDING_DIM") or 768) -# Model should be chosen with 512 context size, ideally don't change this -# If multipass_indexing is enabled, the max context size would be set to -# DOC_EMBEDDING_CONTEXT_SIZE * LARGE_CHUNK_RATIO -DOC_EMBEDDING_CONTEXT_SIZE = 512 -NORMALIZE_EMBEDDINGS = ( - os.environ.get("NORMALIZE_EMBEDDINGS") or "true" -).lower() == "true" - -# Old default model settings, which are needed for an automatic easy upgrade -OLD_DEFAULT_DOCUMENT_ENCODER_MODEL = "thenlper/gte-small" -OLD_DEFAULT_MODEL_DOC_EMBEDDING_DIM = 384 -OLD_DEFAULT_MODEL_NORMALIZE_EMBEDDINGS = False - -# These are only used if reranking is turned off, to normalize the direct retrieval scores for display -# Currently unused -SIM_SCORE_RANGE_LOW = float(os.environ.get("SIM_SCORE_RANGE_LOW") or 0.0) -SIM_SCORE_RANGE_HIGH = float(os.environ.get("SIM_SCORE_RANGE_HIGH") or 1.0) -# Certain models like e5, BGE, etc use a prefix for asymmetric retrievals (query generally shorter than docs) -ASYM_QUERY_PREFIX = os.environ.get("ASYM_QUERY_PREFIX", "search_query: ") -ASYM_PASSAGE_PREFIX = os.environ.get("ASYM_PASSAGE_PREFIX", "search_document: ") -# Purely an optimization, memory limitation consideration -BATCH_SIZE_ENCODE_CHUNKS = 8 -# don't send over too many chunks at once, as sending too many could cause timeouts -BATCH_SIZE_ENCODE_CHUNKS_FOR_API_EMBEDDING_SERVICES = 512 -# For score display purposes, only way is to know the expected ranges -CROSS_ENCODER_RANGE_MAX = 1 -CROSS_ENCODER_RANGE_MIN = 0 - - -##### -# Generative AI Model Configs -##### - -# If changing GEN_AI_MODEL_PROVIDER or GEN_AI_MODEL_VERSION from the default, -# be sure to use one that is LiteLLM compatible: -# https://litellm.vercel.app/docs/providers/azure#completion---using-env-variables -# The provider is the prefix before / in the model argument - -# Additionally Danswer supports GPT4All and custom request library based models -# Set GEN_AI_MODEL_PROVIDER to "custom" to use the custom requests approach -# Set GEN_AI_MODEL_PROVIDER to "gpt4all" to use gpt4all models running locally -GEN_AI_MODEL_PROVIDER = os.environ.get("GEN_AI_MODEL_PROVIDER") or "openai" -# If using Azure, it's the engine name, for example: Danswer -GEN_AI_MODEL_VERSION = os.environ.get("GEN_AI_MODEL_VERSION") -# The fallback display name to use for default model when using a custom model provider -GEN_AI_DISPLAY_NAME = os.environ.get("GEN_AI_DISPLAY_NAME") or "Custom LLM" - -# For secondary flows like extracting filters or deciding if a chunk is useful, we don't need -# as powerful of a model as say GPT-4 so we can use an alternative that is faster and cheaper -FAST_GEN_AI_MODEL_VERSION = os.environ.get("FAST_GEN_AI_MODEL_VERSION") - -# If the Generative AI model requires an API key for access, otherwise can leave blank -GEN_AI_API_KEY = ( - os.environ.get("GEN_AI_API_KEY", os.environ.get("OPENAI_API_KEY")) or None -) - -# API Base, such as (for Azure): https://danswer.openai.azure.com/ -GEN_AI_API_ENDPOINT = os.environ.get("GEN_AI_API_ENDPOINT") or None -# API Version, such as (for Azure): 2023-09-15-preview -GEN_AI_API_VERSION = os.environ.get("GEN_AI_API_VERSION") or None -# LiteLLM custom_llm_provider -GEN_AI_LLM_PROVIDER_TYPE = os.environ.get("GEN_AI_LLM_PROVIDER_TYPE") or None -# Override the auto-detection of LLM max context length -GEN_AI_MAX_TOKENS = int(os.environ.get("GEN_AI_MAX_TOKENS") or 0) or None - -# Set this to be enough for an answer + quotes. Also used for Chat -# This is the minimum token context we will leave for the LLM to generate an answer -GEN_AI_NUM_RESERVED_OUTPUT_TOKENS = int( - os.environ.get("GEN_AI_NUM_RESERVED_OUTPUT_TOKENS") or 1024 -) - -# Typically, GenAI models nowadays are at least 4K tokens -GEN_AI_MODEL_FALLBACK_MAX_TOKENS = 4096 - -# Number of tokens from chat history to include at maximum -# 3000 should be enough context regardless of use, no need to include as much as possible -# as this drives up the cost unnecessarily -GEN_AI_HISTORY_CUTOFF = 3000 -# This is used when computing how much context space is available for documents -# ahead of time in order to let the user know if they can "select" more documents -# It represents a maximum "expected" number of input tokens from the latest user -# message. At query time, we don't actually enforce this - we will only throw an -# error if the total # of tokens exceeds the max input tokens. -GEN_AI_SINGLE_USER_MESSAGE_EXPECTED_MAX_TOKENS = 512 -GEN_AI_TEMPERATURE = float(os.environ.get("GEN_AI_TEMPERATURE") or 0) - -# should be used if you are using a custom LLM inference provider that doesn't support -# streaming format AND you are still using the langchain/litellm LLM class -DISABLE_LITELLM_STREAMING = ( - os.environ.get("DISABLE_LITELLM_STREAMING") or "false" -).lower() == "true" - -# extra headers to pass to LiteLLM -LITELLM_EXTRA_HEADERS: dict[str, str] | None = None -_LITELLM_EXTRA_HEADERS_RAW = os.environ.get("LITELLM_EXTRA_HEADERS") -if _LITELLM_EXTRA_HEADERS_RAW: - try: - LITELLM_EXTRA_HEADERS = json.loads(_LITELLM_EXTRA_HEADERS_RAW) - except Exception: - # need to import here to avoid circular imports - from danswer.utils.logger import setup_logger - - logger = setup_logger() - logger.error( - "Failed to parse LITELLM_EXTRA_HEADERS, must be a valid JSON object" - ) - -# if specified, will pass through request headers to the call to the LLM -LITELLM_PASS_THROUGH_HEADERS: list[str] | None = None -_LITELLM_PASS_THROUGH_HEADERS_RAW = os.environ.get("LITELLM_PASS_THROUGH_HEADERS") -if _LITELLM_PASS_THROUGH_HEADERS_RAW: - try: - LITELLM_PASS_THROUGH_HEADERS = json.loads(_LITELLM_PASS_THROUGH_HEADERS_RAW) - except Exception: - # need to import here to avoid circular imports - from danswer.utils.logger import setup_logger - - logger = setup_logger() - logger.error( - "Failed to parse LITELLM_PASS_THROUGH_HEADERS, must be a valid JSON object" - ) diff --git a/backend/danswer/connectors/README.md b/backend/danswer/connectors/README.md deleted file mode 100644 index b50232fa256..00000000000 --- a/backend/danswer/connectors/README.md +++ /dev/null @@ -1,84 +0,0 @@ - - -# Writing a new Danswer Connector -This README covers how to contribute a new Connector for Danswer. It includes an overview of the design, interfaces, -and required changes. - -Thank you for your contribution! - -### Connector Overview -Connectors come in 3 different flows: -- Load Connector: - - Bulk indexes documents to reflect a point in time. This type of connector generally works by either pulling all - documents via a connector's API or loads the documents from some sort of a dump file. -- Poll connector: - - Incrementally updates documents based on a provided time range. It is used by the background job to pull the latest - changes additions and changes since the last round of polling. This connector helps keep the document index up to date - without needing to fetch/embed/index every document which generally be too slow to do frequently on large sets of - documents. -- Event Based connectors: - - Connectors that listen to events and update documents accordingly. - - Currently not used by the background job, this exists for future design purposes. - - -### Connector Implementation -Refer to [interfaces.py](https://github.com/danswer-ai/danswer/blob/main/backend/danswer/connectors/interfaces.py) -and this first contributor created Pull Request for a new connector (Shoutout to Dan Brown): -[Reference Pull Request](https://github.com/danswer-ai/danswer/pull/139) - -#### Implementing the new Connector -The connector must subclass one or more of LoadConnector, PollConnector, or EventConnector. - -The `__init__` should take arguments for configuring what documents the connector will and where it finds those -documents. For example, if you have a wiki site, it may include the configuration for the team, topic, folder, etc. of -the documents to fetch. It may also include the base domain of the wiki. Alternatively, if all the access information -of the connector is stored in the credential/token, then there may be no required arguments. - -`load_credentials` should take a dictionary which provides all the access information that the connector might need. -For example this could be the user's username and access token. - -Refer to the existing connectors for `load_from_state` and `poll_source` examples. There is not yet a process to listen -for EventConnector events, this will come down the line. - -#### Development Tip -It may be handy to test your new connector separate from the rest of the stack while developing. -Follow the below template: - -```commandline -if __name__ == "__main__": - import time - test_connector = NewConnector(space="engineering") - test_connector.load_credentials({ - "user_id": "foobar", - "access_token": "fake_token" - }) - all_docs = test_connector.load_from_state() - - current = time.time() - one_day_ago = current - 24 * 60 * 60 # 1 day - latest_docs = test_connector.poll_source(one_day_ago, current) -``` - - -### Additional Required Changes: -#### Backend Changes -- Add a new type to -[DocumentSource](https://github.com/danswer-ai/danswer/blob/main/backend/danswer/configs/constants.py) -- Add a mapping from DocumentSource (and optionally connector type) to the right connector class -[here](https://github.com/danswer-ai/danswer/blob/main/backend/danswer/connectors/factory.py#L33) - -#### Frontend Changes -- Create the new connector directory and admin page under `danswer/web/src/app/admin/connectors/` -- Create the new icon, type, source, and filter changes -(refer to existing [PR](https://github.com/danswer-ai/danswer/pull/139)) - -#### Docs Changes -Create the new connector page (with guiding images!) with how to get the connector credentials and how to set up the -connector in Danswer. Then create a Pull Request in https://github.com/danswer-ai/danswer-docs - - -### Before opening PR -1. Be sure to fully test changes end to end with setting up the connector and updating the index with new docs from the -new connector. -2. Be sure to run the linting/formatting, refer to the formatting and linting section in -[CONTRIBUTING.md](https://github.com/danswer-ai/danswer/blob/main/CONTRIBUTING.md#formatting-and-linting) diff --git a/backend/danswer/connectors/__init__.py b/backend/danswer/connectors/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/axero/__init__.py b/backend/danswer/connectors/axero/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/axero/connector.py b/backend/danswer/connectors/axero/connector.py deleted file mode 100644 index a4d5162b6ce..00000000000 --- a/backend/danswer/connectors/axero/connector.py +++ /dev/null @@ -1,363 +0,0 @@ -import time -from datetime import datetime -from datetime import timezone -from typing import Any - -import requests -from pydantic import BaseModel - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import ( - process_in_batches, -) -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -ENTITY_NAME_MAP = {1: "Forum", 3: "Article", 4: "Blog", 9: "Wiki"} - - -def _get_auth_header(api_key: str) -> dict[str, str]: - return {"Rest-Api-Key": api_key} - - -@retry_builder() -@rate_limit_builder(max_calls=5, period=1) -def _rate_limited_request( - endpoint: str, headers: dict, params: dict | None = None -) -> Any: - # https://my.axerosolutions.com/spaces/5/communifire-documentation/wiki/view/370/rest-api - return requests.get(endpoint, headers=headers, params=params) - - -# https://my.axerosolutions.com/spaces/5/communifire-documentation/wiki/view/595/rest-api-get-content-list -def _get_entities( - entity_type: int, - api_key: str, - axero_base_url: str, - start: datetime, - end: datetime, - space_id: str | None = None, -) -> list[dict]: - endpoint = axero_base_url + "api/content/list" - page_num = 1 - pages_fetched = 0 - pages_to_return = [] - break_out = False - while True: - params = { - "EntityType": str(entity_type), - "SortColumn": "DateUpdated", - "SortOrder": "1", # descending - "StartPage": str(page_num), - } - - if space_id is not None: - params["SpaceID"] = space_id - - res = _rate_limited_request( - endpoint, headers=_get_auth_header(api_key), params=params - ) - res.raise_for_status() - - # Axero limitations: - # No next page token, can paginate but things may have changed - # for example, a doc that hasn't been read in by Danswer is updated and is now front of the list - # due to this limitation and the fact that Axero has no rate limiting but API calls can cause - # increased latency for the team, we have to just fetch all the pages quickly to reduce the - # chance of missing a document due to an update (it will still get updated next pass) - # Assumes the volume of data isn't too big to store in memory (probably fine) - data = res.json() - total_records = data["TotalRecords"] - contents = data["ResponseData"] - pages_fetched += len(contents) - logger.debug(f"Fetched {pages_fetched} {ENTITY_NAME_MAP[entity_type]}") - - for page in contents: - update_time = time_str_to_utc(page["DateUpdated"]) - - if update_time > end: - continue - - if update_time < start: - break_out = True - break - - pages_to_return.append(page) - - if pages_fetched >= total_records: - break - - page_num += 1 - - if break_out: - break - - return pages_to_return - - -def _get_obj_by_id(obj_id: int, api_key: str, axero_base_url: str) -> dict: - endpoint = axero_base_url + f"api/content/{obj_id}" - res = _rate_limited_request(endpoint, headers=_get_auth_header(api_key)) - res.raise_for_status() - - return res.json() - - -class AxeroForum(BaseModel): - doc_id: str - title: str - link: str - initial_content: str - responses: list[str] - last_update: datetime - - -def _map_post_to_parent( - posts: dict, - api_key: str, - axero_base_url: str, -) -> list[AxeroForum]: - """Cannot handle in batches since the posts aren't ordered or structured in any way - may need to map any number of them to the initial post""" - epoch_str = "1970-01-01T00:00:00.000" - post_map: dict[int, AxeroForum] = {} - - for ind, post in enumerate(posts): - if (ind + 1) % 25 == 0: - logger.debug(f"Processed {ind + 1} posts or responses") - - post_time = time_str_to_utc( - post.get("DateUpdated") or post.get("DateCreated") or epoch_str - ) - p_id = post.get("ParentContentID") - if p_id in post_map: - axero_forum = post_map[p_id] - axero_forum.responses.insert(0, post.get("ContentSummary")) - axero_forum.last_update = max(axero_forum.last_update, post_time) - else: - initial_post_d = _get_obj_by_id(p_id, api_key, axero_base_url)[ - "ResponseData" - ] - initial_post_time = time_str_to_utc( - initial_post_d.get("DateUpdated") - or initial_post_d.get("DateCreated") - or epoch_str - ) - post_map[p_id] = AxeroForum( - doc_id="AXERO_" + str(initial_post_d.get("ContentID")), - title=initial_post_d.get("ContentTitle"), - link=initial_post_d.get("ContentURL"), - initial_content=initial_post_d.get("ContentSummary"), - responses=[post.get("ContentSummary")], - last_update=max(post_time, initial_post_time), - ) - - return list(post_map.values()) - - -def _get_forums( - api_key: str, - axero_base_url: str, - space_id: str | None = None, -) -> list[dict]: - endpoint = axero_base_url + "api/content/list" - page_num = 1 - pages_fetched = 0 - pages_to_return = [] - break_out = False - - while True: - params = { - "EntityType": "54", - "SortColumn": "DateUpdated", - "SortOrder": "1", # descending - "StartPage": str(page_num), - } - - if space_id is not None: - params["SpaceID"] = space_id - - res = _rate_limited_request( - endpoint, headers=_get_auth_header(api_key), params=params - ) - res.raise_for_status() - - data = res.json() - total_records = data["TotalRecords"] - contents = data["ResponseData"] - pages_fetched += len(contents) - logger.debug(f"Fetched {pages_fetched} forums") - - for page in contents: - pages_to_return.append(page) - - if pages_fetched >= total_records: - break - - page_num += 1 - - if break_out: - break - - return pages_to_return - - -def _translate_forum_to_doc(af: AxeroForum) -> Document: - doc = Document( - id=af.doc_id, - sections=[Section(link=af.link, text=reply) for reply in af.responses], - source=DocumentSource.AXERO, - semantic_identifier=af.title, - doc_updated_at=af.last_update, - metadata={}, - ) - - return doc - - -def _translate_content_to_doc(content: dict) -> Document: - page_text = "" - summary = content.get("ContentSummary") - body = content.get("ContentBody") - if summary: - page_text += f"{summary}\n" - - if body: - content_parsed = parse_html_page_basic(body) - page_text += content_parsed - - doc = Document( - id="AXERO_" + str(content["ContentID"]), - sections=[Section(link=content["ContentURL"], text=page_text)], - source=DocumentSource.AXERO, - semantic_identifier=content["ContentTitle"], - doc_updated_at=time_str_to_utc(content["DateUpdated"]), - metadata={"space": content["SpaceName"]}, - ) - - return doc - - -class AxeroConnector(PollConnector): - def __init__( - self, - # Strings of the integer ids of the spaces - spaces: list[str] | None = None, - include_article: bool = True, - include_blog: bool = True, - include_wiki: bool = True, - include_forum: bool = True, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.include_article = include_article - self.include_blog = include_blog - self.include_wiki = include_wiki - self.include_forum = include_forum - self.batch_size = batch_size - self.space_ids = spaces - self.axero_key = None - self.base_url = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.axero_key = credentials["axero_api_token"] - # As the API key specifically applies to a particular deployment, this is - # included as part of the credential - base_url = credentials["base_url"] - if not base_url.endswith("/"): - base_url += "/" - self.base_url = base_url - return None - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if not self.axero_key or not self.base_url: - raise ConnectorMissingCredentialError("Axero") - - start_datetime = datetime.utcfromtimestamp(start).replace(tzinfo=timezone.utc) - end_datetime = datetime.utcfromtimestamp(end).replace(tzinfo=timezone.utc) - - entity_types = [] - if self.include_article: - entity_types.append(3) - if self.include_blog: - entity_types.append(4) - if self.include_wiki: - entity_types.append(9) - - iterable_space_ids = self.space_ids if self.space_ids else [None] - - for space_id in iterable_space_ids: - for entity in entity_types: - axero_obj = _get_entities( - entity_type=entity, - api_key=self.axero_key, - axero_base_url=self.base_url, - start=start_datetime, - end=end_datetime, - space_id=space_id, - ) - yield from process_in_batches( - objects=axero_obj, - process_function=_translate_content_to_doc, - batch_size=self.batch_size, - ) - - if self.include_forum: - forums_posts = _get_forums( - api_key=self.axero_key, - axero_base_url=self.base_url, - space_id=space_id, - ) - - all_axero_forums = _map_post_to_parent( - posts=forums_posts, - api_key=self.axero_key, - axero_base_url=self.base_url, - ) - - filtered_forums = [ - f - for f in all_axero_forums - if f.last_update >= start_datetime and f.last_update <= end_datetime - ] - - yield from process_in_batches( - objects=filtered_forums, - process_function=_translate_forum_to_doc, - batch_size=self.batch_size, - ) - - -if __name__ == "__main__": - import os - - connector = AxeroConnector() - connector.load_credentials( - { - "axero_api_token": os.environ["AXERO_API_TOKEN"], - "base_url": os.environ["AXERO_BASE_URL"], - } - ) - current = time.time() - - one_year_ago = current - 24 * 60 * 60 * 360 - latest_docs = connector.poll_source(one_year_ago, current) - - print(next(latest_docs)) diff --git a/backend/danswer/connectors/blob/__init__.py b/backend/danswer/connectors/blob/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/blob/connector.py b/backend/danswer/connectors/blob/connector.py deleted file mode 100644 index a664a3d764a..00000000000 --- a/backend/danswer/connectors/blob/connector.py +++ /dev/null @@ -1,277 +0,0 @@ -import os -from datetime import datetime -from datetime import timezone -from io import BytesIO -from typing import Any -from typing import Optional - -import boto3 -from botocore.client import Config -from mypy_boto3_s3 import S3Client - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import BlobType -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class BlobStorageConnector(LoadConnector, PollConnector): - def __init__( - self, - bucket_type: str, - bucket_name: str, - prefix: str = "", - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.bucket_type: BlobType = BlobType(bucket_type) - self.bucket_name = bucket_name - self.prefix = prefix if not prefix or prefix.endswith("/") else prefix + "/" - self.batch_size = batch_size - self.s3_client: Optional[S3Client] = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - """Checks for boto3 credentials based on the bucket type. - (1) R2: Access Key ID, Secret Access Key, Account ID - (2) S3: AWS Access Key ID, AWS Secret Access Key - (3) GOOGLE_CLOUD_STORAGE: Access Key ID, Secret Access Key, Project ID - (4) OCI_STORAGE: Namespace, Region, Access Key ID, Secret Access Key - - For each bucket type, the method initializes the appropriate S3 client: - - R2: Uses Cloudflare R2 endpoint with S3v4 signature - - S3: Creates a standard boto3 S3 client - - GOOGLE_CLOUD_STORAGE: Uses Google Cloud Storage endpoint - - OCI_STORAGE: Uses Oracle Cloud Infrastructure Object Storage endpoint - - Raises ConnectorMissingCredentialError if required credentials are missing. - Raises ValueError for unsupported bucket types. - """ - - logger.debug( - f"Loading credentials for {self.bucket_name} or type {self.bucket_type}" - ) - - if self.bucket_type == BlobType.R2: - if not all( - credentials.get(key) - for key in ["r2_access_key_id", "r2_secret_access_key", "account_id"] - ): - raise ConnectorMissingCredentialError("Cloudflare R2") - self.s3_client = boto3.client( - "s3", - endpoint_url=f"https://{credentials['account_id']}.r2.cloudflarestorage.com", - aws_access_key_id=credentials["r2_access_key_id"], - aws_secret_access_key=credentials["r2_secret_access_key"], - region_name="auto", - config=Config(signature_version="s3v4"), - ) - - elif self.bucket_type == BlobType.S3: - if not all( - credentials.get(key) - for key in ["aws_access_key_id", "aws_secret_access_key"] - ): - raise ConnectorMissingCredentialError("Google Cloud Storage") - - session = boto3.Session( - aws_access_key_id=credentials["aws_access_key_id"], - aws_secret_access_key=credentials["aws_secret_access_key"], - ) - self.s3_client = session.client("s3") - - elif self.bucket_type == BlobType.GOOGLE_CLOUD_STORAGE: - if not all( - credentials.get(key) for key in ["access_key_id", "secret_access_key"] - ): - raise ConnectorMissingCredentialError("Google Cloud Storage") - - self.s3_client = boto3.client( - "s3", - endpoint_url="https://storage.googleapis.com", - aws_access_key_id=credentials["access_key_id"], - aws_secret_access_key=credentials["secret_access_key"], - region_name="auto", - ) - - elif self.bucket_type == BlobType.OCI_STORAGE: - if not all( - credentials.get(key) - for key in ["namespace", "region", "access_key_id", "secret_access_key"] - ): - raise ConnectorMissingCredentialError("Oracle Cloud Infrastructure") - - self.s3_client = boto3.client( - "s3", - endpoint_url=f"https://{credentials['namespace']}.compat.objectstorage.{credentials['region']}.oraclecloud.com", - aws_access_key_id=credentials["access_key_id"], - aws_secret_access_key=credentials["secret_access_key"], - region_name=credentials["region"], - ) - - else: - raise ValueError(f"Unsupported bucket type: {self.bucket_type}") - - return None - - def _download_object(self, key: str) -> bytes: - if self.s3_client is None: - raise ConnectorMissingCredentialError("Blob storage") - object = self.s3_client.get_object(Bucket=self.bucket_name, Key=key) - return object["Body"].read() - - # NOTE: Left in as may be useful for one-off access to documents and sharing across orgs. - # def _get_presigned_url(self, key: str) -> str: - # if self.s3_client is None: - # raise ConnectorMissingCredentialError("Blog storage") - - # url = self.s3_client.generate_presigned_url( - # "get_object", - # Params={"Bucket": self.bucket_name, "Key": key}, - # ExpiresIn=self.presign_length, - # ) - # return url - - def _get_blob_link(self, key: str) -> str: - if self.s3_client is None: - raise ConnectorMissingCredentialError("Blob storage") - - if self.bucket_type == BlobType.R2: - account_id = self.s3_client.meta.endpoint_url.split("//")[1].split(".")[0] - return f"https://{account_id}.r2.cloudflarestorage.com/{self.bucket_name}/{key}" - - elif self.bucket_type == BlobType.S3: - region = self.s3_client.meta.region_name - return f"https://{self.bucket_name}.s3.{region}.amazonaws.com/{key}" - - elif self.bucket_type == BlobType.GOOGLE_CLOUD_STORAGE: - return f"https://storage.cloud.google.com/{self.bucket_name}/{key}" - - elif self.bucket_type == BlobType.OCI_STORAGE: - namespace = self.s3_client.meta.endpoint_url.split("//")[1].split(".")[0] - region = self.s3_client.meta.region_name - return f"https://objectstorage.{region}.oraclecloud.com/n/{namespace}/b/{self.bucket_name}/o/{key}" - - else: - raise ValueError(f"Unsupported bucket type: {self.bucket_type}") - - def _yield_blob_objects( - self, - start: datetime, - end: datetime, - ) -> GenerateDocumentsOutput: - if self.s3_client is None: - raise ConnectorMissingCredentialError("Blob storage") - - paginator = self.s3_client.get_paginator("list_objects_v2") - pages = paginator.paginate(Bucket=self.bucket_name, Prefix=self.prefix) - - batch: list[Document] = [] - for page in pages: - if "Contents" not in page: - continue - - for obj in page["Contents"]: - if obj["Key"].endswith("/"): - continue - - last_modified = obj["LastModified"].replace(tzinfo=timezone.utc) - - if not start <= last_modified <= end: - continue - - downloaded_file = self._download_object(obj["Key"]) - link = self._get_blob_link(obj["Key"]) - name = os.path.basename(obj["Key"]) - - try: - text = extract_file_text( - name, - BytesIO(downloaded_file), - break_on_unprocessable=False, - ) - batch.append( - Document( - id=f"{self.bucket_type}:{self.bucket_name}:{obj['Key']}", - sections=[Section(link=link, text=text)], - source=DocumentSource(self.bucket_type.value), - semantic_identifier=name, - doc_updated_at=last_modified, - metadata={}, - ) - ) - if len(batch) == self.batch_size: - yield batch - batch = [] - - except Exception as e: - logger.exception( - f"Error decoding object {obj['Key']} as UTF-8: {e}" - ) - if batch: - yield batch - - def load_from_state(self) -> GenerateDocumentsOutput: - logger.debug("Loading blob objects") - return self._yield_blob_objects( - start=datetime(1970, 1, 1, tzinfo=timezone.utc), - end=datetime.now(timezone.utc), - ) - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.s3_client is None: - raise ConnectorMissingCredentialError("Blob storage") - - start_datetime = datetime.fromtimestamp(start, tz=timezone.utc) - end_datetime = datetime.fromtimestamp(end, tz=timezone.utc) - - for batch in self._yield_blob_objects(start_datetime, end_datetime): - yield batch - - return None - - -if __name__ == "__main__": - credentials_dict = { - "aws_access_key_id": os.environ.get("AWS_ACCESS_KEY_ID"), - "aws_secret_access_key": os.environ.get("AWS_SECRET_ACCESS_KEY"), - } - - # Initialize the connector - connector = BlobStorageConnector( - bucket_type=os.environ.get("BUCKET_TYPE") or "s3", - bucket_name=os.environ.get("BUCKET_NAME") or "test", - prefix="", - ) - - try: - connector.load_credentials(credentials_dict) - document_batch_generator = connector.load_from_state() - for document_batch in document_batch_generator: - print("First batch of documents:") - for doc in document_batch: - print(f"Document ID: {doc.id}") - print(f"Semantic Identifier: {doc.semantic_identifier}") - print(f"Source: {doc.source}") - print(f"Updated At: {doc.doc_updated_at}") - print("Sections:") - for section in doc.sections: - print(f" - Link: {section.link}") - print(f" - Text: {section.text[:100]}...") - print("---") - break - - except ConnectorMissingCredentialError as e: - print(f"Error: {e}") - except Exception as e: - print(f"An unexpected error occurred: {e}") diff --git a/backend/danswer/connectors/bookstack/__init__.py b/backend/danswer/connectors/bookstack/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/bookstack/client.py b/backend/danswer/connectors/bookstack/client.py deleted file mode 100644 index 57f2b616e5b..00000000000 --- a/backend/danswer/connectors/bookstack/client.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import Any - -import requests - - -class BookStackClientRequestFailedError(ConnectionError): - def __init__(self, status: int, error: str) -> None: - super().__init__( - "BookStack Client request failed with status {status}: {error}".format( - status=status, error=error - ) - ) - - -class BookStackApiClient: - def __init__( - self, - base_url: str, - token_id: str, - token_secret: str, - ) -> None: - self.base_url = base_url - self.token_id = token_id - self.token_secret = token_secret - - def get(self, endpoint: str, params: dict[str, str]) -> dict[str, Any]: - url: str = self._build_url(endpoint) - headers = self._build_headers() - response = requests.get(url, headers=headers, params=params) - - try: - json = response.json() - except Exception: - json = {} - - if response.status_code >= 300: - error = response.reason - response_error = json.get("error", {}).get("message", "") - if response_error: - error = response_error - raise BookStackClientRequestFailedError(response.status_code, error) - - return json - - def _build_headers(self) -> dict[str, str]: - auth = "Token " + self.token_id + ":" + self.token_secret - return { - "Authorization": auth, - "Accept": "application/json", - } - - def _build_url(self, endpoint: str) -> str: - return self.base_url.rstrip("/") + "/api/" + endpoint.lstrip("/") - - def build_app_url(self, endpoint: str) -> str: - return self.base_url.rstrip("/") + "/" + endpoint.lstrip("/") diff --git a/backend/danswer/connectors/bookstack/connector.py b/backend/danswer/connectors/bookstack/connector.py deleted file mode 100644 index f2e692d2c5f..00000000000 --- a/backend/danswer/connectors/bookstack/connector.py +++ /dev/null @@ -1,219 +0,0 @@ -import html -import time -from collections.abc import Callable -from datetime import datetime -from typing import Any - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.bookstack.client import BookStackApiClient -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic - - -class BookstackConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.batch_size = batch_size - self.bookstack_client: BookStackApiClient | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.bookstack_client = BookStackApiClient( - base_url=credentials["bookstack_base_url"], - token_id=credentials["bookstack_api_token_id"], - token_secret=credentials["bookstack_api_token_secret"], - ) - return None - - @staticmethod - def _get_doc_batch( - batch_size: int, - bookstack_client: BookStackApiClient, - endpoint: str, - transformer: Callable[[BookStackApiClient, dict], Document], - start_ind: int, - start: SecondsSinceUnixEpoch | None = None, - end: SecondsSinceUnixEpoch | None = None, - ) -> tuple[list[Document], int]: - doc_batch: list[Document] = [] - - params = { - "count": str(batch_size), - "offset": str(start_ind), - "sort": "+id", - } - - if start: - params["filter[updated_at:gte]"] = datetime.utcfromtimestamp( - start - ).strftime("%Y-%m-%d %H:%M:%S") - - if end: - params["filter[updated_at:lte]"] = datetime.utcfromtimestamp(end).strftime( - "%Y-%m-%d %H:%M:%S" - ) - - batch = bookstack_client.get(endpoint, params=params).get("data", []) - for item in batch: - doc_batch.append(transformer(bookstack_client, item)) - - return doc_batch, len(batch) - - @staticmethod - def _book_to_document( - bookstack_client: BookStackApiClient, book: dict[str, Any] - ) -> Document: - url = bookstack_client.build_app_url("/books/" + str(book.get("slug"))) - title = str(book.get("name", "")) - text = book.get("name", "") + "\n" + book.get("description", "") - updated_at_str = ( - str(book.get("updated_at")) if book.get("updated_at") is not None else None - ) - return Document( - id="book__" + str(book.get("id")), - sections=[Section(link=url, text=text)], - source=DocumentSource.BOOKSTACK, - semantic_identifier="Book: " + title, - title=title, - doc_updated_at=time_str_to_utc(updated_at_str) - if updated_at_str is not None - else None, - metadata={"type": "book"}, - ) - - @staticmethod - def _chapter_to_document( - bookstack_client: BookStackApiClient, chapter: dict[str, Any] - ) -> Document: - url = bookstack_client.build_app_url( - "/books/" - + str(chapter.get("book_slug")) - + "/chapter/" - + str(chapter.get("slug")) - ) - title = str(chapter.get("name", "")) - text = chapter.get("name", "") + "\n" + chapter.get("description", "") - updated_at_str = ( - str(chapter.get("updated_at")) - if chapter.get("updated_at") is not None - else None - ) - return Document( - id="chapter__" + str(chapter.get("id")), - sections=[Section(link=url, text=text)], - source=DocumentSource.BOOKSTACK, - semantic_identifier="Chapter: " + title, - title=title, - doc_updated_at=time_str_to_utc(updated_at_str) - if updated_at_str is not None - else None, - metadata={"type": "chapter"}, - ) - - @staticmethod - def _shelf_to_document( - bookstack_client: BookStackApiClient, shelf: dict[str, Any] - ) -> Document: - url = bookstack_client.build_app_url("/shelves/" + str(shelf.get("slug"))) - title = str(shelf.get("name", "")) - text = shelf.get("name", "") + "\n" + shelf.get("description", "") - updated_at_str = ( - str(shelf.get("updated_at")) - if shelf.get("updated_at") is not None - else None - ) - return Document( - id="shelf:" + str(shelf.get("id")), - sections=[Section(link=url, text=text)], - source=DocumentSource.BOOKSTACK, - semantic_identifier="Shelf: " + title, - title=title, - doc_updated_at=time_str_to_utc(updated_at_str) - if updated_at_str is not None - else None, - metadata={"type": "shelf"}, - ) - - @staticmethod - def _page_to_document( - bookstack_client: BookStackApiClient, page: dict[str, Any] - ) -> Document: - page_id = str(page.get("id")) - title = str(page.get("name", "")) - page_data = bookstack_client.get("/pages/" + page_id, {}) - url = bookstack_client.build_app_url( - "/books/" - + str(page.get("book_slug")) - + "/page/" - + str(page_data.get("slug")) - ) - page_html = "

" + html.escape(title) + "

" + str(page_data.get("html")) - text = parse_html_page_basic(page_html) - updated_at_str = ( - str(page_data.get("updated_at")) - if page_data.get("updated_at") is not None - else None - ) - time.sleep(0.1) - return Document( - id="page:" + page_id, - sections=[Section(link=url, text=text)], - source=DocumentSource.BOOKSTACK, - semantic_identifier="Page: " + str(title), - title=str(title), - doc_updated_at=time_str_to_utc(updated_at_str) - if updated_at_str is not None - else None, - metadata={"type": "page"}, - ) - - def load_from_state(self) -> GenerateDocumentsOutput: - if self.bookstack_client is None: - raise ConnectorMissingCredentialError("Bookstack") - - return self.poll_source(None, None) - - def poll_source( - self, start: SecondsSinceUnixEpoch | None, end: SecondsSinceUnixEpoch | None - ) -> GenerateDocumentsOutput: - if self.bookstack_client is None: - raise ConnectorMissingCredentialError("Bookstack") - - transform_by_endpoint: dict[ - str, Callable[[BookStackApiClient, dict], Document] - ] = { - "/books": self._book_to_document, - "/chapters": self._chapter_to_document, - "/shelves": self._shelf_to_document, - "/pages": self._page_to_document, - } - - for endpoint, transform in transform_by_endpoint.items(): - start_ind = 0 - while True: - doc_batch, num_results = self._get_doc_batch( - batch_size=self.batch_size, - bookstack_client=self.bookstack_client, - endpoint=endpoint, - transformer=transform, - start_ind=start_ind, - start=start, - end=end, - ) - start_ind += num_results - if doc_batch: - yield doc_batch - - if num_results < self.batch_size: - break - else: - time.sleep(0.2) diff --git a/backend/danswer/connectors/clickup/__init__.py b/backend/danswer/connectors/clickup/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/clickup/connector.py b/backend/danswer/connectors/clickup/connector.py deleted file mode 100644 index 78d572af413..00000000000 --- a/backend/danswer/connectors/clickup/connector.py +++ /dev/null @@ -1,216 +0,0 @@ -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import Optional - -import requests - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section - - -CLICKUP_API_BASE_URL = "https://api.clickup.com/api/v2" - - -class ClickupConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - api_token: str | None = None, - team_id: str | None = None, - connector_type: str | None = None, - connector_ids: list[str] | None = None, - retrieve_task_comments: bool = True, - ) -> None: - self.batch_size = batch_size - self.api_token = api_token - self.team_id = team_id - self.connector_type = connector_type if connector_type else "workspace" - self.connector_ids = connector_ids - self.retrieve_task_comments = retrieve_task_comments - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.api_token = credentials["clickup_api_token"] - self.team_id = credentials["clickup_team_id"] - return None - - @retry_builder() - @rate_limit_builder(max_calls=100, period=60) - def _make_request(self, endpoint: str, params: Optional[dict] = None) -> Any: - if not self.api_token: - raise ConnectorMissingCredentialError("Clickup") - - headers = {"Authorization": self.api_token} - - response = requests.get( - f"{CLICKUP_API_BASE_URL}/{endpoint}", headers=headers, params=params - ) - - response.raise_for_status() - - return response.json() - - def _get_task_comments(self, task_id: str) -> list[Section]: - url_endpoint = f"/task/{task_id}/comment" - response = self._make_request(url_endpoint) - comments = [ - Section( - link=f'https://app.clickup.com/t/{task_id}?comment={comment_dict["id"]}', - text=comment_dict["comment_text"], - ) - for comment_dict in response["comments"] - ] - - return comments - - def _get_all_tasks_filtered( - self, - start: int | None = None, - end: int | None = None, - ) -> GenerateDocumentsOutput: - doc_batch: list[Document] = [] - page: int = 0 - params = { - "include_markdown_description": "true", - "include_closed": "true", - "page": page, - } - - if start is not None: - params["date_updated_gt"] = start - if end is not None: - params["date_updated_lt"] = end - - if self.connector_type == "list": - params["list_ids[]"] = self.connector_ids - elif self.connector_type == "folder": - params["project_ids[]"] = self.connector_ids - elif self.connector_type == "space": - params["space_ids[]"] = self.connector_ids - - url_endpoint = f"/team/{self.team_id}/task" - - while True: - response = self._make_request(url_endpoint, params) - - page += 1 - params["page"] = page - - for task in response["tasks"]: - document = Document( - id=task["id"], - source=DocumentSource.CLICKUP, - semantic_identifier=task["name"], - doc_updated_at=( - datetime.fromtimestamp( - round(float(task["date_updated"]) / 1000, 3) - ).replace(tzinfo=timezone.utc) - ), - primary_owners=[ - BasicExpertInfo( - display_name=task["creator"]["username"], - email=task["creator"]["email"], - ) - ], - secondary_owners=[ - BasicExpertInfo( - display_name=assignee["username"], - email=assignee["email"], - ) - for assignee in task["assignees"] - ], - title=task["name"], - sections=[ - Section( - link=task["url"], - text=( - task["markdown_description"] - if "markdown_description" in task - else task["description"] - ), - ) - ], - metadata={ - "id": task["id"], - "status": task["status"]["status"], - "list": task["list"]["name"], - "project": task["project"]["name"], - "folder": task["folder"]["name"], - "space_id": task["space"]["id"], - "tags": [tag["name"] for tag in task["tags"]], - "priority": ( - task["priority"]["priority"] - if "priority" in task and task["priority"] is not None - else "" - ), - }, - ) - - extra_fields = [ - "date_created", - "date_updated", - "date_closed", - "date_done", - "due_date", - ] - for extra_field in extra_fields: - if extra_field in task and task[extra_field] is not None: - document.metadata[extra_field] = task[extra_field] - - if self.retrieve_task_comments: - document.sections.extend(self._get_task_comments(task["id"])) - - doc_batch.append(document) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if response.get("last_page") is True or len(response["tasks"]) < 100: - break - - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - if self.api_token is None: - raise ConnectorMissingCredentialError("Clickup") - - return self._get_all_tasks_filtered(None, None) - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.api_token is None: - raise ConnectorMissingCredentialError("Clickup") - - return self._get_all_tasks_filtered(int(start * 1000), int(end * 1000)) - - -if __name__ == "__main__": - import os - - clickup_connector = ClickupConnector() - - clickup_connector.load_credentials( - { - "clickup_api_token": os.environ["clickup_api_token"], - "clickup_team_id": os.environ["clickup_team_id"], - } - ) - latest_docs = clickup_connector.load_from_state() - - for doc in latest_docs: - print(doc) diff --git a/backend/danswer/connectors/confluence/__init__.py b/backend/danswer/connectors/confluence/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/confluence/connector.py b/backend/danswer/connectors/confluence/connector.py deleted file mode 100644 index b8dc967a3d9..00000000000 --- a/backend/danswer/connectors/confluence/connector.py +++ /dev/null @@ -1,877 +0,0 @@ -import io -import os -from collections.abc import Callable -from collections.abc import Collection -from datetime import datetime -from datetime import timezone -from functools import lru_cache -from typing import Any -from typing import cast -from urllib.parse import urlparse - -import bs4 -from atlassian import Confluence # type:ignore -from requests import HTTPError - -from danswer.configs.app_configs import ( - CONFLUENCE_CONNECTOR_ATTACHMENT_CHAR_COUNT_THRESHOLD, -) -from danswer.configs.app_configs import CONFLUENCE_CONNECTOR_ATTACHMENT_SIZE_THRESHOLD -from danswer.configs.app_configs import CONFLUENCE_CONNECTOR_INDEX_ARCHIVED_PAGES -from danswer.configs.app_configs import CONFLUENCE_CONNECTOR_LABELS_TO_SKIP -from danswer.configs.app_configs import CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING -from danswer.configs.app_configs import CONTINUE_ON_CONNECTOR_FAILURE -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.confluence.rate_limit_handler import ( - make_confluence_call_handle_rate_limit, -) -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.file_processing.html_utils import format_document_soup -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -# Potential Improvements -# 1. Include attachments, etc -# 2. Segment into Sections for more accurate linking, can split by headers but make sure no text/ordering is lost - - -NO_PERMISSIONS_TO_VIEW_ATTACHMENTS_ERROR_STR = ( - "User not permitted to view attachments on content" -) -NO_PARENT_OR_NO_PERMISSIONS_ERROR_STR = ( - "No parent or not permitted to view content with id" -) - - -def _extract_confluence_keys_from_cloud_url(wiki_url: str) -> tuple[str, str, str]: - """Sample - URL w/ page: https://danswer.atlassian.net/wiki/spaces/1234abcd/pages/5678efgh/overview - URL w/o page: https://danswer.atlassian.net/wiki/spaces/ASAM/overview - - wiki_base is https://danswer.atlassian.net/wiki - space is 1234abcd - page_id is 5678efgh - """ - parsed_url = urlparse(wiki_url) - wiki_base = ( - parsed_url.scheme - + "://" - + parsed_url.netloc - + parsed_url.path.split("/spaces")[0] - ) - - path_parts = parsed_url.path.split("/") - space = path_parts[3] - - page_id = path_parts[5] if len(path_parts) > 5 else "" - return wiki_base, space, page_id - - -def _extract_confluence_keys_from_datacenter_url(wiki_url: str) -> tuple[str, str, str]: - """Sample - URL w/ page https://danswer.ai/confluence/display/1234abcd/pages/5678efgh/overview - URL w/o page https://danswer.ai/confluence/display/1234abcd/overview - wiki_base is https://danswer.ai/confluence - space is 1234abcd - page_id is 5678efgh - """ - # /display/ is always right before the space and at the end of the base print() - DISPLAY = "/display/" - PAGE = "/pages/" - - parsed_url = urlparse(wiki_url) - wiki_base = ( - parsed_url.scheme - + "://" - + parsed_url.netloc - + parsed_url.path.split(DISPLAY)[0] - ) - space = DISPLAY.join(parsed_url.path.split(DISPLAY)[1:]).split("/")[0] - page_id = "" - if (content := parsed_url.path.split(PAGE)) and len(content) > 1: - page_id = content[1] - return wiki_base, space, page_id - - -def extract_confluence_keys_from_url(wiki_url: str) -> tuple[str, str, str, bool]: - is_confluence_cloud = ( - ".atlassian.net/wiki/spaces/" in wiki_url - or ".jira.com/wiki/spaces/" in wiki_url - ) - - try: - if is_confluence_cloud: - wiki_base, space, page_id = _extract_confluence_keys_from_cloud_url( - wiki_url - ) - else: - wiki_base, space, page_id = _extract_confluence_keys_from_datacenter_url( - wiki_url - ) - except Exception as e: - error_msg = f"Not a valid Confluence Wiki Link, unable to extract wiki base, space, and page id. Exception: {e}" - logger.error(error_msg) - raise ValueError(error_msg) - - return wiki_base, space, page_id, is_confluence_cloud - - -@lru_cache() -def _get_user(user_id: str, confluence_client: Confluence) -> str: - """Get Confluence Display Name based on the account-id or userkey value - - Args: - user_id (str): The user id (i.e: the account-id or userkey) - confluence_client (Confluence): The Confluence Client - - Returns: - str: The User Display Name. 'Unknown User' if the user is deactivated or not found - """ - user_not_found = "Unknown User" - - get_user_details_by_accountid = make_confluence_call_handle_rate_limit( - confluence_client.get_user_details_by_accountid - ) - try: - return get_user_details_by_accountid(user_id).get("displayName", user_not_found) - except Exception as e: - logger.warning( - f"Unable to get the User Display Name with the id: '{user_id}' - {e}" - ) - return user_not_found - - -def parse_html_page(text: str, confluence_client: Confluence) -> str: - """Parse a Confluence html page and replace the 'user Id' by the real - User Display Name - - Args: - text (str): The page content - confluence_client (Confluence): Confluence client - - Returns: - str: loaded and formated Confluence page - """ - soup = bs4.BeautifulSoup(text, "html.parser") - for user in soup.findAll("ri:user"): - user_id = ( - user.attrs["ri:account-id"] - if "ri:account-id" in user.attrs - else user.get("ri:userkey") - ) - if not user_id: - logger.warning( - "ri:userkey not found in ri:user element. " f"Found attrs: {user.attrs}" - ) - continue - # Include @ sign for tagging, more clear for LLM - user.replaceWith("@" + _get_user(user_id, confluence_client)) - return format_document_soup(soup) - - -def get_used_attachments(text: str, confluence_client: Confluence) -> list[str]: - """Parse a Confluence html page to generate a list of current - attachment in used - - Args: - text (str): The page content - confluence_client (Confluence): Confluence client - - Returns: - list[str]: List of filename currently in used - """ - files_in_used = [] - soup = bs4.BeautifulSoup(text, "html.parser") - for attachment in soup.findAll("ri:attachment"): - files_in_used.append(attachment.attrs["ri:filename"]) - return files_in_used - - -def _comment_dfs( - comments_str: str, - comment_pages: Collection[dict[str, Any]], - confluence_client: Confluence, -) -> str: - get_page_child_by_type = make_confluence_call_handle_rate_limit( - confluence_client.get_page_child_by_type - ) - - for comment_page in comment_pages: - comment_html = comment_page["body"]["storage"]["value"] - comments_str += "\nComment:\n" + parse_html_page( - comment_html, confluence_client - ) - try: - child_comment_pages = get_page_child_by_type( - comment_page["id"], - type="comment", - start=None, - limit=None, - expand="body.storage.value", - ) - comments_str = _comment_dfs( - comments_str, child_comment_pages, confluence_client - ) - except HTTPError as e: - # not the cleanest, but I'm not aware of a nicer way to check the error - if NO_PARENT_OR_NO_PERMISSIONS_ERROR_STR not in str(e): - raise - - return comments_str - - -def _datetime_from_string(datetime_string: str) -> datetime: - datetime_object = datetime.fromisoformat(datetime_string) - - if datetime_object.tzinfo is None: - # If no timezone info, assume it is UTC - datetime_object = datetime_object.replace(tzinfo=timezone.utc) - else: - # If not in UTC, translate it - datetime_object = datetime_object.astimezone(timezone.utc) - - return datetime_object - - -class RecursiveIndexer: - def __init__( - self, - batch_size: int, - confluence_client: Confluence, - index_recursively: bool, - origin_page_id: str, - ) -> None: - self.batch_size = 1 - # batch_size - self.confluence_client = confluence_client - self.index_recursively = index_recursively - self.origin_page_id = origin_page_id - self.pages = self.recurse_children_pages(0, self.origin_page_id) - - def get_origin_page(self) -> list[dict[str, Any]]: - return [self._fetch_origin_page()] - - def get_pages(self, ind: int, size: int) -> list[dict]: - if ind * size > len(self.pages): - return [] - return self.pages[ind * size : (ind + 1) * size] - - def _fetch_origin_page( - self, - ) -> dict[str, Any]: - get_page_by_id = make_confluence_call_handle_rate_limit( - self.confluence_client.get_page_by_id - ) - try: - origin_page = get_page_by_id( - self.origin_page_id, expand="body.storage.value,version" - ) - return origin_page - except Exception as e: - logger.warning( - f"Appending orgin page with id {self.origin_page_id} failed: {e}" - ) - return {} - - def recurse_children_pages( - self, - start_ind: int, - page_id: str, - ) -> list[dict[str, Any]]: - pages: list[dict[str, Any]] = [] - current_level_pages: list[dict[str, Any]] = [] - next_level_pages: list[dict[str, Any]] = [] - - # Initial fetch of first level children - index = start_ind - while batch := self._fetch_single_depth_child_pages( - index, self.batch_size, page_id - ): - current_level_pages.extend(batch) - index += len(batch) - - pages.extend(current_level_pages) - - # Recursively index children and children's children, etc. - while current_level_pages: - for child in current_level_pages: - child_index = 0 - while child_batch := self._fetch_single_depth_child_pages( - child_index, self.batch_size, child["id"] - ): - next_level_pages.extend(child_batch) - child_index += len(child_batch) - - pages.extend(next_level_pages) - current_level_pages = next_level_pages - next_level_pages = [] - - try: - origin_page = self._fetch_origin_page() - pages.append(origin_page) - except Exception as e: - logger.warning(f"Appending origin page with id {page_id} failed: {e}") - - return pages - - def _fetch_single_depth_child_pages( - self, start_ind: int, batch_size: int, page_id: str - ) -> list[dict[str, Any]]: - child_pages: list[dict[str, Any]] = [] - - get_page_child_by_type = make_confluence_call_handle_rate_limit( - self.confluence_client.get_page_child_by_type - ) - - try: - child_page = get_page_child_by_type( - page_id, - type="page", - start=start_ind, - limit=batch_size, - expand="body.storage.value,version", - ) - - child_pages.extend(child_page) - return child_pages - - except Exception: - logger.warning( - f"Batch failed with page {page_id} at offset {start_ind} " - f"with size {batch_size}, processing pages individually..." - ) - - for i in range(batch_size): - ind = start_ind + i - try: - child_page = get_page_child_by_type( - page_id, - type="page", - start=ind, - limit=1, - expand="body.storage.value,version", - ) - child_pages.extend(child_page) - except Exception as e: - logger.warning(f"Page {page_id} at offset {ind} failed: {e}") - raise e - - return child_pages - - -class ConfluenceConnector(LoadConnector, PollConnector): - def __init__( - self, - wiki_page_url: str, - index_recursively: bool = True, - batch_size: int = INDEX_BATCH_SIZE, - continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE, - # if a page has one of the labels specified in this list, we will just - # skip it. This is generally used to avoid indexing extra sensitive - # pages. - labels_to_skip: list[str] = CONFLUENCE_CONNECTOR_LABELS_TO_SKIP, - ) -> None: - self.batch_size = batch_size - self.continue_on_failure = continue_on_failure - self.labels_to_skip = set(labels_to_skip) - self.recursive_indexer: RecursiveIndexer | None = None - self.index_recursively = index_recursively - ( - self.wiki_base, - self.space, - self.page_id, - self.is_cloud, - ) = extract_confluence_keys_from_url(wiki_page_url) - - self.space_level_scan = False - - self.confluence_client: Confluence | None = None - - if self.page_id is None or self.page_id == "": - self.space_level_scan = True - - logger.info( - f"wiki_base: {self.wiki_base}, space: {self.space}, page_id: {self.page_id}," - + f" space_level_scan: {self.space_level_scan}, index_recursively: {self.index_recursively}" - ) - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - username = credentials["confluence_username"] - access_token = credentials["confluence_access_token"] - self.confluence_client = Confluence( - url=self.wiki_base, - # passing in username causes issues for Confluence data center - username=username if self.is_cloud else None, - password=access_token if self.is_cloud else None, - token=access_token if not self.is_cloud else None, - cloud=self.is_cloud, - ) - return None - - def _fetch_pages( - self, - confluence_client: Confluence, - start_ind: int, - ) -> list[dict[str, Any]]: - def _fetch_space(start_ind: int, batch_size: int) -> list[dict[str, Any]]: - get_all_pages_from_space = make_confluence_call_handle_rate_limit( - confluence_client.get_all_pages_from_space - ) - try: - return get_all_pages_from_space( - self.space, - start=start_ind, - limit=batch_size, - status=( - None if CONFLUENCE_CONNECTOR_INDEX_ARCHIVED_PAGES else "current" - ), - expand="body.storage.value,version", - ) - except Exception: - logger.warning( - f"Batch failed with space {self.space} at offset {start_ind} " - f"with size {batch_size}, processing pages individually..." - ) - - view_pages: list[dict[str, Any]] = [] - for i in range(self.batch_size): - try: - # Could be that one of the pages here failed due to this bug: - # https://jira.atlassian.com/browse/CONFCLOUD-76433 - view_pages.extend( - get_all_pages_from_space( - self.space, - start=start_ind + i, - limit=1, - status=( - None - if CONFLUENCE_CONNECTOR_INDEX_ARCHIVED_PAGES - else "current" - ), - expand="body.storage.value,version", - ) - ) - except HTTPError as e: - logger.warning( - f"Page failed with space {self.space} at offset {start_ind + i}, " - f"trying alternative expand option: {e}" - ) - # Use view instead, which captures most info but is less complete - view_pages.extend( - get_all_pages_from_space( - self.space, - start=start_ind + i, - limit=1, - expand="body.view.value,version", - ) - ) - - return view_pages - - def _fetch_page(start_ind: int, batch_size: int) -> list[dict[str, Any]]: - if self.recursive_indexer is None: - self.recursive_indexer = RecursiveIndexer( - origin_page_id=self.page_id, - batch_size=self.batch_size, - confluence_client=self.confluence_client, - index_recursively=self.index_recursively, - ) - - if self.index_recursively: - return self.recursive_indexer.get_pages(start_ind, batch_size) - else: - return self.recursive_indexer.get_origin_page() - - pages: list[dict[str, Any]] = [] - - try: - pages = ( - _fetch_space(start_ind, self.batch_size) - if self.space_level_scan - else _fetch_page(start_ind, self.batch_size) - ) - return pages - - except Exception as e: - if not self.continue_on_failure: - raise e - - # error checking phase, only reachable if `self.continue_on_failure=True` - for i in range(self.batch_size): - try: - pages = ( - _fetch_space(start_ind, self.batch_size) - if self.space_level_scan - else _fetch_page(start_ind, self.batch_size) - ) - return pages - - except Exception: - logger.exception( - "Ran into exception when fetching pages from Confluence" - ) - - return pages - - def _fetch_comments(self, confluence_client: Confluence, page_id: str) -> str: - get_page_child_by_type = make_confluence_call_handle_rate_limit( - confluence_client.get_page_child_by_type - ) - - try: - comment_pages = cast( - Collection[dict[str, Any]], - get_page_child_by_type( - page_id, - type="comment", - start=None, - limit=None, - expand="body.storage.value", - ), - ) - return _comment_dfs("", comment_pages, confluence_client) - except Exception as e: - if not self.continue_on_failure: - raise e - - logger.exception( - "Ran into exception when fetching comments from Confluence" - ) - return "" - - def _fetch_labels(self, confluence_client: Confluence, page_id: str) -> list[str]: - get_page_labels = make_confluence_call_handle_rate_limit( - confluence_client.get_page_labels - ) - try: - labels_response = get_page_labels(page_id) - return [label["name"] for label in labels_response["results"]] - except Exception as e: - if not self.continue_on_failure: - raise e - - logger.exception("Ran into exception when fetching labels from Confluence") - return [] - - @classmethod - def _attachment_to_download_link( - cls, confluence_client: Confluence, attachment: dict[str, Any] - ) -> str: - return confluence_client.url + attachment["_links"]["download"] - - @classmethod - def _attachment_to_content( - cls, - confluence_client: Confluence, - attachment: dict[str, Any], - ) -> str | None: - """If it returns None, assume that we should skip this attachment.""" - if attachment["metadata"]["mediaType"] in [ - "image/jpeg", - "image/png", - "image/gif", - "image/svg+xml", - "video/mp4", - "video/quicktime", - ]: - return None - - download_link = cls._attachment_to_download_link(confluence_client, attachment) - - attachment_size = attachment["extensions"]["fileSize"] - if attachment_size > CONFLUENCE_CONNECTOR_ATTACHMENT_SIZE_THRESHOLD: - logger.warning( - f"Skipping {download_link} due to size. " - f"size={attachment_size} " - f"threshold={CONFLUENCE_CONNECTOR_ATTACHMENT_SIZE_THRESHOLD}" - ) - return None - - response = confluence_client._session.get(download_link) - if response.status_code != 200: - logger.warning( - f"Failed to fetch {download_link} with invalid status code {response.status_code}" - ) - return None - - extracted_text = extract_file_text( - attachment["title"], io.BytesIO(response.content), False - ) - if len(extracted_text) > CONFLUENCE_CONNECTOR_ATTACHMENT_CHAR_COUNT_THRESHOLD: - logger.warning( - f"Skipping {download_link} due to char count. " - f"char count={len(extracted_text)} " - f"threshold={CONFLUENCE_CONNECTOR_ATTACHMENT_CHAR_COUNT_THRESHOLD}" - ) - return None - - return extracted_text - - def _fetch_attachments( - self, confluence_client: Confluence, page_id: str, files_in_used: list[str] - ) -> tuple[str, list[dict[str, Any]]]: - unused_attachments: list = [] - - get_attachments_from_content = make_confluence_call_handle_rate_limit( - confluence_client.get_attachments_from_content - ) - files_attachment_content: list = [] - - try: - expand = "history.lastUpdated,metadata.labels" - attachments_container = get_attachments_from_content( - page_id, start=0, limit=500, expand=expand - ) - for attachment in attachments_container["results"]: - if attachment["title"] not in files_in_used: - unused_attachments.append(attachment) - continue - - attachment_content = self._attachment_to_content( - confluence_client, attachment - ) - if attachment_content: - files_attachment_content.append(attachment_content) - - except Exception as e: - if isinstance( - e, HTTPError - ) and NO_PERMISSIONS_TO_VIEW_ATTACHMENTS_ERROR_STR in str(e): - logger.warning( - f"User does not have access to attachments on page '{page_id}'" - ) - return "", [] - - if not self.continue_on_failure: - raise e - logger.exception( - f"Ran into exception when fetching attachments from Confluence: {e}" - ) - - return "\n".join(files_attachment_content), unused_attachments - - def _get_doc_batch( - self, start_ind: int, time_filter: Callable[[datetime], bool] | None = None - ) -> tuple[list[Document], list[dict[str, Any]], int]: - doc_batch: list[Document] = [] - unused_attachments: list[dict[str, Any]] = [] - - if self.confluence_client is None: - raise ConnectorMissingCredentialError("Confluence") - batch = self._fetch_pages(self.confluence_client, start_ind) - - for page in batch: - last_modified = _datetime_from_string(page["version"]["when"]) - author = cast(str | None, page["version"].get("by", {}).get("email")) - - if time_filter and not time_filter(last_modified): - continue - - page_id = page["id"] - - if self.labels_to_skip or not CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING: - page_labels = self._fetch_labels(self.confluence_client, page_id) - - # check disallowed labels - if self.labels_to_skip: - label_intersection = self.labels_to_skip.intersection(page_labels) - if label_intersection: - logger.info( - f"Page with ID '{page_id}' has a label which has been " - f"designated as disallowed: {label_intersection}. Skipping." - ) - - continue - - page_html = ( - page["body"].get("storage", page["body"].get("view", {})).get("value") - ) - page_url = self.wiki_base + page["_links"]["webui"] - if not page_html: - logger.debug("Page is empty, skipping: %s", page_url) - continue - page_text = parse_html_page(page_html, self.confluence_client) - - files_in_used = get_used_attachments(page_html, self.confluence_client) - attachment_text, unused_page_attachments = self._fetch_attachments( - self.confluence_client, page_id, files_in_used - ) - unused_attachments.extend(unused_page_attachments) - - page_text += attachment_text - comments_text = self._fetch_comments(self.confluence_client, page_id) - page_text += comments_text - doc_metadata: dict[str, str | list[str]] = {"Wiki Space Name": self.space} - if not CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING and page_labels: - doc_metadata["labels"] = page_labels - - doc_batch.append( - Document( - id=page_url, - sections=[Section(link=page_url, text=page_text)], - source=DocumentSource.CONFLUENCE, - semantic_identifier=page["title"], - doc_updated_at=last_modified, - primary_owners=( - [BasicExpertInfo(email=author)] if author else None - ), - metadata=doc_metadata, - ) - ) - return ( - doc_batch, - unused_attachments, - len(batch), - ) - - def _get_attachment_batch( - self, - start_ind: int, - attachments: list[dict[str, Any]], - time_filter: Callable[[datetime], bool] | None = None, - ) -> tuple[list[Document], int]: - doc_batch: list[Document] = [] - - if self.confluence_client is None: - raise ConnectorMissingCredentialError("Confluence") - - end_ind = min(start_ind + self.batch_size, len(attachments)) - - for attachment in attachments[start_ind:end_ind]: - last_updated = _datetime_from_string( - attachment["history"]["lastUpdated"]["when"] - ) - - if time_filter and not time_filter(last_updated): - continue - - attachment_url = self._attachment_to_download_link( - self.confluence_client, attachment - ) - attachment_content = self._attachment_to_content( - self.confluence_client, attachment - ) - if attachment_content is None: - continue - - creator_email = attachment["history"]["createdBy"].get("email") - - comment = attachment["metadata"].get("comment", "") - doc_metadata: dict[str, str | list[str]] = {"comment": comment} - - attachment_labels: list[str] = [] - if not CONFLUENCE_CONNECTOR_SKIP_LABEL_INDEXING: - for label in attachment["metadata"]["labels"]["results"]: - attachment_labels.append(label["name"]) - - doc_metadata["labels"] = attachment_labels - - doc_batch.append( - Document( - id=attachment_url, - sections=[Section(link=attachment_url, text=attachment_content)], - source=DocumentSource.CONFLUENCE, - semantic_identifier=attachment["title"], - doc_updated_at=last_updated, - primary_owners=( - [BasicExpertInfo(email=creator_email)] - if creator_email - else None - ), - metadata=doc_metadata, - ) - ) - - return doc_batch, end_ind - start_ind - - def load_from_state(self) -> GenerateDocumentsOutput: - unused_attachments = [] - - if self.confluence_client is None: - raise ConnectorMissingCredentialError("Confluence") - - start_ind = 0 - while True: - doc_batch, unused_attachments_batch, num_pages = self._get_doc_batch( - start_ind - ) - unused_attachments.extend(unused_attachments_batch) - start_ind += num_pages - if doc_batch: - yield doc_batch - - if num_pages < self.batch_size: - break - - start_ind = 0 - while True: - attachment_batch, num_attachments = self._get_attachment_batch( - start_ind, unused_attachments - ) - start_ind += num_attachments - if attachment_batch: - yield attachment_batch - - if num_attachments < self.batch_size: - break - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - unused_attachments = [] - - if self.confluence_client is None: - raise ConnectorMissingCredentialError("Confluence") - - start_time = datetime.fromtimestamp(start, tz=timezone.utc) - end_time = datetime.fromtimestamp(end, tz=timezone.utc) - - start_ind = 0 - while True: - doc_batch, unused_attachments_batch, num_pages = self._get_doc_batch( - start_ind, time_filter=lambda t: start_time <= t <= end_time - ) - unused_attachments.extend(unused_attachments_batch) - - start_ind += num_pages - if doc_batch: - yield doc_batch - - if num_pages < self.batch_size: - break - - start_ind = 0 - while True: - attachment_batch, num_attachments = self._get_attachment_batch( - start_ind, - unused_attachments, - time_filter=lambda t: start_time <= t <= end_time, - ) - start_ind += num_attachments - if attachment_batch: - yield attachment_batch - - if num_attachments < self.batch_size: - break - - -if __name__ == "__main__": - connector = ConfluenceConnector(os.environ["CONFLUENCE_TEST_SPACE_URL"]) - connector.load_credentials( - { - "confluence_username": os.environ["CONFLUENCE_USER_NAME"], - "confluence_access_token": os.environ["CONFLUENCE_ACCESS_TOKEN"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/confluence/rate_limit_handler.py b/backend/danswer/connectors/confluence/rate_limit_handler.py deleted file mode 100644 index 8755b78f3f4..00000000000 --- a/backend/danswer/connectors/confluence/rate_limit_handler.py +++ /dev/null @@ -1,69 +0,0 @@ -import time -from collections.abc import Callable -from typing import Any -from typing import cast -from typing import TypeVar - -from requests import HTTPError - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -F = TypeVar("F", bound=Callable[..., Any]) - - -RATE_LIMIT_MESSAGE_LOWERCASE = "Rate limit exceeded".lower() - - -class ConfluenceRateLimitError(Exception): - pass - - -def make_confluence_call_handle_rate_limit(confluence_call: F) -> F: - def wrapped_call(*args: list[Any], **kwargs: Any) -> Any: - max_retries = 10 - starting_delay = 5 - backoff = 2 - max_delay = 600 - - for attempt in range(max_retries): - try: - return confluence_call(*args, **kwargs) - except HTTPError as e: - if ( - e.response.status_code == 429 - or RATE_LIMIT_MESSAGE_LOWERCASE in e.response.text.lower() - ): - retry_after = None - try: - retry_after = int(e.response.headers.get("Retry-After")) - except (ValueError, TypeError): - pass - - if retry_after: - logger.warning( - f"Rate limit hit. Retrying after {retry_after} seconds..." - ) - time.sleep(retry_after) - else: - logger.warning( - "Rate limit hit. Retrying with exponential backoff..." - ) - delay = min(starting_delay * (backoff**attempt), max_delay) - time.sleep(delay) - else: - # re-raise, let caller handle - raise - except AttributeError as e: - # Some error within the Confluence library, unclear why it fails. - # Users reported it to be intermittent, so just retry - logger.warning(f"Confluence Internal Error, retrying... {e}") - delay = min(starting_delay * (backoff**attempt), max_delay) - time.sleep(delay) - - if attempt == max_retries - 1: - raise e - - return cast(F, wrapped_call) diff --git a/backend/danswer/connectors/connector_runner.py b/backend/danswer/connectors/connector_runner.py deleted file mode 100644 index e5ad478fb7f..00000000000 --- a/backend/danswer/connectors/connector_runner.py +++ /dev/null @@ -1,70 +0,0 @@ -import sys -from datetime import datetime - -from danswer.connectors.interfaces import BaseConnector -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -TimeRange = tuple[datetime, datetime] - - -class ConnectorRunner: - def __init__( - self, - connector: BaseConnector, - time_range: TimeRange | None = None, - fail_loudly: bool = False, - ): - self.connector = connector - - if isinstance(self.connector, PollConnector): - if time_range is None: - raise ValueError("time_range is required for PollConnector") - - self.doc_batch_generator = self.connector.poll_source( - time_range[0].timestamp(), time_range[1].timestamp() - ) - - elif isinstance(self.connector, LoadConnector): - if time_range and fail_loudly: - raise ValueError( - "time_range specified, but passed in connector is not a PollConnector" - ) - - self.doc_batch_generator = self.connector.load_from_state() - - else: - raise ValueError(f"Invalid connector. type: {type(self.connector)}") - - def run(self) -> GenerateDocumentsOutput: - """Adds additional exception logging to the connector.""" - try: - yield from self.doc_batch_generator - except Exception: - exc_type, _, exc_traceback = sys.exc_info() - - # Traverse the traceback to find the last frame where the exception was raised - tb = exc_traceback - if tb is None: - logger.error("No traceback found for exception") - raise - - while tb.tb_next: - tb = tb.tb_next # Move to the next frame in the traceback - - # Get the local variables from the frame where the exception occurred - local_vars = tb.tb_frame.f_locals - local_vars_str = "\n".join( - f"{key}: {value}" for key, value in local_vars.items() - ) - logger.error( - f"Error in connector. type: {exc_type};\n" - f"local_vars below -> \n{local_vars_str}" - ) - raise diff --git a/backend/danswer/connectors/cross_connector_utils/__init__.py b/backend/danswer/connectors/cross_connector_utils/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/cross_connector_utils/miscellaneous_utils.py b/backend/danswer/connectors/cross_connector_utils/miscellaneous_utils.py deleted file mode 100644 index 897503dca99..00000000000 --- a/backend/danswer/connectors/cross_connector_utils/miscellaneous_utils.py +++ /dev/null @@ -1,64 +0,0 @@ -from collections.abc import Callable -from collections.abc import Iterator -from datetime import datetime -from datetime import timezone -from typing import TypeVar - -from dateutil.parser import parse - -from danswer.configs.constants import IGNORE_FOR_QA -from danswer.connectors.models import BasicExpertInfo -from danswer.utils.text_processing import is_valid_email - - -def datetime_to_utc(dt: datetime) -> datetime: - if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: - dt = dt.replace(tzinfo=timezone.utc) - - return dt.astimezone(timezone.utc) - - -def time_str_to_utc(datetime_str: str) -> datetime: - dt = parse(datetime_str) - return datetime_to_utc(dt) - - -def basic_expert_info_representation(info: BasicExpertInfo) -> str | None: - if info.first_name and info.last_name: - return f"{info.first_name} {info.middle_initial} {info.last_name}" - - if info.display_name: - return info.display_name - - if info.email and is_valid_email(info.email): - return info.email - - if info.first_name: - return info.first_name - - return None - - -def get_experts_stores_representations( - experts: list[BasicExpertInfo] | None, -) -> list[str] | None: - if not experts: - return None - - reps = [basic_expert_info_representation(owner) for owner in experts] - return [owner for owner in reps if owner is not None] - - -T = TypeVar("T") -U = TypeVar("U") - - -def process_in_batches( - objects: list[T], process_function: Callable[[T], U], batch_size: int -) -> Iterator[list[U]]: - for i in range(0, len(objects), batch_size): - yield [process_function(obj) for obj in objects[i : i + batch_size]] - - -def get_metadata_keys_to_ignore() -> list[str]: - return [IGNORE_FOR_QA] diff --git a/backend/danswer/connectors/cross_connector_utils/rate_limit_wrapper.py b/backend/danswer/connectors/cross_connector_utils/rate_limit_wrapper.py deleted file mode 100644 index e3eeaaf617d..00000000000 --- a/backend/danswer/connectors/cross_connector_utils/rate_limit_wrapper.py +++ /dev/null @@ -1,130 +0,0 @@ -import time -from collections.abc import Callable -from functools import wraps -from typing import Any -from typing import cast -from typing import TypeVar - -import requests - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -F = TypeVar("F", bound=Callable[..., Any]) - - -class RateLimitTriedTooManyTimesError(Exception): - pass - - -class _RateLimitDecorator: - """Builds a generic wrapper/decorator for calls to external APIs that - prevents making more than `max_calls` requests per `period` - - Implementation inspired by the `ratelimit` library: - https://github.com/tomasbasham/ratelimit. - - NOTE: is not thread safe. - """ - - def __init__( - self, - max_calls: int, - period: float, # in seconds - sleep_time: float = 2, # in seconds - sleep_backoff: float = 2, # applies exponential backoff - max_num_sleep: int = 0, - ): - self.max_calls = max_calls - self.period = period - self.sleep_time = sleep_time - self.sleep_backoff = sleep_backoff - self.max_num_sleep = max_num_sleep - - self.call_history: list[float] = [] - self.curr_calls = 0 - - def __call__(self, func: F) -> F: - @wraps(func) - def wrapped_func(*args: list, **kwargs: dict[str, Any]) -> Any: - # cleanup calls which are no longer relevant - self._cleanup() - - # check if we've exceeded the rate limit - sleep_cnt = 0 - while len(self.call_history) == self.max_calls: - sleep_time = self.sleep_time * (self.sleep_backoff**sleep_cnt) - logger.notice( - f"Rate limit exceeded for function {func.__name__}. " - f"Waiting {sleep_time} seconds before retrying." - ) - time.sleep(sleep_time) - sleep_cnt += 1 - if self.max_num_sleep != 0 and sleep_cnt >= self.max_num_sleep: - raise RateLimitTriedTooManyTimesError( - f"Exceeded '{self.max_num_sleep}' retries for function '{func.__name__}'" - ) - - self._cleanup() - - # add the current call to the call history - self.call_history.append(time.monotonic()) - return func(*args, **kwargs) - - return cast(F, wrapped_func) - - def _cleanup(self) -> None: - curr_time = time.monotonic() - time_to_expire_before = curr_time - self.period - self.call_history = [ - call_time - for call_time in self.call_history - if call_time > time_to_expire_before - ] - - -rate_limit_builder = _RateLimitDecorator - - -"""If you want to allow the external service to tell you when you've hit the rate limit, -use the following instead""" - -R = TypeVar("R", bound=Callable[..., requests.Response]) - - -def wrap_request_to_handle_ratelimiting( - request_fn: R, default_wait_time_sec: int = 30, max_waits: int = 30 -) -> R: - def wrapped_request(*args: list, **kwargs: dict[str, Any]) -> requests.Response: - for _ in range(max_waits): - response = request_fn(*args, **kwargs) - if response.status_code == 429: - try: - wait_time = int( - response.headers.get("Retry-After", default_wait_time_sec) - ) - except ValueError: - wait_time = default_wait_time_sec - - time.sleep(wait_time) - continue - - return response - - raise RateLimitTriedTooManyTimesError(f"Exceeded '{max_waits}' retries") - - return cast(R, wrapped_request) - - -_rate_limited_get = wrap_request_to_handle_ratelimiting(requests.get) -_rate_limited_post = wrap_request_to_handle_ratelimiting(requests.post) - - -class _RateLimitedRequest: - get = _rate_limited_get - post = _rate_limited_post - - -rl_requests = _RateLimitedRequest diff --git a/backend/danswer/connectors/cross_connector_utils/retry_wrapper.py b/backend/danswer/connectors/cross_connector_utils/retry_wrapper.py deleted file mode 100644 index 7312d1349f7..00000000000 --- a/backend/danswer/connectors/cross_connector_utils/retry_wrapper.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import Callable -from logging import Logger -from typing import Any -from typing import cast -from typing import TypeVar - -from retry import retry - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -F = TypeVar("F", bound=Callable[..., Any]) - - -def retry_builder( - tries: int = 10, - delay: float = 0.1, - max_delay: float | None = None, - backoff: float = 2, - jitter: tuple[float, float] | float = 1, -) -> Callable[[F], F]: - """Builds a generic wrapper/decorator for calls to external APIs that - may fail due to rate limiting, flakes, or other reasons. Applies expontential - backoff with jitter to retry the call.""" - - @retry( - tries=tries, - delay=delay, - max_delay=max_delay, - backoff=backoff, - jitter=jitter, - logger=cast(Logger, logger), - ) - def retry_with_default(func: F) -> F: - def wrapped_func(*args: list, **kwargs: dict[str, Any]) -> Any: - return func(*args, **kwargs) - - return cast(F, wrapped_func) - - return retry_with_default diff --git a/backend/danswer/connectors/danswer_jira/__init__.py b/backend/danswer/connectors/danswer_jira/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/danswer_jira/connector.py b/backend/danswer/connectors/danswer_jira/connector.py deleted file mode 100644 index 9a8fbb31501..00000000000 --- a/backend/danswer/connectors/danswer_jira/connector.py +++ /dev/null @@ -1,303 +0,0 @@ -import os -from datetime import datetime -from datetime import timezone -from typing import Any -from urllib.parse import urlparse - -from jira import JIRA -from jira.resources import Issue - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.app_configs import JIRA_CONNECTOR_LABELS_TO_SKIP -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - - -logger = setup_logger() -PROJECT_URL_PAT = "projects" -JIRA_API_VERSION = os.environ.get("JIRA_API_VERSION") or "2" - - -def extract_jira_project(url: str) -> tuple[str, str]: - parsed_url = urlparse(url) - jira_base = parsed_url.scheme + "://" + parsed_url.netloc - - # Split the path by '/' and find the position of 'projects' to get the project name - split_path = parsed_url.path.split("/") - if PROJECT_URL_PAT in split_path: - project_pos = split_path.index(PROJECT_URL_PAT) - if len(split_path) > project_pos + 1: - jira_project = split_path[project_pos + 1] - else: - raise ValueError("No project name found in the URL") - else: - raise ValueError("'projects' not found in the URL") - - return jira_base, jira_project - - -def extract_text_from_content(content: dict) -> str: - texts = [] - if "content" in content: - for block in content["content"]: - if "content" in block: - for item in block["content"]: - if item["type"] == "text": - texts.append(item["text"]) - return " ".join(texts) - - -def best_effort_get_field_from_issue(jira_issue: Issue, field: str) -> Any: - if hasattr(jira_issue.fields, field): - return getattr(jira_issue.fields, field) - - try: - return jira_issue.raw["fields"][field] - except Exception: - return None - - -def _get_comment_strs( - jira: Issue, comment_email_blacklist: tuple[str, ...] = () -) -> list[str]: - comment_strs = [] - for comment in jira.fields.comment.comments: - try: - if hasattr(comment, "body"): - body_text = extract_text_from_content(comment.raw["body"]) - elif hasattr(comment, "raw"): - body = comment.raw.get("body", "No body content available") - body_text = ( - extract_text_from_content(body) if isinstance(body, dict) else body - ) - else: - body_text = "No body attribute found" - - if ( - hasattr(comment, "author") - and comment.author.emailAddress in comment_email_blacklist - ): - continue # Skip adding comment if author's email is in blacklist - - comment_strs.append(body_text) - except Exception as e: - logger.error(f"Failed to process comment due to an error: {e}") - continue - - return comment_strs - - -def fetch_jira_issues_batch( - jql: str, - start_index: int, - jira_client: JIRA, - batch_size: int = INDEX_BATCH_SIZE, - comment_email_blacklist: tuple[str, ...] = (), - labels_to_skip: set[str] | None = None, -) -> tuple[list[Document], int]: - doc_batch = [] - - batch = jira_client.search_issues( - jql, - startAt=start_index, - maxResults=batch_size, - ) - - for jira in batch: - if type(jira) != Issue: - logger.warning(f"Found Jira object not of type Issue {jira}") - continue - - if labels_to_skip and any( - label in jira.fields.labels for label in labels_to_skip - ): - logger.info( - f"Skipping {jira.key} because it has a label to skip. Found " - f"labels: {jira.fields.labels}. Labels to skip: {labels_to_skip}." - ) - continue - - comments = _get_comment_strs(jira, comment_email_blacklist) - semantic_rep = ( - f"{jira.fields.description}\n" - if jira.fields.description - else "" + "\n".join([f"Comment: {comment}" for comment in comments]) - ) - - page_url = f"{jira_client.client_info()}/browse/{jira.key}" - - people = set() - try: - people.add( - BasicExpertInfo( - display_name=jira.fields.creator.displayName, - email=jira.fields.creator.emailAddress, - ) - ) - except Exception: - # Author should exist but if not, doesn't matter - pass - - try: - people.add( - BasicExpertInfo( - display_name=jira.fields.assignee.displayName, # type: ignore - email=jira.fields.assignee.emailAddress, # type: ignore - ) - ) - except Exception: - # Author should exist but if not, doesn't matter - pass - - metadata_dict = {} - priority = best_effort_get_field_from_issue(jira, "priority") - if priority: - metadata_dict["priority"] = priority.name - status = best_effort_get_field_from_issue(jira, "status") - if status: - metadata_dict["status"] = status.name - resolution = best_effort_get_field_from_issue(jira, "resolution") - if resolution: - metadata_dict["resolution"] = resolution.name - labels = best_effort_get_field_from_issue(jira, "labels") - if labels: - metadata_dict["label"] = labels - - doc_batch.append( - Document( - id=page_url, - sections=[Section(link=page_url, text=semantic_rep)], - source=DocumentSource.JIRA, - semantic_identifier=jira.fields.summary, - doc_updated_at=time_str_to_utc(jira.fields.updated), - primary_owners=list(people) or None, - # TODO add secondary_owners (commenters) if needed - metadata=metadata_dict, - ) - ) - return doc_batch, len(batch) - - -class JiraConnector(LoadConnector, PollConnector): - def __init__( - self, - jira_project_url: str, - comment_email_blacklist: list[str] | None = None, - batch_size: int = INDEX_BATCH_SIZE, - # if a ticket has one of the labels specified in this list, we will just - # skip it. This is generally used to avoid indexing extra sensitive - # tickets. - labels_to_skip: list[str] = JIRA_CONNECTOR_LABELS_TO_SKIP, - ) -> None: - self.batch_size = batch_size - self.jira_base, self.jira_project = extract_jira_project(jira_project_url) - self.jira_client: JIRA | None = None - self._comment_email_blacklist = comment_email_blacklist or [] - - self.labels_to_skip = set(labels_to_skip) - - @property - def comment_email_blacklist(self) -> tuple: - return tuple(email.strip() for email in self._comment_email_blacklist) - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - api_token = credentials["jira_api_token"] - # if user provide an email we assume it's cloud - if "jira_user_email" in credentials: - email = credentials["jira_user_email"] - self.jira_client = JIRA( - basic_auth=(email, api_token), - server=self.jira_base, - options={"rest_api_version": JIRA_API_VERSION}, - ) - else: - self.jira_client = JIRA( - token_auth=api_token, - server=self.jira_base, - options={"rest_api_version": JIRA_API_VERSION}, - ) - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - if self.jira_client is None: - raise ConnectorMissingCredentialError("Jira") - - start_ind = 0 - while True: - doc_batch, fetched_batch_size = fetch_jira_issues_batch( - jql=f"project = {self.jira_project}", - start_index=start_ind, - jira_client=self.jira_client, - batch_size=self.batch_size, - comment_email_blacklist=self.comment_email_blacklist, - labels_to_skip=self.labels_to_skip, - ) - - if doc_batch: - yield doc_batch - - start_ind += fetched_batch_size - if fetched_batch_size < self.batch_size: - break - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.jira_client is None: - raise ConnectorMissingCredentialError("Jira") - - start_date_str = datetime.fromtimestamp(start, tz=timezone.utc).strftime( - "%Y-%m-%d %H:%M" - ) - end_date_str = datetime.fromtimestamp(end, tz=timezone.utc).strftime( - "%Y-%m-%d %H:%M" - ) - - jql = ( - f"project = {self.jira_project} AND " - f"updated >= '{start_date_str}' AND " - f"updated <= '{end_date_str}'" - ) - - start_ind = 0 - while True: - doc_batch, fetched_batch_size = fetch_jira_issues_batch( - jql=jql, - start_index=start_ind, - jira_client=self.jira_client, - batch_size=self.batch_size, - comment_email_blacklist=self.comment_email_blacklist, - labels_to_skip=self.labels_to_skip, - ) - - if doc_batch: - yield doc_batch - - start_ind += fetched_batch_size - if fetched_batch_size < self.batch_size: - break - - -if __name__ == "__main__": - import os - - connector = JiraConnector( - os.environ["JIRA_PROJECT_URL"], comment_email_blacklist=[] - ) - connector.load_credentials( - { - "jira_user_email": os.environ["JIRA_USER_EMAIL"], - "jira_api_token": os.environ["JIRA_API_TOKEN"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/danswer_jira/utils.py b/backend/danswer/connectors/danswer_jira/utils.py deleted file mode 100644 index 506f5eff75e..00000000000 --- a/backend/danswer/connectors/danswer_jira/utils.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Module with custom fields processing functions""" -from typing import Any -from typing import List - -from jira import JIRA -from jira.resources import CustomFieldOption -from jira.resources import Issue -from jira.resources import User - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class CustomFieldExtractor: - @staticmethod - def _process_custom_field_value(value: Any) -> str: - """ - Process a custom field value to a string - """ - try: - if isinstance(value, str): - return value - elif isinstance(value, CustomFieldOption): - return value.value - elif isinstance(value, User): - return value.displayName - elif isinstance(value, List): - return " ".join( - [CustomFieldExtractor._process_custom_field_value(v) for v in value] - ) - else: - return str(value) - except Exception as e: - logger.error(f"Error processing custom field value {value}: {e}") - return "" - - @staticmethod - def get_issue_custom_fields( - jira: Issue, custom_fields: dict, max_value_length: int = 250 - ) -> dict: - """ - Process all custom fields of an issue to a dictionary of strings - :param jira: jira_issue, bug or similar - :param custom_fields: custom fields dictionary - :param max_value_length: maximum length of the value to be processed, if exceeded, it will be truncated - """ - - issue_custom_fields = { - custom_fields[key]: value - for key, value in jira.fields.__dict__.items() - if value and key in custom_fields.keys() - } - - processed_fields = {} - - if issue_custom_fields: - for key, value in issue_custom_fields.items(): - processed = CustomFieldExtractor._process_custom_field_value(value) - # We need max length parameter, because there are some plugins that often has very long description - # and there is just a technical information so we just avoid long values - if len(processed) < max_value_length: - processed_fields[key] = processed - - return processed_fields - - @staticmethod - def get_all_custom_fields(jira_client: JIRA) -> dict: - """Get all custom fields from Jira""" - fields = jira_client.fields() - fields_dct = { - field["id"]: field["name"] for field in fields if field["custom"] is True - } - return fields_dct - - -class CommonFieldExtractor: - @staticmethod - def get_issue_common_fields(jira: Issue) -> dict: - return { - "Priority": jira.fields.priority.name if jira.fields.priority else None, - "Reporter": jira.fields.reporter.displayName - if jira.fields.reporter - else None, - "Assignee": jira.fields.assignee.displayName - if jira.fields.assignee - else None, - "Status": jira.fields.status.name if jira.fields.status else None, - "Resolution": jira.fields.resolution.name - if jira.fields.resolution - else None, - } diff --git a/backend/danswer/connectors/discourse/__init__.py b/backend/danswer/connectors/discourse/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/discourse/connector.py b/backend/danswer/connectors/discourse/connector.py deleted file mode 100644 index d74aad0f276..00000000000 --- a/backend/danswer/connectors/discourse/connector.py +++ /dev/null @@ -1,244 +0,0 @@ -import time -import urllib.parse -from datetime import datetime -from datetime import timezone -from typing import Any - -import requests -from pydantic import BaseModel -from requests import Response - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class DiscoursePerms(BaseModel): - api_key: str - api_username: str - - -@retry_builder() -def discourse_request( - endpoint: str, perms: DiscoursePerms, params: dict | None = None -) -> Response: - headers = {"Api-Key": perms.api_key, "Api-Username": perms.api_username} - - response = requests.get(endpoint, headers=headers, params=params) - response.raise_for_status() - - return response - - -class DiscourseConnector(PollConnector): - def __init__( - self, - base_url: str, - categories: list[str] | None = None, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - parsed_url = urllib.parse.urlparse(base_url) - if not parsed_url.scheme: - base_url = "https://" + base_url - self.base_url = base_url - - self.categories = [c.lower() for c in categories] if categories else [] - self.category_id_map: dict[int, str] = {} - - self.batch_size = batch_size - self.permissions: DiscoursePerms | None = None - self.active_categories: set | None = None - - @rate_limit_builder(max_calls=50, period=60) - def _make_request(self, endpoint: str, params: dict | None = None) -> Response: - if not self.permissions: - raise ConnectorMissingCredentialError("Discourse") - return discourse_request(endpoint, self.permissions, params) - - def _get_categories_map( - self, - ) -> None: - assert self.permissions is not None - categories_endpoint = urllib.parse.urljoin(self.base_url, "categories.json") - response = self._make_request( - endpoint=categories_endpoint, - params={"include_subcategories": True}, - ) - categories = response.json()["category_list"]["categories"] - self.category_id_map = { - cat["id"]: cat["name"] - for cat in categories - if not self.categories or cat["name"].lower() in self.categories - } - self.active_categories = set(self.category_id_map) - - def _get_doc_from_topic(self, topic_id: int) -> Document: - assert self.permissions is not None - topic_endpoint = urllib.parse.urljoin(self.base_url, f"t/{topic_id}.json") - response = self._make_request(endpoint=topic_endpoint) - topic = response.json() - - topic_url = urllib.parse.urljoin(self.base_url, f"t/{topic['slug']}") - - sections = [] - poster = None - responders = [] - seen_names = set() - for ind, post in enumerate(topic["post_stream"]["posts"]): - if ind == 0: - poster_name = post.get("name") - if poster_name: - seen_names.add(poster_name) - poster = BasicExpertInfo(display_name=poster_name) - else: - responder_name = post.get("name") - if responder_name and responder_name not in seen_names: - seen_names.add(responder_name) - responders.append(BasicExpertInfo(display_name=responder_name)) - - sections.append( - Section(link=topic_url, text=parse_html_page_basic(post["cooked"])) - ) - category_name = self.category_id_map.get(topic["category_id"]) - - metadata: dict[str, str | list[str]] = ( - { - "category": category_name, - } - if category_name - else {} - ) - - if topic.get("tags"): - metadata["tags"] = topic["tags"] - - doc = Document( - id="_".join([DocumentSource.DISCOURSE.value, str(topic["id"])]), - sections=sections, - source=DocumentSource.DISCOURSE, - semantic_identifier=topic["title"], - doc_updated_at=time_str_to_utc(topic["last_posted_at"]), - primary_owners=[poster] if poster else None, - secondary_owners=responders or None, - metadata=metadata, - ) - return doc - - def _get_latest_topics( - self, start: datetime | None, end: datetime | None, page: int - ) -> list[int]: - assert self.permissions is not None - topic_ids = [] - - if not self.categories: - latest_endpoint = urllib.parse.urljoin( - self.base_url, f"latest.json?page={page}" - ) - response = self._make_request(endpoint=latest_endpoint) - topics = response.json()["topic_list"]["topics"] - - else: - topics = [] - empty_categories = [] - - for category_id in self.category_id_map.keys(): - category_endpoint = urllib.parse.urljoin( - self.base_url, f"c/{category_id}.json?page={page}&sys=latest" - ) - response = self._make_request(endpoint=category_endpoint) - new_topics = response.json()["topic_list"]["topics"] - - if len(new_topics) == 0: - empty_categories.append(category_id) - topics.extend(new_topics) - - for empty_category in empty_categories: - self.category_id_map.pop(empty_category) - - for topic in topics: - last_time = topic.get("last_posted_at") - if not last_time: - continue - - last_time_dt = time_str_to_utc(last_time) - if (start and start > last_time_dt) or (end and end < last_time_dt): - continue - - topic_ids.append(topic["id"]) - if len(topic_ids) >= self.batch_size: - break - - return topic_ids - - def _yield_discourse_documents( - self, - start: datetime, - end: datetime, - ) -> GenerateDocumentsOutput: - page = 1 - while topic_ids := self._get_latest_topics(start, end, page): - doc_batch: list[Document] = [] - for topic_id in topic_ids: - doc_batch.append(self._get_doc_from_topic(topic_id)) - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if doc_batch: - yield doc_batch - page += 1 - - def load_credentials( - self, - credentials: dict[str, Any], - ) -> dict[str, Any] | None: - self.permissions = DiscoursePerms( - api_key=credentials["discourse_api_key"], - api_username=credentials["discourse_api_username"], - ) - return None - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.permissions is None: - raise ConnectorMissingCredentialError("Discourse") - - start_datetime = datetime.utcfromtimestamp(start).replace(tzinfo=timezone.utc) - end_datetime = datetime.utcfromtimestamp(end).replace(tzinfo=timezone.utc) - - self._get_categories_map() - - yield from self._yield_discourse_documents(start_datetime, end_datetime) - - -if __name__ == "__main__": - import os - - connector = DiscourseConnector(base_url=os.environ["DISCOURSE_BASE_URL"]) - connector.load_credentials( - { - "discourse_api_key": os.environ["DISCOURSE_API_KEY"], - "discourse_api_username": os.environ["DISCOURSE_API_USERNAME"], - } - ) - - current = time.time() - one_year_ago = current - 24 * 60 * 60 * 360 - latest_docs = connector.poll_source(one_year_ago, current) - print(next(latest_docs)) diff --git a/backend/danswer/connectors/document360/__init__.py b/backend/danswer/connectors/document360/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/document360/connector.py b/backend/danswer/connectors/document360/connector.py deleted file mode 100644 index 6a9f4ba6a56..00000000000 --- a/backend/danswer/connectors/document360/connector.py +++ /dev/null @@ -1,209 +0,0 @@ -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import List -from typing import Optional - -import requests - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.document360.utils import flatten_child_categories -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic - -# Limitations and Potential Improvements -# 1. The "Categories themselves contain potentially relevant information" but they're not pulled in -# 2. Only the HTML Articles are supported, Document360 also has a Markdown and "Block" format -# 3. The contents are not as cleaned up as other HTML connectors - -DOCUMENT360_BASE_URL = "https://portal.document360.io" -DOCUMENT360_API_BASE_URL = "https://apihub.document360.io/v2" - - -class Document360Connector(LoadConnector, PollConnector): - def __init__( - self, - workspace: str, - categories: List[str] | None = None, - batch_size: int = INDEX_BATCH_SIZE, - portal_id: Optional[str] = None, - api_token: Optional[str] = None, - ) -> None: - self.portal_id = portal_id - self.workspace = workspace - self.categories = categories - self.batch_size = batch_size - self.api_token = api_token - - def load_credentials(self, credentials: dict[str, Any]) -> Optional[dict[str, Any]]: - self.api_token = credentials.get("document360_api_token") - self.portal_id = credentials.get("portal_id") - return None - - # rate limiting set based on the enterprise plan: https://apidocs.document360.com/apidocs/rate-limiting - # NOTE: retry will handle cases where user is not on enterprise plan - we will just hit the rate limit - # and then retry after a period - @retry_builder() - @rate_limit_builder(max_calls=100, period=60) - def _make_request(self, endpoint: str, params: Optional[dict] = None) -> Any: - if not self.api_token: - raise ConnectorMissingCredentialError("Document360") - - headers = {"accept": "application/json", "api_token": self.api_token} - - response = requests.get( - f"{DOCUMENT360_API_BASE_URL}/{endpoint}", headers=headers, params=params - ) - response.raise_for_status() - - return response.json()["data"] - - def _get_workspace_id_by_name(self) -> str: - projects = self._make_request("ProjectVersions") - workspace_id = next( - ( - project["id"] - for project in projects - if project["version_code_name"] == self.workspace - ), - None, - ) - if workspace_id is None: - raise ValueError("Not able to find Workspace ID by the user provided name") - - return workspace_id - - def _get_articles_with_category(self, workspace_id: str) -> Any: - all_categories = self._make_request( - f"ProjectVersions/{workspace_id}/categories" - ) - articles_with_category = [] - - for category in all_categories: - if not self.categories or category["name"] in self.categories: - for article in category["articles"]: - articles_with_category.append( - {"id": article["id"], "category_name": category["name"]} - ) - for child_category in category["child_categories"]: - all_nested_categories = flatten_child_categories(child_category) - for nested_category in all_nested_categories: - for article in nested_category["articles"]: - articles_with_category.append( - { - "id": article["id"], - "category_name": nested_category["name"], - } - ) - - return articles_with_category - - def _process_articles( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.api_token is None: - raise ConnectorMissingCredentialError("Document360") - - workspace_id = self._get_workspace_id_by_name() - articles = self._get_articles_with_category(workspace_id) - - doc_batch: List[Document] = [] - - for article in articles: - article_details = self._make_request( - f"Articles/{article['id']}", {"langCode": "en"} - ) - - updated_at = datetime.strptime( - article_details["modified_at"], "%Y-%m-%dT%H:%M:%S.%fZ" - ).replace(tzinfo=timezone.utc) - if start is not None and updated_at < start: - continue - if end is not None and updated_at > end: - continue - - authors = [ - BasicExpertInfo( - display_name=author.get("name"), email=author["email_id"] - ) - for author in article_details.get("authors", []) - if author["email_id"] - ] - - doc_link = ( - article_details["url"] - if article_details.get("url") - else f"{DOCUMENT360_BASE_URL}/{self.portal_id}/document/v1/view/{article['id']}" - ) - - html_content = article_details["html_content"] - article_content = ( - parse_html_page_basic(html_content) if html_content is not None else "" - ) - doc_text = ( - f"{article_details.get('description', '')}\n{article_content}".strip() - ) - - document = Document( - id=article_details["id"], - sections=[Section(link=doc_link, text=doc_text)], - source=DocumentSource.DOCUMENT360, - semantic_identifier=article_details["title"], - doc_updated_at=updated_at, - primary_owners=authors, - metadata={ - "workspace": self.workspace, - "category": article["category_name"], - }, - ) - - doc_batch.append(document) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._process_articles() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.fromtimestamp(start, tz=timezone.utc) - end_datetime = datetime.fromtimestamp(end, tz=timezone.utc) - return self._process_articles(start_datetime, end_datetime) - - -if __name__ == "__main__": - import time - import os - - document360_connector = Document360Connector(os.environ["DOCUMENT360_WORKSPACE"]) - document360_connector.load_credentials( - { - "portal_id": os.environ["DOCUMENT360_PORTAL_ID"], - "document360_api_token": os.environ["DOCUMENT360_API_TOKEN"], - } - ) - - current = time.time() - one_year_ago = current - 24 * 60 * 60 * 360 - latest_docs = document360_connector.poll_source(one_year_ago, current) - - for doc in latest_docs: - print(doc) diff --git a/backend/danswer/connectors/document360/utils.py b/backend/danswer/connectors/document360/utils.py deleted file mode 100644 index 87ef880da62..00000000000 --- a/backend/danswer/connectors/document360/utils.py +++ /dev/null @@ -1,8 +0,0 @@ -def flatten_child_categories(category: dict) -> list[dict]: - if not category["child_categories"]: - return [category] - else: - flattened_categories = [category] - for child_category in category["child_categories"]: - flattened_categories.extend(flatten_child_categories(child_category)) - return flattened_categories diff --git a/backend/danswer/connectors/dropbox/__init__.py b/backend/danswer/connectors/dropbox/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/dropbox/connector.py b/backend/danswer/connectors/dropbox/connector.py deleted file mode 100644 index b36f0fbd122..00000000000 --- a/backend/danswer/connectors/dropbox/connector.py +++ /dev/null @@ -1,155 +0,0 @@ -from datetime import timezone -from io import BytesIO -from typing import Any - -from dropbox import Dropbox # type: ignore -from dropbox.exceptions import ApiError # type:ignore -from dropbox.files import FileMetadata # type:ignore -from dropbox.files import FolderMetadata # type:ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -class DropboxConnector(LoadConnector, PollConnector): - def __init__(self, batch_size: int = INDEX_BATCH_SIZE) -> None: - self.batch_size = batch_size - self.dropbox_client: Dropbox | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.dropbox_client = Dropbox(credentials["dropbox_access_token"]) - return None - - def _download_file(self, path: str) -> bytes: - """Download a single file from Dropbox.""" - if self.dropbox_client is None: - raise ConnectorMissingCredentialError("Dropbox") - _, resp = self.dropbox_client.files_download(path) - return resp.content - - def _get_shared_link(self, path: str) -> str: - """Create a shared link for a file in Dropbox.""" - if self.dropbox_client is None: - raise ConnectorMissingCredentialError("Dropbox") - - try: - # Check if a shared link already exists - shared_links = self.dropbox_client.sharing_list_shared_links(path=path) - if shared_links.links: - return shared_links.links[0].url - - link_metadata = ( - self.dropbox_client.sharing_create_shared_link_with_settings(path) - ) - return link_metadata.url - except ApiError as err: - logger.exception(f"Failed to create a shared link for {path}: {err}") - return "" - - def _yield_files_recursive( - self, - path: str, - start: SecondsSinceUnixEpoch | None, - end: SecondsSinceUnixEpoch | None, - ) -> GenerateDocumentsOutput: - """Yield files in batches from a specified Dropbox folder, including subfolders.""" - if self.dropbox_client is None: - raise ConnectorMissingCredentialError("Dropbox") - - result = self.dropbox_client.files_list_folder( - path, - limit=self.batch_size, - recursive=False, - include_non_downloadable_files=False, - ) - - while True: - batch: list[Document] = [] - for entry in result.entries: - if isinstance(entry, FileMetadata): - modified_time = entry.client_modified - if modified_time.tzinfo is None: - # If no timezone info, assume it is UTC - modified_time = modified_time.replace(tzinfo=timezone.utc) - else: - # If not in UTC, translate it - modified_time = modified_time.astimezone(timezone.utc) - - time_as_seconds = int(modified_time.timestamp()) - if start and time_as_seconds < start: - continue - if end and time_as_seconds > end: - continue - - downloaded_file = self._download_file(entry.path_display) - link = self._get_shared_link(entry.path_display) - try: - text = extract_file_text( - entry.name, - BytesIO(downloaded_file), - break_on_unprocessable=False, - ) - batch.append( - Document( - id=f"doc:{entry.id}", - sections=[Section(link=link, text=text)], - source=DocumentSource.DROPBOX, - semantic_identifier=entry.name, - doc_updated_at=modified_time, - metadata={"type": "article"}, - ) - ) - except Exception as e: - logger.exception( - f"Error decoding file {entry.path_display} as utf-8 error occurred: {e}" - ) - - elif isinstance(entry, FolderMetadata): - yield from self._yield_files_recursive(entry.path_lower, start, end) - - if batch: - yield batch - - if not result.has_more: - break - - result = self.dropbox_client.files_list_folder_continue(result.cursor) - - def load_from_state(self) -> GenerateDocumentsOutput: - return self.poll_source(None, None) - - def poll_source( - self, start: SecondsSinceUnixEpoch | None, end: SecondsSinceUnixEpoch | None - ) -> GenerateDocumentsOutput: - if self.dropbox_client is None: - raise ConnectorMissingCredentialError("Dropbox") - - for batch in self._yield_files_recursive("", start, end): - yield batch - - return None - - -if __name__ == "__main__": - import os - - connector = DropboxConnector() - connector.load_credentials( - { - "dropbox_access_token": os.environ["DROPBOX_ACCESS_TOKEN"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/factory.py b/backend/danswer/connectors/factory.py deleted file mode 100644 index 1a3d605d3a5..00000000000 --- a/backend/danswer/connectors/factory.py +++ /dev/null @@ -1,140 +0,0 @@ -from typing import Any -from typing import Type - -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.connectors.axero.connector import AxeroConnector -from danswer.connectors.blob.connector import BlobStorageConnector -from danswer.connectors.bookstack.connector import BookstackConnector -from danswer.connectors.clickup.connector import ClickupConnector -from danswer.connectors.confluence.connector import ConfluenceConnector -from danswer.connectors.danswer_jira.connector import JiraConnector -from danswer.connectors.discourse.connector import DiscourseConnector -from danswer.connectors.document360.connector import Document360Connector -from danswer.connectors.dropbox.connector import DropboxConnector -from danswer.connectors.file.connector import LocalFileConnector -from danswer.connectors.github.connector import GithubConnector -from danswer.connectors.gitlab.connector import GitlabConnector -from danswer.connectors.gmail.connector import GmailConnector -from danswer.connectors.gong.connector import GongConnector -from danswer.connectors.google_drive.connector import GoogleDriveConnector -from danswer.connectors.google_site.connector import GoogleSitesConnector -from danswer.connectors.guru.connector import GuruConnector -from danswer.connectors.hubspot.connector import HubSpotConnector -from danswer.connectors.interfaces import BaseConnector -from danswer.connectors.interfaces import EventConnector -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.linear.connector import LinearConnector -from danswer.connectors.loopio.connector import LoopioConnector -from danswer.connectors.mediawiki.wiki import MediaWikiConnector -from danswer.connectors.models import InputType -from danswer.connectors.notion.connector import NotionConnector -from danswer.connectors.productboard.connector import ProductboardConnector -from danswer.connectors.requesttracker.connector import RequestTrackerConnector -from danswer.connectors.salesforce.connector import SalesforceConnector -from danswer.connectors.sharepoint.connector import SharepointConnector -from danswer.connectors.slab.connector import SlabConnector -from danswer.connectors.slack.connector import SlackPollConnector -from danswer.connectors.slack.load_connector import SlackLoadConnector -from danswer.connectors.teams.connector import TeamsConnector -from danswer.connectors.web.connector import WebConnector -from danswer.connectors.wikipedia.connector import WikipediaConnector -from danswer.connectors.zendesk.connector import ZendeskConnector -from danswer.connectors.zulip.connector import ZulipConnector -from danswer.db.credentials import backend_update_credential_json -from danswer.db.models import Credential - - -class ConnectorMissingException(Exception): - pass - - -def identify_connector_class( - source: DocumentSource, - input_type: InputType | None = None, -) -> Type[BaseConnector]: - connector_map = { - DocumentSource.WEB: WebConnector, - DocumentSource.FILE: LocalFileConnector, - DocumentSource.SLACK: { - InputType.LOAD_STATE: SlackLoadConnector, - InputType.POLL: SlackPollConnector, - }, - DocumentSource.GITHUB: GithubConnector, - DocumentSource.GMAIL: GmailConnector, - DocumentSource.GITLAB: GitlabConnector, - DocumentSource.GOOGLE_DRIVE: GoogleDriveConnector, - DocumentSource.BOOKSTACK: BookstackConnector, - DocumentSource.CONFLUENCE: ConfluenceConnector, - DocumentSource.JIRA: JiraConnector, - DocumentSource.PRODUCTBOARD: ProductboardConnector, - DocumentSource.SLAB: SlabConnector, - DocumentSource.NOTION: NotionConnector, - DocumentSource.ZULIP: ZulipConnector, - DocumentSource.REQUESTTRACKER: RequestTrackerConnector, - DocumentSource.GURU: GuruConnector, - DocumentSource.LINEAR: LinearConnector, - DocumentSource.HUBSPOT: HubSpotConnector, - DocumentSource.DOCUMENT360: Document360Connector, - DocumentSource.GONG: GongConnector, - DocumentSource.GOOGLE_SITES: GoogleSitesConnector, - DocumentSource.ZENDESK: ZendeskConnector, - DocumentSource.LOOPIO: LoopioConnector, - DocumentSource.DROPBOX: DropboxConnector, - DocumentSource.SHAREPOINT: SharepointConnector, - DocumentSource.TEAMS: TeamsConnector, - DocumentSource.SALESFORCE: SalesforceConnector, - DocumentSource.DISCOURSE: DiscourseConnector, - DocumentSource.AXERO: AxeroConnector, - DocumentSource.CLICKUP: ClickupConnector, - DocumentSource.MEDIAWIKI: MediaWikiConnector, - DocumentSource.WIKIPEDIA: WikipediaConnector, - DocumentSource.S3: BlobStorageConnector, - DocumentSource.R2: BlobStorageConnector, - DocumentSource.GOOGLE_CLOUD_STORAGE: BlobStorageConnector, - DocumentSource.OCI_STORAGE: BlobStorageConnector, - } - connector_by_source = connector_map.get(source, {}) - - if isinstance(connector_by_source, dict): - if input_type is None: - # If not specified, default to most exhaustive update - connector = connector_by_source.get(InputType.LOAD_STATE) - else: - connector = connector_by_source.get(input_type) - else: - connector = connector_by_source - if connector is None: - raise ConnectorMissingException(f"Connector not found for source={source}") - - if any( - [ - input_type == InputType.LOAD_STATE - and not issubclass(connector, LoadConnector), - input_type == InputType.POLL and not issubclass(connector, PollConnector), - input_type == InputType.EVENT and not issubclass(connector, EventConnector), - ] - ): - raise ConnectorMissingException( - f"Connector for source={source} does not accept input_type={input_type}" - ) - return connector - - -def instantiate_connector( - source: DocumentSource, - input_type: InputType, - connector_specific_config: dict[str, Any], - credential: Credential, - db_session: Session, -) -> BaseConnector: - connector_class = identify_connector_class(source, input_type) - connector = connector_class(**connector_specific_config) - new_credentials = connector.load_credentials(credential.credential_json) - - if new_credentials is not None: - backend_update_credential_json(credential, new_credentials, db_session) - - return connector diff --git a/backend/danswer/connectors/file/__init__.py b/backend/danswer/connectors/file/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/file/connector.py b/backend/danswer/connectors/file/connector.py deleted file mode 100644 index 6c5501734b0..00000000000 --- a/backend/danswer/connectors/file/connector.py +++ /dev/null @@ -1,201 +0,0 @@ -import os -from collections.abc import Iterator -from datetime import datetime -from datetime import timezone -from pathlib import Path -from typing import Any -from typing import IO - -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.db.engine import get_sqlalchemy_engine -from danswer.file_processing.extract_file_text import check_file_ext_is_valid -from danswer.file_processing.extract_file_text import detect_encoding -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.file_processing.extract_file_text import get_file_ext -from danswer.file_processing.extract_file_text import is_text_file_extension -from danswer.file_processing.extract_file_text import load_files_from_zip -from danswer.file_processing.extract_file_text import pdf_to_text -from danswer.file_processing.extract_file_text import read_text_file -from danswer.file_store.file_store import get_default_file_store -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _read_files_and_metadata( - file_name: str, - db_session: Session, -) -> Iterator[tuple[str, IO, dict[str, Any]]]: - """Reads the file into IO, in the case of a zip file, yields each individual - file contained within, also includes the metadata dict if packaged in the zip""" - extension = get_file_ext(file_name) - metadata: dict[str, Any] = {} - directory_path = os.path.dirname(file_name) - - file_content = get_default_file_store(db_session).read_file(file_name, mode="b") - - if extension == ".zip": - for file_info, file, metadata in load_files_from_zip( - file_content, ignore_dirs=True - ): - yield os.path.join(directory_path, file_info.filename), file, metadata - elif check_file_ext_is_valid(extension): - yield file_name, file_content, metadata - else: - logger.warning(f"Skipping file '{file_name}' with extension '{extension}'") - - -def _process_file( - file_name: str, - file: IO[Any], - metadata: dict[str, Any] | None = None, - pdf_pass: str | None = None, -) -> list[Document]: - extension = get_file_ext(file_name) - if not check_file_ext_is_valid(extension): - logger.warning(f"Skipping file '{file_name}' with extension '{extension}'") - return [] - - file_metadata: dict[str, Any] = {} - - if is_text_file_extension(file_name): - encoding = detect_encoding(file) - file_content_raw, file_metadata = read_text_file( - file, encoding=encoding, ignore_danswer_metadata=False - ) - - # Using the PDF reader function directly to pass in password cleanly - elif extension == ".pdf": - file_content_raw = pdf_to_text(file=file, pdf_pass=pdf_pass) - - else: - file_content_raw = extract_file_text( - file_name=file_name, - file=file, - ) - - all_metadata = {**metadata, **file_metadata} if metadata else file_metadata - - # add a prefix to avoid conflicts with other connectors - doc_id = f"FILE_CONNECTOR__{file_name}" - if metadata: - doc_id = metadata.get("document_id") or doc_id - - # If this is set, we will show this in the UI as the "name" of the file - file_display_name = all_metadata.get("file_display_name") or os.path.basename( - file_name - ) - title = ( - all_metadata["title"] or "" if "title" in all_metadata else file_display_name - ) - - time_updated = all_metadata.get("time_updated", datetime.now(timezone.utc)) - if isinstance(time_updated, str): - time_updated = time_str_to_utc(time_updated) - - dt_str = all_metadata.get("doc_updated_at") - final_time_updated = time_str_to_utc(dt_str) if dt_str else time_updated - - # Metadata tags separate from the Danswer specific fields - metadata_tags = { - k: v - for k, v in all_metadata.items() - if k - not in [ - "document_id", - "time_updated", - "doc_updated_at", - "link", - "primary_owners", - "secondary_owners", - "filename", - "file_display_name", - "title", - ] - } - - p_owner_names = all_metadata.get("primary_owners") - s_owner_names = all_metadata.get("secondary_owners") - p_owners = ( - [BasicExpertInfo(display_name=name) for name in p_owner_names] - if p_owner_names - else None - ) - s_owners = ( - [BasicExpertInfo(display_name=name) for name in s_owner_names] - if s_owner_names - else None - ) - - return [ - Document( - id=doc_id, - sections=[ - Section(link=all_metadata.get("link"), text=file_content_raw.strip()) - ], - source=DocumentSource.FILE, - semantic_identifier=file_display_name, - title=title, - doc_updated_at=final_time_updated, - primary_owners=p_owners, - secondary_owners=s_owners, - # currently metadata just houses tags, other stuff like owners / updated at have dedicated fields - metadata=metadata_tags, - ) - ] - - -class LocalFileConnector(LoadConnector): - def __init__( - self, - file_locations: list[Path | str], - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.file_locations = [Path(file_location) for file_location in file_locations] - self.batch_size = batch_size - self.pdf_pass: str | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.pdf_pass = credentials.get("pdf_password") - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - documents: list[Document] = [] - with Session(get_sqlalchemy_engine()) as db_session: - for file_path in self.file_locations: - current_datetime = datetime.now(timezone.utc) - files = _read_files_and_metadata( - file_name=str(file_path), db_session=db_session - ) - - for file_name, file, metadata in files: - metadata["time_updated"] = metadata.get( - "time_updated", current_datetime - ) - documents.extend( - _process_file(file_name, file, metadata, self.pdf_pass) - ) - - if len(documents) >= self.batch_size: - yield documents - documents = [] - - if documents: - yield documents - - -if __name__ == "__main__": - connector = LocalFileConnector(file_locations=[os.environ["TEST_FILE"]]) - connector.load_credentials({"pdf_password": os.environ["PDF_PASSWORD"]}) - - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/github/__init__.py b/backend/danswer/connectors/github/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/github/connector.py b/backend/danswer/connectors/github/connector.py deleted file mode 100644 index aa72a3bef6e..00000000000 --- a/backend/danswer/connectors/github/connector.py +++ /dev/null @@ -1,241 +0,0 @@ -import time -from collections.abc import Iterator -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Any -from typing import cast - -from github import Github -from github import RateLimitExceededException -from github import Repository -from github.Issue import Issue -from github.PaginatedList import PaginatedList -from github.PullRequest import PullRequest - -from danswer.configs.app_configs import GITHUB_CONNECTOR_BASE_URL -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.batching import batch_generator -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -_MAX_NUM_RATE_LIMIT_RETRIES = 5 - - -def _sleep_after_rate_limit_exception(github_client: Github) -> None: - sleep_time = github_client.get_rate_limit().core.reset.replace( - tzinfo=timezone.utc - ) - datetime.now(tz=timezone.utc) - sleep_time += timedelta(minutes=1) # add an extra minute just to be safe - logger.notice(f"Ran into Github rate-limit. Sleeping {sleep_time.seconds} seconds.") - time.sleep(sleep_time.seconds) - - -def _get_batch_rate_limited( - git_objs: PaginatedList, page_num: int, github_client: Github, attempt_num: int = 0 -) -> list[Any]: - if attempt_num > _MAX_NUM_RATE_LIMIT_RETRIES: - raise RuntimeError( - "Re-tried fetching batch too many times. Something is going wrong with fetching objects from Github" - ) - - try: - objs = list(git_objs.get_page(page_num)) - # fetch all data here to disable lazy loading later - # this is needed to capture the rate limit exception here (if one occurs) - for obj in objs: - if hasattr(obj, "raw_data"): - getattr(obj, "raw_data") - return objs - except RateLimitExceededException: - _sleep_after_rate_limit_exception(github_client) - return _get_batch_rate_limited( - git_objs, page_num, github_client, attempt_num + 1 - ) - - -def _batch_github_objects( - git_objs: PaginatedList, github_client: Github, batch_size: int -) -> Iterator[list[Any]]: - page_num = 0 - while True: - batch = _get_batch_rate_limited(git_objs, page_num, github_client) - page_num += 1 - - if not batch: - break - - for mini_batch in batch_generator(batch, batch_size=batch_size): - yield mini_batch - - -def _convert_pr_to_document(pull_request: PullRequest) -> Document: - return Document( - id=pull_request.html_url, - sections=[Section(link=pull_request.html_url, text=pull_request.body or "")], - source=DocumentSource.GITHUB, - semantic_identifier=pull_request.title, - # updated_at is UTC time but is timezone unaware, explicitly add UTC - # as there is logic in indexing to prevent wrong timestamped docs - # due to local time discrepancies with UTC - doc_updated_at=pull_request.updated_at.replace(tzinfo=timezone.utc), - metadata={ - "merged": str(pull_request.merged), - "state": pull_request.state, - }, - ) - - -def _fetch_issue_comments(issue: Issue) -> str: - comments = issue.get_comments() - return "\nComment: ".join(comment.body for comment in comments) - - -def _convert_issue_to_document(issue: Issue) -> Document: - return Document( - id=issue.html_url, - sections=[Section(link=issue.html_url, text=issue.body or "")], - source=DocumentSource.GITHUB, - semantic_identifier=issue.title, - # updated_at is UTC time but is timezone unaware - doc_updated_at=issue.updated_at.replace(tzinfo=timezone.utc), - metadata={ - "state": issue.state, - }, - ) - - -class GithubConnector(LoadConnector, PollConnector): - def __init__( - self, - repo_owner: str, - repo_name: str, - batch_size: int = INDEX_BATCH_SIZE, - state_filter: str = "all", - include_prs: bool = True, - include_issues: bool = False, - ) -> None: - self.repo_owner = repo_owner - self.repo_name = repo_name - self.batch_size = batch_size - self.state_filter = state_filter - self.include_prs = include_prs - self.include_issues = include_issues - self.github_client: Github | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.github_client = ( - Github( - credentials["github_access_token"], base_url=GITHUB_CONNECTOR_BASE_URL - ) - if GITHUB_CONNECTOR_BASE_URL - else Github(credentials["github_access_token"]) - ) - return None - - def _get_github_repo( - self, github_client: Github, attempt_num: int = 0 - ) -> Repository.Repository: - if attempt_num > _MAX_NUM_RATE_LIMIT_RETRIES: - raise RuntimeError( - "Re-tried fetching repo too many times. Something is going wrong with fetching objects from Github" - ) - - try: - return github_client.get_repo(f"{self.repo_owner}/{self.repo_name}") - except RateLimitExceededException: - _sleep_after_rate_limit_exception(github_client) - return self._get_github_repo(github_client, attempt_num + 1) - - def _fetch_from_github( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.github_client is None: - raise ConnectorMissingCredentialError("GitHub") - - repo = self._get_github_repo(self.github_client) - - if self.include_prs: - pull_requests = repo.get_pulls( - state=self.state_filter, sort="updated", direction="desc" - ) - - for pr_batch in _batch_github_objects( - pull_requests, self.github_client, self.batch_size - ): - doc_batch: list[Document] = [] - for pr in pr_batch: - if start is not None and pr.updated_at < start: - yield doc_batch - return - if end is not None and pr.updated_at > end: - continue - doc_batch.append(_convert_pr_to_document(cast(PullRequest, pr))) - yield doc_batch - - if self.include_issues: - issues = repo.get_issues( - state=self.state_filter, sort="updated", direction="desc" - ) - - for issue_batch in _batch_github_objects( - issues, self.github_client, self.batch_size - ): - doc_batch = [] - for issue in issue_batch: - issue = cast(Issue, issue) - if start is not None and issue.updated_at < start: - yield doc_batch - return - if end is not None and issue.updated_at > end: - continue - if issue.pull_request is not None: - # PRs are handled separately - continue - doc_batch.append(_convert_issue_to_document(issue)) - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_from_github() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - - # Move start time back by 3 hours, since some Issues/PRs are getting dropped - # Could be due to delayed processing on GitHub side - # The non-updated issues since last poll will be shortcut-ed and not embedded - adjusted_start_datetime = start_datetime - timedelta(hours=3) - - epoch = datetime.utcfromtimestamp(0) - if adjusted_start_datetime < epoch: - adjusted_start_datetime = epoch - - return self._fetch_from_github(adjusted_start_datetime, end_datetime) - - -if __name__ == "__main__": - import os - - connector = GithubConnector( - repo_owner=os.environ["REPO_OWNER"], - repo_name=os.environ["REPO_NAME"], - ) - connector.load_credentials( - {"github_access_token": os.environ["GITHUB_ACCESS_TOKEN"]} - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/gitlab/__init__.py b/backend/danswer/connectors/gitlab/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/gitlab/connector.py b/backend/danswer/connectors/gitlab/connector.py deleted file mode 100644 index f07baf3e141..00000000000 --- a/backend/danswer/connectors/gitlab/connector.py +++ /dev/null @@ -1,255 +0,0 @@ -import fnmatch -import itertools -from collections import deque -from collections.abc import Iterable -from collections.abc import Iterator -from datetime import datetime -from datetime import timezone -from typing import Any - -import gitlab -import pytz -from gitlab.v4.objects import Project - -from danswer.configs.app_configs import GITLAB_CONNECTOR_INCLUDE_CODE_FILES -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -# List of directories/Files to exclude -exclude_patterns = [ - "logs", - ".github/", - ".gitlab/", - ".pre-commit-config.yaml", -] -logger = setup_logger() - - -def _batch_gitlab_objects( - git_objs: Iterable[Any], batch_size: int -) -> Iterator[list[Any]]: - it = iter(git_objs) - while True: - batch = list(itertools.islice(it, batch_size)) - if not batch: - break - yield batch - - -def get_author(author: Any) -> BasicExpertInfo: - return BasicExpertInfo( - display_name=author.get("name"), - ) - - -def _convert_merge_request_to_document(mr: Any) -> Document: - doc = Document( - id=mr.web_url, - sections=[Section(link=mr.web_url, text=mr.description or "")], - source=DocumentSource.GITLAB, - semantic_identifier=mr.title, - # updated_at is UTC time but is timezone unaware, explicitly add UTC - # as there is logic in indexing to prevent wrong timestamped docs - # due to local time discrepancies with UTC - doc_updated_at=mr.updated_at.replace(tzinfo=timezone.utc), - primary_owners=[get_author(mr.author)], - metadata={"state": mr.state, "type": "MergeRequest"}, - ) - return doc - - -def _convert_issue_to_document(issue: Any) -> Document: - doc = Document( - id=issue.web_url, - sections=[Section(link=issue.web_url, text=issue.description or "")], - source=DocumentSource.GITLAB, - semantic_identifier=issue.title, - # updated_at is UTC time but is timezone unaware, explicitly add UTC - # as there is logic in indexing to prevent wrong timestamped docs - # due to local time discrepancies with UTC - doc_updated_at=issue.updated_at.replace(tzinfo=timezone.utc), - primary_owners=[get_author(issue.author)], - metadata={"state": issue.state, "type": issue.type if issue.type else "Issue"}, - ) - return doc - - -def _convert_code_to_document( - project: Project, file: Any, url: str, projectName: str, projectOwner: str -) -> Document: - file_content_obj = project.files.get( - file_path=file["path"], ref="master" - ) # Replace 'master' with your branch name if needed - try: - file_content = file_content_obj.decode().decode("utf-8") - except UnicodeDecodeError: - file_content = file_content_obj.decode().decode("latin-1") - - file_url = f"{url}/{projectOwner}/{projectName}/-/blob/master/{file['path']}" # Construct the file URL - doc = Document( - id=file["id"], - sections=[Section(link=file_url, text=file_content)], - source=DocumentSource.GITLAB, - semantic_identifier=file["name"], - doc_updated_at=datetime.now().replace( - tzinfo=timezone.utc - ), # Use current time as updated_at - primary_owners=[], # Fill this as needed - metadata={"type": "CodeFile"}, - ) - return doc - - -def _should_exclude(path: str) -> bool: - """Check if a path matches any of the exclude patterns.""" - return any(fnmatch.fnmatch(path, pattern) for pattern in exclude_patterns) - - -class GitlabConnector(LoadConnector, PollConnector): - def __init__( - self, - project_owner: str, - project_name: str, - batch_size: int = INDEX_BATCH_SIZE, - state_filter: str = "all", - include_mrs: bool = True, - include_issues: bool = True, - include_code_files: bool = GITLAB_CONNECTOR_INCLUDE_CODE_FILES, - ) -> None: - self.project_owner = project_owner - self.project_name = project_name - self.batch_size = batch_size - self.state_filter = state_filter - self.include_mrs = include_mrs - self.include_issues = include_issues - self.include_code_files = include_code_files - self.gitlab_client: gitlab.Gitlab | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.gitlab_client = gitlab.Gitlab( - credentials["gitlab_url"], private_token=credentials["gitlab_access_token"] - ) - return None - - def _fetch_from_gitlab( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.gitlab_client is None: - raise ConnectorMissingCredentialError("Gitlab") - project: gitlab.Project = self.gitlab_client.projects.get( - f"{self.project_owner}/{self.project_name}" - ) - - # Fetch code files - if self.include_code_files: - # Fetching using BFS as project.report_tree with recursion causing slow load - queue = deque([""]) # Start with the root directory - while queue: - current_path = queue.popleft() - files = project.repository_tree(path=current_path, all=True) - for file_batch in _batch_gitlab_objects(files, self.batch_size): - code_doc_batch: list[Document] = [] - for file in file_batch: - if _should_exclude(file["path"]): - continue - - if file["type"] == "blob": - code_doc_batch.append( - _convert_code_to_document( - project, - file, - self.gitlab_client.url, - self.project_name, - self.project_owner, - ) - ) - elif file["type"] == "tree": - queue.append(file["path"]) - - if code_doc_batch: - yield code_doc_batch - - if self.include_mrs: - merge_requests = project.mergerequests.list( - state=self.state_filter, order_by="updated_at", sort="desc" - ) - - for mr_batch in _batch_gitlab_objects(merge_requests, self.batch_size): - mr_doc_batch: list[Document] = [] - for mr in mr_batch: - mr.updated_at = datetime.strptime( - mr.updated_at, "%Y-%m-%dT%H:%M:%S.%f%z" - ) - if start is not None and mr.updated_at < start.replace( - tzinfo=pytz.UTC - ): - yield mr_doc_batch - return - if end is not None and mr.updated_at > end.replace(tzinfo=pytz.UTC): - continue - mr_doc_batch.append(_convert_merge_request_to_document(mr)) - yield mr_doc_batch - - if self.include_issues: - issues = project.issues.list(state=self.state_filter) - - for issue_batch in _batch_gitlab_objects(issues, self.batch_size): - issue_doc_batch: list[Document] = [] - for issue in issue_batch: - issue.updated_at = datetime.strptime( - issue.updated_at, "%Y-%m-%dT%H:%M:%S.%f%z" - ) - if start is not None: - start = start.replace(tzinfo=pytz.UTC) - if issue.updated_at < start: - yield issue_doc_batch - return - if end is not None: - end = end.replace(tzinfo=pytz.UTC) - if issue.updated_at > end: - continue - issue_doc_batch.append(_convert_issue_to_document(issue)) - yield issue_doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_from_gitlab() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - return self._fetch_from_gitlab(start_datetime, end_datetime) - - -if __name__ == "__main__": - import os - - connector = GitlabConnector( - # gitlab_url="https://gitlab.com/api/v4", - project_owner=os.environ["PROJECT_OWNER"], - project_name=os.environ["PROJECT_NAME"], - batch_size=10, - state_filter="all", - include_mrs=True, - include_issues=True, - include_code_files=GITLAB_CONNECTOR_INCLUDE_CODE_FILES, - ) - - connector.load_credentials( - { - "gitlab_access_token": os.environ["GITLAB_ACCESS_TOKEN"], - "gitlab_url": os.environ["GITLAB_URL"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/gmail/__init__.py b/backend/danswer/connectors/gmail/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/gmail/connector.py b/backend/danswer/connectors/gmail/connector.py deleted file mode 100644 index 42d2f305f73..00000000000 --- a/backend/danswer/connectors/gmail/connector.py +++ /dev/null @@ -1,221 +0,0 @@ -from base64 import urlsafe_b64decode -from typing import Any -from typing import cast -from typing import Dict - -from google.oauth2.credentials import Credentials as OAuthCredentials # type: ignore -from google.oauth2.service_account import Credentials as ServiceAccountCredentials # type: ignore -from googleapiclient import discovery # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.gmail.connector_auth import ( - get_gmail_creds_for_authorized_user, -) -from danswer.connectors.gmail.connector_auth import ( - get_gmail_creds_for_service_account, -) -from danswer.connectors.gmail.constants import ( - DB_CREDENTIALS_DICT_DELEGATED_USER_KEY, -) -from danswer.connectors.gmail.constants import DB_CREDENTIALS_DICT_TOKEN_KEY -from danswer.connectors.gmail.constants import ( - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class GmailConnector(LoadConnector, PollConnector): - def __init__(self, batch_size: int = INDEX_BATCH_SIZE) -> None: - self.batch_size = batch_size - self.creds: OAuthCredentials | ServiceAccountCredentials | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, str] | None: - """Checks for two different types of credentials. - (1) A credential which holds a token acquired via a user going thorugh - the Google OAuth flow. - (2) A credential which holds a service account key JSON file, which - can then be used to impersonate any user in the workspace. - """ - creds: OAuthCredentials | ServiceAccountCredentials | None = None - new_creds_dict = None - if DB_CREDENTIALS_DICT_TOKEN_KEY in credentials: - access_token_json_str = cast( - str, credentials[DB_CREDENTIALS_DICT_TOKEN_KEY] - ) - creds = get_gmail_creds_for_authorized_user( - token_json_str=access_token_json_str - ) - - # tell caller to update token stored in DB if it has changed - # (e.g. the token has been refreshed) - new_creds_json_str = creds.to_json() if creds else "" - if new_creds_json_str != access_token_json_str: - new_creds_dict = {DB_CREDENTIALS_DICT_TOKEN_KEY: new_creds_json_str} - - if GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY in credentials: - service_account_key_json_str = credentials[ - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY - ] - creds = get_gmail_creds_for_service_account( - service_account_key_json_str=service_account_key_json_str - ) - - # "Impersonate" a user if one is specified - delegated_user_email = cast( - str | None, credentials.get(DB_CREDENTIALS_DICT_DELEGATED_USER_KEY) - ) - if delegated_user_email: - creds = creds.with_subject(delegated_user_email) if creds else None # type: ignore - - if creds is None: - raise PermissionError( - "Unable to access Gmail - unknown credential structure." - ) - - self.creds = creds - return new_creds_dict - - def _get_email_body(self, payload: dict[str, Any]) -> str: - parts = payload.get("parts", []) - email_body = "" - for part in parts: - mime_type = part.get("mimeType") - body = part.get("body") - if mime_type == "text/plain": - data = body.get("data", "") - text = urlsafe_b64decode(data).decode() - email_body += text - return email_body - - def _email_to_document(self, full_email: Dict[str, Any]) -> Document: - email_id = full_email["id"] - payload = full_email["payload"] - headers = payload.get("headers") - labels = full_email.get("labelIds", []) - metadata = {} - if headers: - for header in headers: - name = header.get("name").lower() - value = header.get("value") - if name in ["from", "to", "subject", "date", "cc", "bcc"]: - metadata[name] = value - email_data = "" - for name, value in metadata.items(): - email_data += f"{name}: {value}\n" - metadata["labels"] = labels - logger.debug(f"{email_data}") - email_body_text: str = self._get_email_body(payload) - date_str = metadata.get("date") - email_updated_at = time_str_to_utc(date_str) if date_str else None - link = f"https://mail.google.com/mail/u/0/#inbox/{email_id}" - return Document( - id=email_id, - sections=[Section(link=link, text=email_data + email_body_text)], - source=DocumentSource.GMAIL, - title=metadata.get("subject"), - semantic_identifier=metadata.get("subject", "Untitled Email"), - doc_updated_at=email_updated_at, - metadata=metadata, - ) - - @staticmethod - def _build_time_range_query( - time_range_start: SecondsSinceUnixEpoch | None = None, - time_range_end: SecondsSinceUnixEpoch | None = None, - ) -> str | None: - query = "" - if time_range_start is not None and time_range_start != 0: - query += f"after:{int(time_range_start)}" - if time_range_end is not None and time_range_end != 0: - query += f" before:{int(time_range_end)}" - query = query.strip() - - if len(query) == 0: - return None - - return query - - def _fetch_mails_from_gmail( - self, - time_range_start: SecondsSinceUnixEpoch | None = None, - time_range_end: SecondsSinceUnixEpoch | None = None, - ) -> GenerateDocumentsOutput: - if self.creds is None: - raise PermissionError("Not logged into Gmail") - page_token = "" - query = GmailConnector._build_time_range_query(time_range_start, time_range_end) - service = discovery.build("gmail", "v1", credentials=self.creds) - while page_token is not None: - result = ( - service.users() - .messages() - .list( - userId="me", - pageToken=page_token, - q=query, - maxResults=self.batch_size, - ) - .execute() - ) - page_token = result.get("nextPageToken") - messages = result.get("messages", []) - doc_batch = [] - for message in messages: - message_id = message["id"] - msg = ( - service.users() - .messages() - .get(userId="me", id=message_id, format="full") - .execute() - ) - doc = self._email_to_document(msg) - doc_batch.append(doc) - if len(doc_batch) > 0: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - yield from self._fetch_mails_from_gmail() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - yield from self._fetch_mails_from_gmail(start, end) - - -if __name__ == "__main__": - import json - import os - - service_account_json_path = os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY_JSON_PATH") - if not service_account_json_path: - raise ValueError( - "Please set GOOGLE_SERVICE_ACCOUNT_KEY_JSON_PATH environment variable" - ) - with open(service_account_json_path) as f: - creds = json.load(f) - - credentials_dict = { - DB_CREDENTIALS_DICT_TOKEN_KEY: json.dumps(creds), - } - delegated_user = os.environ.get("GMAIL_DELEGATED_USER") - if delegated_user: - credentials_dict[DB_CREDENTIALS_DICT_DELEGATED_USER_KEY] = delegated_user - - connector = GmailConnector() - connector.load_credentials( - json.loads(credentials_dict[DB_CREDENTIALS_DICT_TOKEN_KEY]) - ) - document_batch_generator = connector.load_from_state() - for document_batch in document_batch_generator: - print(document_batch) - break diff --git a/backend/danswer/connectors/gmail/connector_auth.py b/backend/danswer/connectors/gmail/connector_auth.py deleted file mode 100644 index ad80d1e1eb1..00000000000 --- a/backend/danswer/connectors/gmail/connector_auth.py +++ /dev/null @@ -1,199 +0,0 @@ -import json -from typing import cast -from urllib.parse import parse_qs -from urllib.parse import ParseResult -from urllib.parse import urlparse - -from google.auth.transport.requests import Request # type: ignore -from google.oauth2.credentials import Credentials as OAuthCredentials # type: ignore -from google.oauth2.service_account import Credentials as ServiceAccountCredentials # type: ignore -from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import KV_CRED_KEY -from danswer.configs.constants import KV_GMAIL_CRED_KEY -from danswer.configs.constants import KV_GMAIL_SERVICE_ACCOUNT_KEY -from danswer.connectors.gmail.constants import ( - DB_CREDENTIALS_DICT_DELEGATED_USER_KEY, -) -from danswer.connectors.gmail.constants import DB_CREDENTIALS_DICT_TOKEN_KEY -from danswer.connectors.gmail.constants import ( - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.connectors.gmail.constants import SCOPES -from danswer.db.credentials import update_credential_json -from danswer.db.models import User -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.server.documents.models import CredentialBase -from danswer.server.documents.models import GoogleAppCredentials -from danswer.server.documents.models import GoogleServiceAccountKey -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _build_frontend_gmail_redirect() -> str: - return f"{WEB_DOMAIN}/admin/connectors/gmail/auth/callback" - - -def get_gmail_creds_for_authorized_user( - token_json_str: str, -) -> OAuthCredentials | None: - creds_json = json.loads(token_json_str) - creds = OAuthCredentials.from_authorized_user_info(creds_json, SCOPES) - if creds.valid: - return creds - - if creds.expired and creds.refresh_token: - try: - creds.refresh(Request()) - if creds.valid: - logger.notice("Refreshed Gmail tokens.") - return creds - except Exception as e: - logger.exception(f"Failed to refresh gmail access token due to: {e}") - return None - - return None - - -def get_gmail_creds_for_service_account( - service_account_key_json_str: str, -) -> ServiceAccountCredentials | None: - service_account_key = json.loads(service_account_key_json_str) - creds = ServiceAccountCredentials.from_service_account_info( - service_account_key, scopes=SCOPES - ) - if not creds.valid or not creds.expired: - creds.refresh(Request()) - return creds if creds.valid else None - - -def verify_csrf(credential_id: int, state: str) -> None: - csrf = get_dynamic_config_store().load(KV_CRED_KEY.format(str(credential_id))) - if csrf != state: - raise PermissionError( - "State from Gmail Connector callback does not match expected" - ) - - -def get_gmail_auth_url(credential_id: int) -> str: - creds_str = str(get_dynamic_config_store().load(KV_GMAIL_CRED_KEY)) - credential_json = json.loads(creds_str) - flow = InstalledAppFlow.from_client_config( - credential_json, - scopes=SCOPES, - redirect_uri=_build_frontend_gmail_redirect(), - ) - auth_url, _ = flow.authorization_url(prompt="consent") - - parsed_url = cast(ParseResult, urlparse(auth_url)) - params = parse_qs(parsed_url.query) - - get_dynamic_config_store().store( - KV_CRED_KEY.format(credential_id), params.get("state", [None])[0], encrypt=True - ) # type: ignore - return str(auth_url) - - -def get_auth_url(credential_id: int) -> str: - creds_str = str(get_dynamic_config_store().load(KV_GMAIL_CRED_KEY)) - credential_json = json.loads(creds_str) - flow = InstalledAppFlow.from_client_config( - credential_json, - scopes=SCOPES, - redirect_uri=_build_frontend_gmail_redirect(), - ) - auth_url, _ = flow.authorization_url(prompt="consent") - - parsed_url = cast(ParseResult, urlparse(auth_url)) - params = parse_qs(parsed_url.query) - - get_dynamic_config_store().store( - KV_CRED_KEY.format(credential_id), params.get("state", [None])[0], encrypt=True - ) # type: ignore - return str(auth_url) - - -def update_gmail_credential_access_tokens( - auth_code: str, - credential_id: int, - user: User, - db_session: Session, -) -> OAuthCredentials | None: - app_credentials = get_google_app_gmail_cred() - flow = InstalledAppFlow.from_client_config( - app_credentials.model_dump(), - scopes=SCOPES, - redirect_uri=_build_frontend_gmail_redirect(), - ) - flow.fetch_token(code=auth_code) - creds = flow.credentials - token_json_str = creds.to_json() - new_creds_dict = {DB_CREDENTIALS_DICT_TOKEN_KEY: token_json_str} - - if not update_credential_json(credential_id, new_creds_dict, user, db_session): - return None - return creds - - -def build_service_account_creds( - delegated_user_email: str | None = None, -) -> CredentialBase: - service_account_key = get_gmail_service_account_key() - - credential_dict = { - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY: service_account_key.json(), - } - if delegated_user_email: - credential_dict[DB_CREDENTIALS_DICT_DELEGATED_USER_KEY] = delegated_user_email - - return CredentialBase( - source=DocumentSource.GMAIL, - credential_json=credential_dict, - admin_public=True, - ) - - -def get_google_app_gmail_cred() -> GoogleAppCredentials: - creds_str = str(get_dynamic_config_store().load(KV_GMAIL_CRED_KEY)) - return GoogleAppCredentials(**json.loads(creds_str)) - - -def upsert_google_app_gmail_cred(app_credentials: GoogleAppCredentials) -> None: - get_dynamic_config_store().store( - KV_GMAIL_CRED_KEY, app_credentials.json(), encrypt=True - ) - - -def delete_google_app_gmail_cred() -> None: - get_dynamic_config_store().delete(KV_GMAIL_CRED_KEY) - - -def get_gmail_service_account_key() -> GoogleServiceAccountKey: - creds_str = str(get_dynamic_config_store().load(KV_GMAIL_SERVICE_ACCOUNT_KEY)) - return GoogleServiceAccountKey(**json.loads(creds_str)) - - -def upsert_gmail_service_account_key( - service_account_key: GoogleServiceAccountKey, -) -> None: - get_dynamic_config_store().store( - KV_GMAIL_SERVICE_ACCOUNT_KEY, service_account_key.json(), encrypt=True - ) - - -def upsert_service_account_key(service_account_key: GoogleServiceAccountKey) -> None: - get_dynamic_config_store().store( - KV_GMAIL_SERVICE_ACCOUNT_KEY, service_account_key.json(), encrypt=True - ) - - -def delete_gmail_service_account_key() -> None: - get_dynamic_config_store().delete(KV_GMAIL_SERVICE_ACCOUNT_KEY) - - -def delete_service_account_key() -> None: - get_dynamic_config_store().delete(KV_GMAIL_SERVICE_ACCOUNT_KEY) diff --git a/backend/danswer/connectors/gmail/constants.py b/backend/danswer/connectors/gmail/constants.py deleted file mode 100644 index 36eff081818..00000000000 --- a/backend/danswer/connectors/gmail/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -DB_CREDENTIALS_DICT_TOKEN_KEY = "gmail_tokens" -GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY = "gmail_service_account_key" -DB_CREDENTIALS_DICT_DELEGATED_USER_KEY = "gmail_delegated_user" -SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] diff --git a/backend/danswer/connectors/gong/__init__.py b/backend/danswer/connectors/gong/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/gong/connector.py b/backend/danswer/connectors/gong/connector.py deleted file mode 100644 index 56c93f57d50..00000000000 --- a/backend/danswer/connectors/gong/connector.py +++ /dev/null @@ -1,316 +0,0 @@ -import base64 -from collections.abc import Generator -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Any -from typing import cast - -import requests - -from danswer.configs.app_configs import CONTINUE_ON_CONNECTOR_FAILURE -from danswer.configs.app_configs import GONG_CONNECTOR_START_TIME -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - -GONG_BASE_URL = "https://us-34014.api.gong.io" - - -class GongConnector(LoadConnector, PollConnector): - def __init__( - self, - workspaces: list[str] | None = None, - batch_size: int = INDEX_BATCH_SIZE, - continue_on_fail: bool = CONTINUE_ON_CONNECTOR_FAILURE, - hide_user_info: bool = False, - ) -> None: - self.workspaces = workspaces - self.batch_size: int = batch_size - self.continue_on_fail = continue_on_fail - self.auth_token_basic: str | None = None - self.hide_user_info = hide_user_info - - def _get_auth_header(self) -> dict[str, str]: - if self.auth_token_basic is None: - raise ConnectorMissingCredentialError("Gong") - - return {"Authorization": f"Basic {self.auth_token_basic}"} - - def _get_workspace_id_map(self) -> dict[str, str]: - url = f"{GONG_BASE_URL}/v2/workspaces" - response = requests.get(url, headers=self._get_auth_header()) - response.raise_for_status() - - workspaces_details = response.json().get("workspaces") - name_id_map = { - workspace["name"]: workspace["id"] for workspace in workspaces_details - } - id_id_map = { - workspace["id"]: workspace["id"] for workspace in workspaces_details - } - # In very rare case, if a workspace is given a name which is the id of another workspace, - # Then the user input is treated as the name - return {**id_id_map, **name_id_map} - - def _get_transcript_batches( - self, start_datetime: str | None = None, end_datetime: str | None = None - ) -> Generator[list[dict[str, Any]], None, None]: - url = f"{GONG_BASE_URL}/v2/calls/transcript" - body: dict[str, dict] = {"filter": {}} - if start_datetime: - body["filter"]["fromDateTime"] = start_datetime - if end_datetime: - body["filter"]["toDateTime"] = end_datetime - - # The batch_ids in the previous method appears to be batches of call_ids to process - # In this method, we will retrieve transcripts for them in batches. - transcripts: list[dict[str, Any]] = [] - workspace_list = self.workspaces or [None] # type: ignore - workspace_map = self._get_workspace_id_map() if self.workspaces else {} - - for workspace in workspace_list: - if workspace: - logger.info(f"Updating Gong workspace: {workspace}") - workspace_id = workspace_map.get(workspace) - if not workspace_id: - logger.error(f"Invalid Gong workspace: {workspace}") - if not self.continue_on_fail: - raise ValueError(f"Invalid workspace: {workspace}") - continue - body["filter"]["workspaceId"] = workspace_id - else: - if "workspaceId" in body["filter"]: - del body["filter"]["workspaceId"] - - while True: - response = requests.post( - url, headers=self._get_auth_header(), json=body - ) - # If no calls in the range, just break out - if response.status_code == 404: - break - - try: - response.raise_for_status() - except Exception: - logger.error(f"Error fetching transcripts: {response.text}") - raise - - data = response.json() - call_transcripts = data.get("callTranscripts", []) - transcripts.extend(call_transcripts) - - while len(transcripts) >= self.batch_size: - yield transcripts[: self.batch_size] - transcripts = transcripts[self.batch_size :] - - cursor = data.get("records", {}).get("cursor") - if cursor: - body["cursor"] = cursor - else: - break - - if transcripts: - yield transcripts - - def _get_call_details_by_ids(self, call_ids: list[str]) -> dict: - url = f"{GONG_BASE_URL}/v2/calls/extensive" - - body = { - "filter": {"callIds": call_ids}, - "contentSelector": {"exposedFields": {"parties": True}}, - } - - response = requests.post(url, headers=self._get_auth_header(), json=body) - response.raise_for_status() - - calls = response.json().get("calls") - call_to_metadata = {} - for call in calls: - call_to_metadata[call["metaData"]["id"]] = call - - return call_to_metadata - - @staticmethod - def _parse_parties(parties: list[dict]) -> dict[str, str]: - id_mapping = {} - for party in parties: - name = party.get("name") - email = party.get("emailAddress") - - if name and email: - full_identifier = f"{name} ({email})" - elif name: - full_identifier = name - elif email: - full_identifier = email - else: - full_identifier = "Unknown" - - id_mapping[party["speakerId"]] = full_identifier - - return id_mapping - - def _fetch_calls( - self, start_datetime: str | None = None, end_datetime: str | None = None - ) -> GenerateDocumentsOutput: - for transcript_batch in self._get_transcript_batches( - start_datetime, end_datetime - ): - doc_batch: list[Document] = [] - - call_ids = cast( - list[str], - [t.get("callId") for t in transcript_batch if t.get("callId")], - ) - call_details_map = self._get_call_details_by_ids(call_ids) - - for transcript in transcript_batch: - call_id = transcript.get("callId") - - if not call_id or call_id not in call_details_map: - logger.error( - f"Couldn't get call information for Call ID: {call_id}" - ) - if not self.continue_on_fail: - raise RuntimeError( - f"Couldn't get call information for Call ID: {call_id}" - ) - continue - - call_details = call_details_map[call_id] - call_metadata = call_details["metaData"] - - call_time_str = call_metadata["started"] - call_title = call_metadata["title"] - logger.info( - f"Indexing Gong call from {call_time_str.split('T', 1)[0]}: {call_title}" - ) - - call_parties = cast(list[dict] | None, call_details.get("parties")) - if call_parties is None: - logger.error(f"Couldn't get parties for Call ID: {call_id}") - call_parties = [] - - id_to_name_map = self._parse_parties(call_parties) - - # Keeping a separate dict here in case the parties info is incomplete - speaker_to_name: dict[str, str] = {} - - transcript_text = "" - call_purpose = call_metadata["purpose"] - if call_purpose: - transcript_text += f"Call Description: {call_purpose}\n\n" - - contents = transcript["transcript"] - for segment in contents: - speaker_id = segment.get("speakerId", "") - if speaker_id not in speaker_to_name: - if self.hide_user_info: - speaker_to_name[ - speaker_id - ] = f"User {len(speaker_to_name) + 1}" - else: - speaker_to_name[speaker_id] = id_to_name_map.get( - speaker_id, "Unknown" - ) - - speaker_name = speaker_to_name[speaker_id] - - sentences = segment.get("sentences", {}) - monolog = " ".join( - [sentence.get("text", "") for sentence in sentences] - ) - transcript_text += f"{speaker_name}: {monolog}\n\n" - - metadata = {} - if call_metadata.get("system"): - metadata["client"] = call_metadata.get("system") - # TODO calls have a clientUniqueId field, can pull that in later - - doc_batch.append( - Document( - id=call_id, - sections=[ - Section(link=call_metadata["url"], text=transcript_text) - ], - source=DocumentSource.GONG, - # Should not ever be Untitled as a call cannot be made without a Title - semantic_identifier=call_title or "Untitled", - doc_updated_at=datetime.fromisoformat(call_time_str).astimezone( - timezone.utc - ), - metadata={"client": call_metadata.get("system")}, - ) - ) - yield doc_batch - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - combined = ( - f'{credentials["gong_access_key"]}:{credentials["gong_access_key_secret"]}' - ) - self.auth_token_basic = base64.b64encode(combined.encode("utf-8")).decode( - "utf-8" - ) - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_calls() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - end_datetime = datetime.fromtimestamp(end, tz=timezone.utc) - - # if this env variable is set, don't start from a timestamp before the specified - # start time - # TODO: remove this once this is globally available - if GONG_CONNECTOR_START_TIME: - special_start_datetime = datetime.fromisoformat(GONG_CONNECTOR_START_TIME) - special_start_datetime = special_start_datetime.replace(tzinfo=timezone.utc) - else: - special_start_datetime = datetime.fromtimestamp(0, tz=timezone.utc) - - # don't let the special start dt be past the end time, this causes issues when - # the Gong API (`filter.fromDateTime: must be before toDateTime`) - special_start_datetime = min(special_start_datetime, end_datetime) - - start_datetime = max( - datetime.fromtimestamp(start, tz=timezone.utc), special_start_datetime - ) - - # Because these are meeting start times, the meeting needs to end and be processed - # so adding a 1 day buffer and fetching by default till current time - start_one_day_offset = start_datetime - timedelta(days=1) - start_time = start_one_day_offset.isoformat() - - end_time = datetime.fromtimestamp(end, tz=timezone.utc).isoformat() - - logger.info(f"Fetching Gong calls between {start_time} and {end_time}") - return self._fetch_calls(start_time, end_time) - - -if __name__ == "__main__": - import os - - connector = GongConnector() - connector.load_credentials( - { - "gong_access_key": os.environ["GONG_ACCESS_KEY"], - "gong_access_key_secret": os.environ["GONG_ACCESS_KEY_SECRET"], - } - ) - - latest_docs = connector.load_from_state() - print(next(latest_docs)) diff --git a/backend/danswer/connectors/google_drive/__init__.py b/backend/danswer/connectors/google_drive/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/google_drive/connector.py b/backend/danswer/connectors/google_drive/connector.py deleted file mode 100644 index 40a9b73432f..00000000000 --- a/backend/danswer/connectors/google_drive/connector.py +++ /dev/null @@ -1,555 +0,0 @@ -import io -from collections.abc import Iterator -from collections.abc import Sequence -from datetime import datetime -from datetime import timezone -from enum import Enum -from itertools import chain -from typing import Any -from typing import cast - -from google.oauth2.credentials import Credentials as OAuthCredentials # type: ignore -from google.oauth2.service_account import Credentials as ServiceAccountCredentials # type: ignore -from googleapiclient import discovery # type: ignore -from googleapiclient.errors import HttpError # type: ignore - -from danswer.configs.app_configs import CONTINUE_ON_CONNECTOR_FAILURE -from danswer.configs.app_configs import GOOGLE_DRIVE_FOLLOW_SHORTCUTS -from danswer.configs.app_configs import GOOGLE_DRIVE_INCLUDE_SHARED -from danswer.configs.app_configs import GOOGLE_DRIVE_ONLY_ORG_PUBLIC -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import IGNORE_FOR_QA -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.google_drive.connector_auth import ( - get_google_drive_creds_for_authorized_user, -) -from danswer.connectors.google_drive.connector_auth import ( - get_google_drive_creds_for_service_account, -) -from danswer.connectors.google_drive.constants import ( - DB_CREDENTIALS_DICT_DELEGATED_USER_KEY, -) -from danswer.connectors.google_drive.constants import ( - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.connectors.google_drive.constants import DB_CREDENTIALS_DICT_TOKEN_KEY -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import docx_to_text -from danswer.file_processing.extract_file_text import pdf_to_text -from danswer.file_processing.extract_file_text import pptx_to_text -from danswer.utils.batching import batch_generator -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -DRIVE_FOLDER_TYPE = "application/vnd.google-apps.folder" -DRIVE_SHORTCUT_TYPE = "application/vnd.google-apps.shortcut" -UNSUPPORTED_FILE_TYPE_CONTENT = "" # keep empty for now - - -class GDriveMimeType(str, Enum): - DOC = "application/vnd.google-apps.document" - SPREADSHEET = "application/vnd.google-apps.spreadsheet" - PDF = "application/pdf" - WORD_DOC = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - PPT = "application/vnd.google-apps.presentation" - POWERPOINT = ( - "application/vnd.openxmlformats-officedocument.presentationml.presentation" - ) - - -GoogleDriveFileType = dict[str, Any] - -# Google Drive APIs are quite flakey and may 500 for an -# extended period of time. Trying to combat here by adding a very -# long retry period (~20 minutes of trying every minute) -add_retries = retry_builder(tries=50, max_delay=30) - - -def _run_drive_file_query( - service: discovery.Resource, - query: str, - continue_on_failure: bool, - include_shared: bool = GOOGLE_DRIVE_INCLUDE_SHARED, - follow_shortcuts: bool = GOOGLE_DRIVE_FOLLOW_SHORTCUTS, - batch_size: int = INDEX_BATCH_SIZE, -) -> Iterator[GoogleDriveFileType]: - next_page_token = "" - while next_page_token is not None: - logger.debug(f"Running Google Drive fetch with query: {query}") - results = add_retries( - lambda: ( - service.files() - .list( - corpora="allDrives" - if include_shared - else "user", # needed to search through shared drives - pageSize=batch_size, - supportsAllDrives=include_shared, - includeItemsFromAllDrives=include_shared, - fields=( - "nextPageToken, files(mimeType, id, name, permissions, " - "modifiedTime, webViewLink, shortcutDetails)" - ), - pageToken=next_page_token, - q=query, - ) - .execute() - ) - )() - next_page_token = results.get("nextPageToken") - files = results["files"] - for file in files: - if follow_shortcuts and "shortcutDetails" in file: - try: - file_shortcut_points_to = add_retries( - lambda: ( - service.files() - .get( - fileId=file["shortcutDetails"]["targetId"], - supportsAllDrives=include_shared, - fields="mimeType, id, name, modifiedTime, webViewLink, permissions, shortcutDetails", - ) - .execute() - ) - )() - yield file_shortcut_points_to - except HttpError: - logger.error( - f"Failed to follow shortcut with details: {file['shortcutDetails']}" - ) - if continue_on_failure: - continue - raise - else: - yield file - - -def _get_folder_id( - service: discovery.Resource, - parent_id: str, - folder_name: str, - include_shared: bool, - follow_shortcuts: bool, -) -> str | None: - """ - Get the ID of a folder given its name and the ID of its parent folder. - """ - query = f"'{parent_id}' in parents and name='{folder_name}' and " - if follow_shortcuts: - query += f"(mimeType='{DRIVE_FOLDER_TYPE}' or mimeType='{DRIVE_SHORTCUT_TYPE}')" - else: - query += f"mimeType='{DRIVE_FOLDER_TYPE}'" - - # TODO: support specifying folder path in shared drive rather than just `My Drive` - results = add_retries( - lambda: ( - service.files() - .list( - q=query, - spaces="drive", - fields="nextPageToken, files(id, name, shortcutDetails)", - supportsAllDrives=include_shared, - includeItemsFromAllDrives=include_shared, - ) - .execute() - ) - )() - items = results.get("files", []) - - folder_id = None - if items: - if follow_shortcuts and "shortcutDetails" in items[0]: - folder_id = items[0]["shortcutDetails"]["targetId"] - else: - folder_id = items[0]["id"] - return folder_id - - -def _get_folders( - service: discovery.Resource, - continue_on_failure: bool, - folder_id: str | None = None, # if specified, only fetches files within this folder - include_shared: bool = GOOGLE_DRIVE_INCLUDE_SHARED, - follow_shortcuts: bool = GOOGLE_DRIVE_FOLLOW_SHORTCUTS, - batch_size: int = INDEX_BATCH_SIZE, -) -> Iterator[GoogleDriveFileType]: - query = f"mimeType = '{DRIVE_FOLDER_TYPE}' " - if follow_shortcuts: - query = "(" + query + f" or mimeType = '{DRIVE_SHORTCUT_TYPE}'" + ") " - - if folder_id: - query += f"and '{folder_id}' in parents " - query = query.rstrip() # remove the trailing space(s) - - for file in _run_drive_file_query( - service=service, - query=query, - continue_on_failure=continue_on_failure, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - batch_size=batch_size, - ): - # Need to check this since file may have been a target of a shortcut - # and not necessarily a folder - if file["mimeType"] == DRIVE_FOLDER_TYPE: - yield file - else: - pass - - -def _get_files( - service: discovery.Resource, - continue_on_failure: bool, - time_range_start: SecondsSinceUnixEpoch | None = None, - time_range_end: SecondsSinceUnixEpoch | None = None, - folder_id: str | None = None, # if specified, only fetches files within this folder - include_shared: bool = GOOGLE_DRIVE_INCLUDE_SHARED, - follow_shortcuts: bool = GOOGLE_DRIVE_FOLLOW_SHORTCUTS, - batch_size: int = INDEX_BATCH_SIZE, -) -> Iterator[GoogleDriveFileType]: - query = f"mimeType != '{DRIVE_FOLDER_TYPE}' " - if time_range_start is not None: - time_start = datetime.utcfromtimestamp(time_range_start).isoformat() + "Z" - query += f"and modifiedTime >= '{time_start}' " - if time_range_end is not None: - time_stop = datetime.utcfromtimestamp(time_range_end).isoformat() + "Z" - query += f"and modifiedTime <= '{time_stop}' " - if folder_id: - query += f"and '{folder_id}' in parents " - query = query.rstrip() # remove the trailing space(s) - - files = _run_drive_file_query( - service=service, - query=query, - continue_on_failure=continue_on_failure, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - batch_size=batch_size, - ) - - return files - - -def get_all_files_batched( - service: discovery.Resource, - continue_on_failure: bool, - include_shared: bool = GOOGLE_DRIVE_INCLUDE_SHARED, - follow_shortcuts: bool = GOOGLE_DRIVE_FOLLOW_SHORTCUTS, - batch_size: int = INDEX_BATCH_SIZE, - time_range_start: SecondsSinceUnixEpoch | None = None, - time_range_end: SecondsSinceUnixEpoch | None = None, - folder_id: str | None = None, # if specified, only fetches files within this folder - # if True, will fetch files in sub-folders of the specified folder ID. - # Only applies if folder_id is specified. - traverse_subfolders: bool = True, - folder_ids_traversed: list[str] | None = None, -) -> Iterator[list[GoogleDriveFileType]]: - """Gets all files matching the criteria specified by the args from Google Drive - in batches of size `batch_size`. - """ - found_files = _get_files( - service=service, - continue_on_failure=continue_on_failure, - time_range_start=time_range_start, - time_range_end=time_range_end, - folder_id=folder_id, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - batch_size=batch_size, - ) - yield from batch_generator( - items=found_files, - batch_size=batch_size, - pre_batch_yield=lambda batch_files: logger.debug( - f"Parseable Documents in batch: {[file['name'] for file in batch_files]}" - ), - ) - - if traverse_subfolders and folder_id is not None: - folder_ids_traversed = folder_ids_traversed or [] - subfolders = _get_folders( - service=service, - folder_id=folder_id, - continue_on_failure=continue_on_failure, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - batch_size=batch_size, - ) - for subfolder in subfolders: - if subfolder["id"] not in folder_ids_traversed: - logger.info("Fetching all files in subfolder: " + subfolder["name"]) - folder_ids_traversed.append(subfolder["id"]) - yield from get_all_files_batched( - service=service, - continue_on_failure=continue_on_failure, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - batch_size=batch_size, - time_range_start=time_range_start, - time_range_end=time_range_end, - folder_id=subfolder["id"], - traverse_subfolders=traverse_subfolders, - folder_ids_traversed=folder_ids_traversed, - ) - else: - logger.debug( - "Skipping subfolder since already traversed: " + subfolder["name"] - ) - - -def extract_text(file: dict[str, str], service: discovery.Resource) -> str: - mime_type = file["mimeType"] - - if mime_type not in set(item.value for item in GDriveMimeType): - # Unsupported file types can still have a title, finding this way is still useful - return UNSUPPORTED_FILE_TYPE_CONTENT - - if mime_type in [ - GDriveMimeType.DOC.value, - GDriveMimeType.PPT.value, - GDriveMimeType.SPREADSHEET.value, - ]: - export_mime_type = "text/plain" - if mime_type == GDriveMimeType.SPREADSHEET.value: - export_mime_type = "text/csv" - elif mime_type == GDriveMimeType.PPT.value: - export_mime_type = "text/plain" - - response = ( - service.files() - .export(fileId=file["id"], mimeType=export_mime_type) - .execute() - ) - return response.decode("utf-8") - - elif mime_type == GDriveMimeType.WORD_DOC.value: - response = service.files().get_media(fileId=file["id"]).execute() - return docx_to_text(file=io.BytesIO(response)) - elif mime_type == GDriveMimeType.PDF.value: - response = service.files().get_media(fileId=file["id"]).execute() - return pdf_to_text(file=io.BytesIO(response)) - elif mime_type == GDriveMimeType.POWERPOINT.value: - response = service.files().get_media(fileId=file["id"]).execute() - return pptx_to_text(file=io.BytesIO(response)) - - return UNSUPPORTED_FILE_TYPE_CONTENT - - -class GoogleDriveConnector(LoadConnector, PollConnector): - def __init__( - self, - # optional list of folder paths e.g. "[My Folder/My Subfolder]" - # if specified, will only index files in these folders - folder_paths: list[str] | None = None, - batch_size: int = INDEX_BATCH_SIZE, - include_shared: bool = GOOGLE_DRIVE_INCLUDE_SHARED, - follow_shortcuts: bool = GOOGLE_DRIVE_FOLLOW_SHORTCUTS, - only_org_public: bool = GOOGLE_DRIVE_ONLY_ORG_PUBLIC, - continue_on_failure: bool = CONTINUE_ON_CONNECTOR_FAILURE, - ) -> None: - self.folder_paths = folder_paths or [] - self.batch_size = batch_size - self.include_shared = include_shared - self.follow_shortcuts = follow_shortcuts - self.only_org_public = only_org_public - self.continue_on_failure = continue_on_failure - self.creds: OAuthCredentials | ServiceAccountCredentials | None = None - - @staticmethod - def _process_folder_paths( - service: discovery.Resource, - folder_paths: list[str], - include_shared: bool, - follow_shortcuts: bool, - ) -> list[str]: - """['Folder/Sub Folder'] -> ['']""" - folder_ids: list[str] = [] - for path in folder_paths: - folder_names = path.split("/") - parent_id = "root" - for folder_name in folder_names: - found_parent_id = _get_folder_id( - service=service, - parent_id=parent_id, - folder_name=folder_name, - include_shared=include_shared, - follow_shortcuts=follow_shortcuts, - ) - if found_parent_id is None: - raise ValueError( - ( - f"Folder '{folder_name}' in path '{path}' " - "not found in Google Drive" - ) - ) - parent_id = found_parent_id - folder_ids.append(parent_id) - - return folder_ids - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, str] | None: - """Checks for two different types of credentials. - (1) A credential which holds a token acquired via a user going thorough - the Google OAuth flow. - (2) A credential which holds a service account key JSON file, which - can then be used to impersonate any user in the workspace. - """ - creds: OAuthCredentials | ServiceAccountCredentials | None = None - new_creds_dict = None - if DB_CREDENTIALS_DICT_TOKEN_KEY in credentials: - access_token_json_str = cast( - str, credentials[DB_CREDENTIALS_DICT_TOKEN_KEY] - ) - creds = get_google_drive_creds_for_authorized_user( - token_json_str=access_token_json_str - ) - - # tell caller to update token stored in DB if it has changed - # (e.g. the token has been refreshed) - new_creds_json_str = creds.to_json() if creds else "" - if new_creds_json_str != access_token_json_str: - new_creds_dict = {DB_CREDENTIALS_DICT_TOKEN_KEY: new_creds_json_str} - - if DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY in credentials: - service_account_key_json_str = credentials[ - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY - ] - creds = get_google_drive_creds_for_service_account( - service_account_key_json_str=service_account_key_json_str - ) - - # "Impersonate" a user if one is specified - delegated_user_email = cast( - str | None, credentials.get(DB_CREDENTIALS_DICT_DELEGATED_USER_KEY) - ) - if delegated_user_email: - creds = creds.with_subject(delegated_user_email) if creds else None # type: ignore - - if creds is None: - raise PermissionError( - "Unable to access Google Drive - unknown credential structure." - ) - - self.creds = creds - return new_creds_dict - - def _fetch_docs_from_drive( - self, - start: SecondsSinceUnixEpoch | None = None, - end: SecondsSinceUnixEpoch | None = None, - ) -> GenerateDocumentsOutput: - if self.creds is None: - raise PermissionError("Not logged into Google Drive") - - service = discovery.build("drive", "v3", credentials=self.creds) - folder_ids: Sequence[str | None] = self._process_folder_paths( - service, self.folder_paths, self.include_shared, self.follow_shortcuts - ) - if not folder_ids: - folder_ids = [None] - - file_batches = chain( - *[ - get_all_files_batched( - service=service, - continue_on_failure=self.continue_on_failure, - include_shared=self.include_shared, - follow_shortcuts=self.follow_shortcuts, - batch_size=self.batch_size, - time_range_start=start, - time_range_end=end, - folder_id=folder_id, - traverse_subfolders=True, - ) - for folder_id in folder_ids - ] - ) - for files_batch in file_batches: - doc_batch = [] - for file in files_batch: - try: - # Skip files that are shortcuts - if file.get("mimeType") == DRIVE_SHORTCUT_TYPE: - logger.info("Ignoring Drive Shortcut Filetype") - continue - - if self.only_org_public: - if "permissions" not in file: - continue - if not any( - permission["type"] == "domain" - for permission in file["permissions"] - ): - continue - - text_contents = extract_text(file, service) or "" - - doc_batch.append( - Document( - id=file["webViewLink"], - sections=[ - Section(link=file["webViewLink"], text=text_contents) - ], - source=DocumentSource.GOOGLE_DRIVE, - semantic_identifier=file["name"], - doc_updated_at=datetime.fromisoformat( - file["modifiedTime"] - ).astimezone(timezone.utc), - metadata={} if text_contents else {IGNORE_FOR_QA: "True"}, - ) - ) - except Exception as e: - if not self.continue_on_failure: - raise e - - logger.exception( - "Ran into exception when pulling a file from Google Drive" - ) - - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - yield from self._fetch_docs_from_drive() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - # need to subtract 10 minutes from start time to account for modifiedTime - # propogation if a document is modified, it takes some time for the API to - # reflect these changes if we do not have an offset, then we may "miss" the - # update when polling - yield from self._fetch_docs_from_drive(start, end) - - -if __name__ == "__main__": - import json - import os - - service_account_json_path = os.environ.get("GOOGLE_SERVICE_ACCOUNT_KEY_JSON_PATH") - if not service_account_json_path: - raise ValueError( - "Please set GOOGLE_SERVICE_ACCOUNT_KEY_JSON_PATH environment variable" - ) - with open(service_account_json_path) as f: - creds = json.load(f) - - credentials_dict = { - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY: json.dumps(creds), - } - delegated_user = os.environ.get("GOOGLE_DRIVE_DELEGATED_USER") - if delegated_user: - credentials_dict[DB_CREDENTIALS_DICT_DELEGATED_USER_KEY] = delegated_user - - connector = GoogleDriveConnector(include_shared=True, follow_shortcuts=True) - connector.load_credentials(credentials_dict) - document_batch_generator = connector.load_from_state() - for document_batch in document_batch_generator: - print(document_batch) - break diff --git a/backend/danswer/connectors/google_drive/connector_auth.py b/backend/danswer/connectors/google_drive/connector_auth.py deleted file mode 100644 index 0f47727e6ee..00000000000 --- a/backend/danswer/connectors/google_drive/connector_auth.py +++ /dev/null @@ -1,171 +0,0 @@ -import json -from typing import cast -from urllib.parse import parse_qs -from urllib.parse import ParseResult -from urllib.parse import urlparse - -from google.auth.transport.requests import Request # type: ignore -from google.oauth2.credentials import Credentials as OAuthCredentials # type: ignore -from google.oauth2.service_account import Credentials as ServiceAccountCredentials # type: ignore -from google_auth_oauthlib.flow import InstalledAppFlow # type: ignore -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import KV_CRED_KEY -from danswer.configs.constants import KV_GOOGLE_DRIVE_CRED_KEY -from danswer.configs.constants import KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY -from danswer.connectors.google_drive.constants import ( - DB_CREDENTIALS_DICT_DELEGATED_USER_KEY, -) -from danswer.connectors.google_drive.constants import ( - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.connectors.google_drive.constants import DB_CREDENTIALS_DICT_TOKEN_KEY -from danswer.connectors.google_drive.constants import SCOPES -from danswer.db.credentials import update_credential_json -from danswer.db.models import User -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.server.documents.models import CredentialBase -from danswer.server.documents.models import GoogleAppCredentials -from danswer.server.documents.models import GoogleServiceAccountKey -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _build_frontend_google_drive_redirect() -> str: - return f"{WEB_DOMAIN}/admin/connectors/google-drive/auth/callback" - - -def get_google_drive_creds_for_authorized_user( - token_json_str: str, -) -> OAuthCredentials | None: - creds_json = json.loads(token_json_str) - creds = OAuthCredentials.from_authorized_user_info(creds_json, SCOPES) - if creds.valid: - return creds - - if creds.expired and creds.refresh_token: - try: - creds.refresh(Request()) - if creds.valid: - logger.notice("Refreshed Google Drive tokens.") - return creds - except Exception as e: - logger.exception(f"Failed to refresh google drive access token due to: {e}") - return None - - return None - - -def get_google_drive_creds_for_service_account( - service_account_key_json_str: str, -) -> ServiceAccountCredentials | None: - service_account_key = json.loads(service_account_key_json_str) - creds = ServiceAccountCredentials.from_service_account_info( - service_account_key, scopes=SCOPES - ) - if not creds.valid or not creds.expired: - creds.refresh(Request()) - return creds if creds.valid else None - - -def verify_csrf(credential_id: int, state: str) -> None: - csrf = get_dynamic_config_store().load(KV_CRED_KEY.format(str(credential_id))) - if csrf != state: - raise PermissionError( - "State from Google Drive Connector callback does not match expected" - ) - - -def get_auth_url(credential_id: int) -> str: - creds_str = str(get_dynamic_config_store().load(KV_GOOGLE_DRIVE_CRED_KEY)) - credential_json = json.loads(creds_str) - flow = InstalledAppFlow.from_client_config( - credential_json, - scopes=SCOPES, - redirect_uri=_build_frontend_google_drive_redirect(), - ) - auth_url, _ = flow.authorization_url(prompt="consent") - - parsed_url = cast(ParseResult, urlparse(auth_url)) - params = parse_qs(parsed_url.query) - - get_dynamic_config_store().store( - KV_CRED_KEY.format(credential_id), params.get("state", [None])[0], encrypt=True - ) # type: ignore - return str(auth_url) - - -def update_credential_access_tokens( - auth_code: str, - credential_id: int, - user: User, - db_session: Session, -) -> OAuthCredentials | None: - app_credentials = get_google_app_cred() - flow = InstalledAppFlow.from_client_config( - app_credentials.model_dump(), - scopes=SCOPES, - redirect_uri=_build_frontend_google_drive_redirect(), - ) - flow.fetch_token(code=auth_code) - creds = flow.credentials - token_json_str = creds.to_json() - new_creds_dict = {DB_CREDENTIALS_DICT_TOKEN_KEY: token_json_str} - - if not update_credential_json(credential_id, new_creds_dict, user, db_session): - return None - return creds - - -def build_service_account_creds( - source: DocumentSource, - delegated_user_email: str | None = None, -) -> CredentialBase: - service_account_key = get_service_account_key() - - credential_dict = { - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY: service_account_key.json(), - } - if delegated_user_email: - credential_dict[DB_CREDENTIALS_DICT_DELEGATED_USER_KEY] = delegated_user_email - - return CredentialBase( - credential_json=credential_dict, - admin_public=True, - source=DocumentSource.GOOGLE_DRIVE, - ) - - -def get_google_app_cred() -> GoogleAppCredentials: - creds_str = str(get_dynamic_config_store().load(KV_GOOGLE_DRIVE_CRED_KEY)) - return GoogleAppCredentials(**json.loads(creds_str)) - - -def upsert_google_app_cred(app_credentials: GoogleAppCredentials) -> None: - get_dynamic_config_store().store( - KV_GOOGLE_DRIVE_CRED_KEY, app_credentials.json(), encrypt=True - ) - - -def delete_google_app_cred() -> None: - get_dynamic_config_store().delete(KV_GOOGLE_DRIVE_CRED_KEY) - - -def get_service_account_key() -> GoogleServiceAccountKey: - creds_str = str( - get_dynamic_config_store().load(KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY) - ) - return GoogleServiceAccountKey(**json.loads(creds_str)) - - -def upsert_service_account_key(service_account_key: GoogleServiceAccountKey) -> None: - get_dynamic_config_store().store( - KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY, service_account_key.json(), encrypt=True - ) - - -def delete_service_account_key() -> None: - get_dynamic_config_store().delete(KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY) diff --git a/backend/danswer/connectors/google_drive/constants.py b/backend/danswer/connectors/google_drive/constants.py deleted file mode 100644 index 214bfd5cb97..00000000000 --- a/backend/danswer/connectors/google_drive/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -DB_CREDENTIALS_DICT_TOKEN_KEY = "google_drive_tokens" -DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY = "google_drive_service_account_key" -DB_CREDENTIALS_DICT_DELEGATED_USER_KEY = "google_drive_delegated_user" -SCOPES = [ - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/drive.metadata.readonly", -] diff --git a/backend/danswer/connectors/google_site/__init__.py b/backend/danswer/connectors/google_site/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/google_site/connector.py b/backend/danswer/connectors/google_site/connector.py deleted file mode 100644 index 9cfcf224e3f..00000000000 --- a/backend/danswer/connectors/google_site/connector.py +++ /dev/null @@ -1,147 +0,0 @@ -import os -import re -from typing import Any -from typing import cast - -from bs4 import BeautifulSoup -from bs4 import Tag -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.db.engine import get_sqlalchemy_engine -from danswer.file_processing.extract_file_text import load_files_from_zip -from danswer.file_processing.extract_file_text import read_text_file -from danswer.file_processing.html_utils import web_html_cleanup -from danswer.file_store.file_store import get_default_file_store -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def a_tag_text_to_path(atag: Tag) -> str: - page_path = atag.text.strip().lower() - page_path = re.sub(r"[^a-zA-Z0-9\s]", "", page_path) - page_path = "-".join(page_path.split()) - - return page_path - - -def find_google_sites_page_path_from_navbar( - element: BeautifulSoup | Tag, path: str, depth: int -) -> str | None: - lis = cast( - list[Tag], - element.find_all("li", attrs={"data-nav-level": f"{depth}"}), - ) - for li in lis: - a = cast(Tag, li.find("a")) - if a.get("aria-selected") == "true": - return f"{path}/{a_tag_text_to_path(a)}" - elif a.get("aria-expanded") == "true": - sub_path = find_google_sites_page_path_from_navbar( - element, f"{path}/{a_tag_text_to_path(a)}", depth + 1 - ) - if sub_path: - return sub_path - - return None - - -class GoogleSitesConnector(LoadConnector): - def __init__( - self, - zip_path: str, - base_url: str, - batch_size: int = INDEX_BATCH_SIZE, - ): - self.zip_path = zip_path - self.base_url = base_url - self.batch_size = batch_size - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - pass - - def load_from_state(self) -> GenerateDocumentsOutput: - documents: list[Document] = [] - - with Session(get_sqlalchemy_engine()) as db_session: - file_content_io = get_default_file_store(db_session).read_file( - self.zip_path, mode="b" - ) - - # load the HTML files - files = load_files_from_zip(file_content_io) - count = 0 - for file_info, file_io, _metadata in files: - # skip non-published files - if "/PUBLISHED/" not in file_info.filename: - continue - - file_path, extension = os.path.splitext(file_info.filename) - if extension != ".html": - continue - - file_content, _ = read_text_file(file_io) - soup = BeautifulSoup(file_content, "html.parser") - - # get the link out of the navbar - header = cast(Tag, soup.find("header")) - nav = cast(Tag, header.find("nav")) - path = find_google_sites_page_path_from_navbar(nav, "", 1) - if not path: - count += 1 - logger.error( - f"Could not find path for '{file_info.filename}'. " - + "This page will not have a working link.\n\n" - + f"# of broken links so far - {count}" - ) - logger.info(f"Path to page: {path}") - # cleanup the hidden `Skip to main content` and `Skip to navigation` that - # appears at the top of every page - for div in soup.find_all("div", attrs={"data-is-touch-wrapper": "true"}): - div.extract() - - # get the body of the page - parsed_html = web_html_cleanup( - soup, additional_element_types_to_discard=["header", "nav"] - ) - - title = parsed_html.title or file_path.split("/")[-1] - documents.append( - Document( - id=f"{DocumentSource.GOOGLE_SITES.value}:{path}", - source=DocumentSource.GOOGLE_SITES, - semantic_identifier=title, - sections=[ - Section( - link=(self.base_url.rstrip("/") + "/" + path.lstrip("/")) - if path - else "", - text=parsed_html.cleaned_text, - ) - ], - metadata={}, - ) - ) - - if len(documents) >= self.batch_size: - yield documents - documents = [] - - if documents: - yield documents - - -if __name__ == "__main__": - connector = GoogleSitesConnector( - os.environ["GOOGLE_SITES_ZIP_PATH"], - os.environ.get("GOOGLE_SITES_BASE_URL", ""), - ) - for doc_batch in connector.load_from_state(): - for doc in doc_batch: - print(doc) diff --git a/backend/danswer/connectors/guru/__init__.py b/backend/danswer/connectors/guru/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/guru/connector.py b/backend/danswer/connectors/guru/connector.py deleted file mode 100644 index a27546425d3..00000000000 --- a/backend/danswer/connectors/guru/connector.py +++ /dev/null @@ -1,167 +0,0 @@ -import json -from datetime import datetime -from datetime import timezone -from typing import Any - -import requests - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.utils.logger import setup_logger - -# Potential Improvements -# 1. Support fetching per collection via collection token (configured at connector creation) - -GURU_API_BASE = "https://api.getguru.com/api/v1/" -GURU_QUERY_ENDPOINT = GURU_API_BASE + "search/query" -GURU_CARDS_URL = "https://app.getguru.com/card/" -logger = setup_logger() - - -def unixtime_to_guru_time_str(unix_time: SecondsSinceUnixEpoch) -> str: - date_obj = datetime.fromtimestamp(unix_time, tz=timezone.utc) - date_str = date_obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - tz_str = date_obj.strftime("%z") - return date_str + tz_str - - -class GuruConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - guru_user: str | None = None, - guru_user_token: str | None = None, - ) -> None: - self.batch_size = batch_size - self.guru_user = guru_user - self.guru_user_token = guru_user_token - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.guru_user = credentials["guru_user"] - self.guru_user_token = credentials["guru_user_token"] - return None - - def _process_cards( - self, start_str: str | None = None, end_str: str | None = None - ) -> GenerateDocumentsOutput: - if self.guru_user is None or self.guru_user_token is None: - raise ConnectorMissingCredentialError("Guru") - - doc_batch: list[Document] = [] - - session = requests.Session() - session.auth = (self.guru_user, self.guru_user_token) - - params: dict[str, str | int] = {"maxResults": self.batch_size} - - if start_str is not None and end_str is not None: - params["q"] = f"lastModified >= {start_str} AND lastModified < {end_str}" - - current_url = GURU_QUERY_ENDPOINT # This is how they handle pagination, a different url will be provided - while True: - response = session.get(current_url, params=params) - response.raise_for_status() - - if response.status_code == 204: - break - - cards = json.loads(response.text) - for card in cards: - title = card["preferredPhrase"] - link = GURU_CARDS_URL + card["slug"] - content_text = parse_html_page_basic(card["content"]) - last_updated = time_str_to_utc(card["lastModified"]) - last_verified = ( - time_str_to_utc(card.get("lastVerified")) - if card.get("lastVerified") - else None - ) - - # For Danswer, we decay document score overtime, either last_updated or - # last_verified is a good enough signal for the document's recency - latest_time = ( - max(last_verified, last_updated) if last_verified else last_updated - ) - - metadata_dict: dict[str, str | list[str]] = {} - tags = [tag.get("value") for tag in card.get("tags", [])] - if tags: - metadata_dict["tags"] = tags - - boards = [board.get("title") for board in card.get("boards", [])] - if boards: - # In UI it's called Folders - metadata_dict["folders"] = boards - - collection = card.get("collection", {}) - if collection: - metadata_dict["collection_name"] = collection.get("name", "") - - owner = card.get("owner", {}) - author = None - if owner: - author = BasicExpertInfo( - email=owner.get("email"), - first_name=owner.get("firstName"), - last_name=owner.get("lastName"), - ) - - doc_batch.append( - Document( - id=card["id"], - sections=[Section(link=link, text=content_text)], - source=DocumentSource.GURU, - semantic_identifier=title, - doc_updated_at=latest_time, - primary_owners=[author] if author is not None else None, - # Can add verifies and commenters later - metadata=metadata_dict, - ) - ) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if not hasattr(response, "links") or not response.links: - break - current_url = response.links["next-page"]["url"] - - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._process_cards() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_time = unixtime_to_guru_time_str(start) - end_time = unixtime_to_guru_time_str(end) - - return self._process_cards(start_time, end_time) - - -if __name__ == "__main__": - import os - - connector = GuruConnector() - connector.load_credentials( - { - "guru_user": os.environ["GURU_USER"], - "guru_user_token": os.environ["GURU_USER_TOKEN"], - } - ) - - latest_docs = connector.load_from_state() - print(next(latest_docs)) diff --git a/backend/danswer/connectors/hubspot/__init__.py b/backend/danswer/connectors/hubspot/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/hubspot/connector.py b/backend/danswer/connectors/hubspot/connector.py deleted file mode 100644 index bd13c1e75b7..00000000000 --- a/backend/danswer/connectors/hubspot/connector.py +++ /dev/null @@ -1,145 +0,0 @@ -from datetime import datetime -from datetime import timezone -from typing import Any - -import requests -from hubspot import HubSpot # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -HUBSPOT_BASE_URL = "https://app.hubspot.com/contacts/" -HUBSPOT_API_URL = "https://api.hubapi.com/integrations/v1/me" - -logger = setup_logger() - - -class HubSpotConnector(LoadConnector, PollConnector): - def __init__( - self, batch_size: int = INDEX_BATCH_SIZE, access_token: str | None = None - ) -> None: - self.batch_size = batch_size - self.access_token = access_token - self.portal_id: str | None = None - self.ticket_base_url = HUBSPOT_BASE_URL - - def get_portal_id(self) -> str: - headers = { - "Authorization": f"Bearer {self.access_token}", - "Content-Type": "application/json", - } - - response = requests.get(HUBSPOT_API_URL, headers=headers) - if response.status_code != 200: - raise Exception("Error fetching portal ID") - - data = response.json() - return data["portalId"] - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.access_token = credentials["hubspot_access_token"] - - if self.access_token: - self.portal_id = self.get_portal_id() - self.ticket_base_url = f"{HUBSPOT_BASE_URL}{self.portal_id}/ticket/" - - return None - - def _process_tickets( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.access_token is None: - raise ConnectorMissingCredentialError("HubSpot") - - api_client = HubSpot(access_token=self.access_token) - all_tickets = api_client.crm.tickets.get_all(associations=["contacts", "notes"]) - - doc_batch: list[Document] = [] - - for ticket in all_tickets: - updated_at = ticket.updated_at.replace(tzinfo=None) - if start is not None and updated_at < start: - continue - if end is not None and updated_at > end: - continue - - title = ticket.properties["subject"] - link = self.ticket_base_url + ticket.id - content_text = ticket.properties["content"] - - associated_emails: list[str] = [] - associated_notes: list[str] = [] - - if ticket.associations: - contacts = ticket.associations.get("contacts") - notes = ticket.associations.get("notes") - - if contacts: - for contact in contacts.results: - contact = api_client.crm.contacts.basic_api.get_by_id( - contact_id=contact.id - ) - associated_emails.append(contact.properties["email"]) - - if notes: - for note in notes.results: - note = api_client.crm.objects.notes.basic_api.get_by_id( - note_id=note.id, properties=["content", "hs_body_preview"] - ) - if note.properties["hs_body_preview"] is None: - continue - associated_notes.append(note.properties["hs_body_preview"]) - - associated_emails_str = " ,".join(associated_emails) - associated_notes_str = " ".join(associated_notes) - - content_text = f"{content_text}\n emails: {associated_emails_str} \n notes: {associated_notes_str}" - - doc_batch.append( - Document( - id=ticket.id, - sections=[Section(link=link, text=content_text)], - source=DocumentSource.HUBSPOT, - semantic_identifier=title, - # Is already in tzutc, just replacing the timezone format - doc_updated_at=ticket.updated_at.replace(tzinfo=timezone.utc), - metadata={}, - ) - ) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._process_tickets() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - return self._process_tickets(start_datetime, end_datetime) - - -if __name__ == "__main__": - import os - - connector = HubSpotConnector() - connector.load_credentials( - {"hubspot_access_token": os.environ["HUBSPOT_ACCESS_TOKEN"]} - ) - - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/interfaces.py b/backend/danswer/connectors/interfaces.py deleted file mode 100644 index 3bd99792cce..00000000000 --- a/backend/danswer/connectors/interfaces.py +++ /dev/null @@ -1,63 +0,0 @@ -import abc -from collections.abc import Iterator -from typing import Any - -from danswer.connectors.models import Document - - -SecondsSinceUnixEpoch = float - -GenerateDocumentsOutput = Iterator[list[Document]] - - -class BaseConnector(abc.ABC): - @abc.abstractmethod - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - raise NotImplementedError - - @staticmethod - def parse_metadata(metadata: dict[str, Any]) -> list[str]: - """Parse the metadata for a document/chunk into a string to pass to Generative AI as additional context""" - custom_parser_req_msg = ( - "Specific metadata parsing required, connector has not implemented it." - ) - metadata_lines = [] - for metadata_key, metadata_value in metadata.items(): - if isinstance(metadata_value, str): - metadata_lines.append(f"{metadata_key}: {metadata_value}") - elif isinstance(metadata_value, list): - if not all([isinstance(val, str) for val in metadata_value]): - raise RuntimeError(custom_parser_req_msg) - metadata_lines.append(f'{metadata_key}: {", ".join(metadata_value)}') - else: - raise RuntimeError(custom_parser_req_msg) - return metadata_lines - - -# Large set update or reindex, generally pulling a complete state or from a savestate file -class LoadConnector(BaseConnector): - @abc.abstractmethod - def load_from_state(self) -> GenerateDocumentsOutput: - raise NotImplementedError - - -# Small set updates by time -class PollConnector(BaseConnector): - @abc.abstractmethod - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - raise NotImplementedError - - -class IdConnector(BaseConnector): - @abc.abstractmethod - def retrieve_all_source_ids(self) -> set[str]: - raise NotImplementedError - - -# Event driven -class EventConnector(BaseConnector): - @abc.abstractmethod - def handle_event(self, event: Any) -> GenerateDocumentsOutput: - raise NotImplementedError diff --git a/backend/danswer/connectors/linear/__init__.py b/backend/danswer/connectors/linear/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/linear/connector.py b/backend/danswer/connectors/linear/connector.py deleted file mode 100644 index 8455b20f5ba..00000000000 --- a/backend/danswer/connectors/linear/connector.py +++ /dev/null @@ -1,218 +0,0 @@ -import os -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import cast - -import requests - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -_NUM_RETRIES = 5 -_TIMEOUT = 60 -_LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" - - -def _make_query(request_body: dict[str, Any], api_key: str) -> requests.Response: - headers = { - "Authorization": api_key, - "Content-Type": "application/json", - } - - for i in range(_NUM_RETRIES): - try: - response = requests.post( - _LINEAR_GRAPHQL_URL, - headers=headers, - json=request_body, - timeout=_TIMEOUT, - ) - if not response.ok: - raise RuntimeError( - f"Error fetching issues from Linear: {response.text}" - ) - - return response - except Exception as e: - if i == _NUM_RETRIES - 1: - raise e - - logger.warning(f"A Linear GraphQL error occurred: {e}. Retrying...") - - raise RuntimeError( - "Unexpected execution when querying Linear. This should never happen." - ) - - -class LinearConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.batch_size = batch_size - self.linear_api_key: str | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.linear_api_key = cast(str, credentials["linear_api_key"]) - return None - - def _process_issues( - self, start_str: datetime | None = None, end_str: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.linear_api_key is None: - raise ConnectorMissingCredentialError("Linear") - - lte_filter = f'lte: "{end_str}"' if end_str else "" - gte_filter = f'gte: "{start_str}"' if start_str else "" - updatedAtFilter = f""" - {lte_filter} - {gte_filter} - """ - - query = ( - """ - query IterateIssueBatches($first: Int, $after: String) { - issues( - orderBy: updatedAt, - first: $first, - after: $after, - filter: { - updatedAt: { - """ - + updatedAtFilter - + """ - }, - - } - ) { - edges { - node { - id - createdAt - updatedAt - archivedAt - number - title - priority - estimate - sortOrder - startedAt - completedAt - startedTriageAt - triagedAt - canceledAt - autoClosedAt - autoArchivedAt - dueDate - slaStartedAt - slaBreachesAt - trashed - snoozedUntilAt - team { - name - } - previousIdentifiers - subIssueSortOrder - priorityLabel - identifier - url - branchName - customerTicketCount - description - descriptionData - comments { - nodes { - url - body - } - } - } - } - pageInfo { - hasNextPage - endCursor - } - } - } - """ - ) - - has_more = True - endCursor = None - while has_more: - graphql_query = { - "query": query, - "variables": { - "first": self.batch_size, - "after": endCursor, - }, - } - logger.debug(f"Requesting issues from Linear with query: {graphql_query}") - - response = _make_query(graphql_query, self.linear_api_key) - response_json = response.json() - logger.debug(f"Raw response from Linear: {response_json}") - edges = response_json["data"]["issues"]["edges"] - - documents: list[Document] = [] - for edge in edges: - node = edge["node"] - documents.append( - Document( - id=node["id"], - sections=[ - Section( - link=node["url"], - text=node["description"] or "", - ) - ] - + [ - Section( - link=node["url"], - text=comment["body"] or "", - ) - for comment in node["comments"]["nodes"] - ], - source=DocumentSource.LINEAR, - semantic_identifier=f"[{node['identifier']}] {node['title']}", - title=node["title"], - doc_updated_at=time_str_to_utc(node["updatedAt"]), - metadata={ - "team": node["team"]["name"], - }, - ) - ) - yield documents - - endCursor = response_json["data"]["issues"]["pageInfo"]["endCursor"] - has_more = response_json["data"]["issues"]["pageInfo"]["hasNextPage"] - - def load_from_state(self) -> GenerateDocumentsOutput: - yield from self._process_issues() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_time = datetime.fromtimestamp(start, tz=timezone.utc) - end_time = datetime.fromtimestamp(end, tz=timezone.utc) - - yield from self._process_issues(start_str=start_time, end_str=end_time) - - -if __name__ == "__main__": - connector = LinearConnector() - connector.load_credentials({"linear_api_key": os.environ["LINEAR_API_KEY"]}) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/loopio/__init__.py b/backend/danswer/connectors/loopio/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/loopio/connector.py b/backend/danswer/connectors/loopio/connector.py deleted file mode 100644 index e10bed87617..00000000000 --- a/backend/danswer/connectors/loopio/connector.py +++ /dev/null @@ -1,216 +0,0 @@ -import json -from collections.abc import Generator -from datetime import datetime -from datetime import timezone -from typing import Any - -from oauthlib.oauth2 import BackendApplicationClient -from requests_oauthlib import OAuth2Session # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.file_processing.html_utils import strip_excessive_newlines_and_spaces -from danswer.utils.logger import setup_logger - -LOOPIO_API_BASE = "https://api.loopio.com/" -LOOPIO_AUTH_URL = LOOPIO_API_BASE + "oauth2/access_token" -LOOPIO_DATA_URL = LOOPIO_API_BASE + "data/" - -logger = setup_logger() - - -class LoopioConnector(LoadConnector, PollConnector): - def __init__( - self, - loopio_stack_name: str | None = None, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.batch_size = batch_size - self.loopio_client_id: str | None = None - self.loopio_client_token: str | None = None - self.loopio_stack_name = loopio_stack_name - - def _fetch_data( - self, resource: str, params: dict[str, str | int] - ) -> Generator[dict[str, Any], None, None]: - client = BackendApplicationClient( - client_id=self.loopio_client_id, scope=["library:read"] - ) - session = OAuth2Session(client=client) - session.fetch_token( - token_url=LOOPIO_AUTH_URL, - client_id=self.loopio_client_id, - client_secret=self.loopio_client_token, - ) - page = 0 - stop_at_page = 1 - while (page := page + 1) <= stop_at_page: - params["page"] = page - response = session.request( - "GET", - LOOPIO_DATA_URL + resource, - headers={"Accept": "application/json"}, - params=params, - ) - if response.status_code == 400: - logger.error( - f"Loopio API returned 400 for {resource} with params {params}", - ) - logger.error(response.text) - response.raise_for_status() - response_data = json.loads(response.text) - stop_at_page = response_data.get("totalPages", 1) - yield response_data - - def _build_search_filter( - self, stack_name: str | None, start: str | None, end: str | None - ) -> dict[str, Any]: - filter: dict[str, Any] = {} - if start is not None and end is not None: - filter["lastUpdatedDate"] = {"gte": start, "lt": end} - - if stack_name is not None: - # Right now this is fetching the stacks every time, which is not ideal. - # We should update this later to store the ID when we create the Connector - for stack in self._fetch_data(resource="v2/stacks", params={}): - for item in stack["items"]: - if item["name"] == stack_name: - filter["locations"] = [{"stackID": item["id"]}] - break - if "locations" not in filter: - raise ValueError(f"Stack {stack_name} not found in Loopio") - return filter - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.loopio_subdomain = credentials["loopio_subdomain"] - self.loopio_client_id = credentials["loopio_client_id"] - self.loopio_client_token = credentials["loopio_client_token"] - return None - - def _process_entries( - self, start: str | None = None, end: str | None = None - ) -> GenerateDocumentsOutput: - if self.loopio_client_id is None or self.loopio_client_token is None: - raise ConnectorMissingCredentialError("Loopio") - - filter = self._build_search_filter( - stack_name=self.loopio_stack_name, start=start, end=end - ) - params: dict[str, str | int] = {"pageSize": self.batch_size} - params["filter"] = json.dumps(filter) - - doc_batch: list[Document] = [] - for library_entries in self._fetch_data( - resource="v2/libraryEntries", params=params - ): - for entry in library_entries.get("items", []): - link = f"https://{self.loopio_subdomain}.loopio.com/library?entry={entry['id']}" - topic = "/".join( - part["name"] for part in entry["location"].values() if part - ) - - answer = parse_html_page_basic(entry.get("answer", {}).get("text", "")) - questions = [ - question.get("text").replace("\xa0", " ") - for question in entry["questions"] - if question.get("text") - ] - questions_string = strip_excessive_newlines_and_spaces( - "\n".join(questions) - ) - content_text = f"{answer}\n\nRelated Questions: {questions_string}" - content_text = strip_excessive_newlines_and_spaces( - content_text.replace("\xa0", " ") - ) - - last_updated = time_str_to_utc(entry["lastUpdatedDate"]) - last_reviewed = ( - time_str_to_utc(entry["lastReviewedDate"]) - if entry.get("lastReviewedDate") - else None - ) - - # For Danswer, we decay document score overtime, either last_updated or - # last_reviewed is a good enough signal for the document's recency - latest_time = ( - max(last_reviewed, last_updated) if last_reviewed else last_updated - ) - creator = entry.get("creator") - last_updated_by = entry.get("lastUpdatedBy") - last_reviewed_by = entry.get("lastReviewedBy") - - primary_owners: list[BasicExpertInfo] = [ - BasicExpertInfo(display_name=owner.get("name")) - for owner in [creator, last_updated_by] - if owner is not None - ] - secondary_owners: list[BasicExpertInfo] = [ - BasicExpertInfo(display_name=owner.get("name")) - for owner in [last_reviewed_by] - if owner is not None - ] - doc_batch.append( - Document( - id=entry["id"], - sections=[Section(link=link, text=content_text)], - source=DocumentSource.LOOPIO, - semantic_identifier=questions[0], - doc_updated_at=latest_time, - primary_owners=primary_owners, - secondary_owners=secondary_owners, - metadata={ - "topic": topic, - "questions": "\n".join(questions), - "creator": creator.get("name") if creator else "", - }, - ) - ) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - if len(doc_batch) > 0: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._process_entries() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_time = datetime.fromtimestamp(start, tz=timezone.utc).isoformat( - timespec="seconds" - ) - end_time = datetime.fromtimestamp(end, tz=timezone.utc).isoformat( - timespec="seconds" - ) - - return self._process_entries(start_time, end_time) - - -if __name__ == "__main__": - import os - - connector = LoopioConnector( - loopio_stack_name=os.environ.get("LOOPIO_STACK_NAME", None) - ) - connector.load_credentials( - { - "loopio_client_id": os.environ["LOOPIO_CLIENT_ID"], - "loopio_client_token": os.environ["LOOPIO_CLIENT_TOKEN"], - "loopio_subdomain": os.environ["LOOPIO_SUBDOMAIN"], - } - ) - - latest_docs = connector.load_from_state() - print(next(latest_docs)) diff --git a/backend/danswer/connectors/mediawiki/__init__.py b/backend/danswer/connectors/mediawiki/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/mediawiki/family.py b/backend/danswer/connectors/mediawiki/family.py deleted file mode 100644 index 0d953066700..00000000000 --- a/backend/danswer/connectors/mediawiki/family.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import builtins -import functools -import itertools -from typing import Any -from unittest import mock -from urllib.parse import urlparse -from urllib.parse import urlunparse - -from pywikibot import family # type: ignore[import-untyped] -from pywikibot import pagegenerators # type: ignore[import-untyped] -from pywikibot.scripts import generate_family_file # type: ignore[import-untyped] -from pywikibot.scripts.generate_user_files import pywikibot # type: ignore[import-untyped] - -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -@mock.patch.object( - builtins, "print", lambda *args: logger.info("\t".join(map(str, args))) -) -class FamilyFileGeneratorInMemory(generate_family_file.FamilyFileGenerator): - """A subclass of FamilyFileGenerator that writes the family file to memory instead of to disk.""" - - def __init__( - self, - url: str, - name: str, - dointerwiki: str | bool = True, - verify: str | bool = True, - ): - """Initialize the FamilyFileGeneratorInMemory.""" - - url_parse = urlparse(url, "https") - if not url_parse.netloc and url_parse.path: - url = urlunparse( - (url_parse.scheme, url_parse.path, url_parse.netloc, *url_parse[3:]) - ) - else: - url = urlunparse(url_parse) - assert isinstance(url, str) - - if any(x not in generate_family_file.NAME_CHARACTERS for x in name): - raise ValueError( - 'ERROR: Name of family "{}" must be ASCII letters and digits [a-zA-Z0-9]', - name, - ) - - if isinstance(dointerwiki, bool): - dointerwiki = "Y" if dointerwiki else "N" - assert isinstance(dointerwiki, str) - - if isinstance(verify, bool): - verify = "Y" if verify else "N" - assert isinstance(verify, str) - - super().__init__(url, name, dointerwiki, verify) - self.family_definition: type[family.Family] | None = None - - def get_params(self) -> bool: - """Get the parameters for the family class definition. - - This override prevents the method from prompting the user for input (which would be impossible in this context). - We do all the input validation in the constructor. - """ - return True - - def writefile(self, verify: Any) -> None: - """Write the family file. - - This overrides the method in the parent class to write the family definition to memory instead of to disk. - - Args: - verify: unused argument necessary to match the signature of the method in the parent class. - """ - code_hostname_pairs = { - f"{k}": f"{urlparse(w.server).netloc}" for k, w in self.wikis.items() - } - - code_path_pairs = {f"{k}": f"{w.scriptpath}" for k, w in self.wikis.items()} - - code_protocol_pairs = { - f"{k}": f"{urlparse(w.server).scheme}" for k, w in self.wikis.items() - } - - class Family(family.Family): # noqa: D101 - """The family definition for the wiki.""" - - name = "%(name)s" - langs = code_hostname_pairs - - def scriptpath(self, code: str) -> str: - return code_path_pairs[code] - - def protocol(self, code: str) -> str: - return code_protocol_pairs[code] - - self.family_definition = Family - - -@functools.lru_cache(maxsize=None) -def generate_family_class(url: str, name: str) -> type[family.Family]: - """Generate a family file for a given URL and name. - - Args: - url: The URL of the wiki. - name: The short name of the wiki (customizable by the user). - - Returns: - The family definition. - - Raises: - ValueError: If the family definition was not generated. - """ - - generator = FamilyFileGeneratorInMemory(url, name, "Y", "Y") - generator.run() - if generator.family_definition is None: - raise ValueError("Family definition was not generated.") - return generator.family_definition - - -def family_class_dispatch(url: str, name: str) -> type[family.Family]: - """Find or generate a family class for a given URL and name. - - Args: - url: The URL of the wiki. - name: The short name of the wiki (customizable by the user). - - """ - if "wikipedia" in url: - import pywikibot.families.wikipedia_family # type: ignore[import-untyped] - - return pywikibot.families.wikipedia_family.Family - # TODO: Support additional families pre-defined in `pywikibot.families.*_family.py` files - return generate_family_class(url, name) - - -if __name__ == "__main__": - url = "fallout.fandom.com/wiki/Fallout_Wiki" - name = "falloutfandom" - - categories: list[str] = [] - pages = ["Fallout: New Vegas"] - recursion_depth = 1 - family_type = generate_family_class(url, name) - - site = pywikibot.Site(fam=family_type(), code="en") - categories = [ - pywikibot.Category(site, f"Category:{category.replace(' ', '_')}") - for category in categories - ] - pages = [pywikibot.Page(site, page) for page in pages] - all_pages = itertools.chain( - pages, - *[ - pagegenerators.CategorizedPageGenerator(category, recurse=recursion_depth) - for category in categories - ], - ) - for page in all_pages: - print(page.title()) - print(page.text[:1000]) diff --git a/backend/danswer/connectors/mediawiki/wiki.py b/backend/danswer/connectors/mediawiki/wiki.py deleted file mode 100644 index f4ec1e02311..00000000000 --- a/backend/danswer/connectors/mediawiki/wiki.py +++ /dev/null @@ -1,220 +0,0 @@ -from __future__ import annotations - -import datetime -import itertools -from collections.abc import Generator -from typing import Any -from typing import ClassVar - -import pywikibot.time # type: ignore[import-untyped] -from pywikibot import pagegenerators # type: ignore[import-untyped] -from pywikibot import textlib # type: ignore[import-untyped] - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.mediawiki.family import family_class_dispatch -from danswer.connectors.models import Document -from danswer.connectors.models import Section - - -def pywikibot_timestamp_to_utc_datetime( - timestamp: pywikibot.time.Timestamp, -) -> datetime.datetime: - """Convert a pywikibot timestamp to a datetime object in UTC. - - Args: - timestamp: The pywikibot timestamp to convert. - - Returns: - A datetime object in UTC. - """ - return datetime.datetime.astimezone(timestamp, tz=datetime.timezone.utc) - - -def get_doc_from_page( - page: pywikibot.Page, site: pywikibot.Site | None, source_type: DocumentSource -) -> Document: - """Generate Danswer Document from a MediaWiki page object. - - Args: - page: Page from a MediaWiki site. - site: MediaWiki site (used to parse the sections of the page using the site template, if available). - source_type: Source of the document. - - Returns: - Generated document. - """ - page_text = page.text - sections_extracted: textlib.Content = textlib.extract_sections(page_text, site) - - sections = [ - Section( - link=f"{page.full_url()}#" + section.heading.replace(" ", "_"), - text=section.title + section.content, - ) - for section in sections_extracted.sections - ] - sections.append( - Section( - link=page.full_url(), - text=sections_extracted.header, - ) - ) - - return Document( - source=source_type, - title=page.title(), - doc_updated_at=pywikibot_timestamp_to_utc_datetime( - page.latest_revision.timestamp - ), - sections=sections, - semantic_identifier=page.title(), - metadata={"categories": [category.title() for category in page.categories()]}, - id=page.pageid, - ) - - -class MediaWikiConnector(LoadConnector, PollConnector): - """A connector for MediaWiki wikis. - - Args: - hostname: The hostname of the wiki. - categories: The categories to include in the index. - pages: The pages to include in the index. - recurse_depth: The depth to recurse into categories. -1 means unbounded recursion. - language_code: The language code of the wiki. - batch_size: The batch size for loading documents. - - Raises: - ValueError: If `recurse_depth` is not an integer greater than or equal to -1. - """ - - document_source_type: ClassVar[DocumentSource] = DocumentSource.MEDIAWIKI - """DocumentSource type for all documents generated by instances of this class. Can be overridden for connectors - tailored for specific sites.""" - - def __init__( - self, - hostname: str, - categories: list[str], - pages: list[str], - recurse_depth: int, - language_code: str = "en", - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - if recurse_depth < -1: - raise ValueError( - f"recurse_depth must be an integer greater than or equal to -1. Got {recurse_depth} instead." - ) - # -1 means infinite recursion, which `pywikibot` will only do with `True` - self.recurse_depth: bool | int = True if recurse_depth == -1 else recurse_depth - - self.batch_size = batch_size - - # short names can only have ascii letters and digits - - self.family = family_class_dispatch(hostname, "Wikipedia Connector")() - self.site = pywikibot.Site(fam=self.family, code=language_code) - self.categories = [ - pywikibot.Category(self.site, f"Category:{category.replace(' ', '_')}") - for category in categories - ] - self.pages = [pywikibot.Page(self.site, page) for page in pages] - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - """Load credentials for a MediaWiki site. - - Note: - For most read-only operations, MediaWiki API credentials are not necessary. - This method can be overridden in the event that a particular MediaWiki site - requires credentials. - """ - return None - - def _get_doc_batch( - self, - start: SecondsSinceUnixEpoch | None = None, - end: SecondsSinceUnixEpoch | None = None, - ) -> Generator[list[Document], None, None]: - """Request batches of pages from a MediaWiki site. - - Args: - start: The beginning of the time period of pages to request. - end: The end of the time period of pages to request. - - Yields: - Lists of Documents containing each parsed page in a batch. - """ - doc_batch: list[Document] = [] - - # Pywikibot can handle batching for us, including only loading page contents when we finally request them. - category_pages = [ - pagegenerators.PreloadingGenerator( - pagegenerators.EdittimeFilterPageGenerator( - pagegenerators.CategorizedPageGenerator( - category, recurse=self.recurse_depth - ), - last_edit_start=datetime.datetime.fromtimestamp(start) - if start - else None, - last_edit_end=datetime.datetime.fromtimestamp(end) if end else None, - ), - groupsize=self.batch_size, - ) - for category in self.categories - ] - - # Since we can specify both individual pages and categories, we need to iterate over all of them. - all_pages = itertools.chain(self.pages, *category_pages) - for page in all_pages: - doc_batch.append( - get_doc_from_page(page, self.site, self.document_source_type) - ) - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - """Load all documents from the source. - - Returns: - A generator of documents. - """ - return self.poll_source(None, None) - - def poll_source( - self, start: SecondsSinceUnixEpoch | None, end: SecondsSinceUnixEpoch | None - ) -> GenerateDocumentsOutput: - """Poll the source for new documents. - - Args: - start: The start of the time range to poll. - end: The end of the time range to poll. - - Returns: - A generator of documents. - """ - return self._get_doc_batch(start, end) - - -if __name__ == "__main__": - HOSTNAME = "fallout.fandom.com" - test_connector = MediaWikiConnector( - hostname=HOSTNAME, - categories=["Fallout:_New_Vegas_factions"], - pages=["Fallout: New Vegas"], - recurse_depth=1, - ) - - all_docs = list(test_connector.load_from_state()) - print("All docs", all_docs) - current = datetime.datetime.now().timestamp() - one_day_ago = current - 30 * 24 * 60 * 60 # 30 days - latest_docs = list(test_connector.poll_source(one_day_ago, current)) - print("Latest docs", latest_docs) diff --git a/backend/danswer/connectors/models.py b/backend/danswer/connectors/models.py deleted file mode 100644 index 192aa1b206a..00000000000 --- a/backend/danswer/connectors/models.py +++ /dev/null @@ -1,201 +0,0 @@ -from datetime import datetime -from enum import Enum -from typing import Any - -from pydantic import BaseModel - -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import INDEX_SEPARATOR -from danswer.configs.constants import RETURN_SEPARATOR -from danswer.utils.text_processing import make_url_compatible - - -class InputType(str, Enum): - LOAD_STATE = "load_state" # e.g. loading a current full state or a save state, such as from a file - POLL = "poll" # e.g. calling an API to get all documents in the last hour - EVENT = "event" # e.g. registered an endpoint as a listener, and processing connector events - PRUNE = "prune" - - -class ConnectorMissingCredentialError(PermissionError): - def __init__(self, connector_name: str) -> None: - connector_name = connector_name or "Unknown" - super().__init__( - f"{connector_name} connector missing credentials, was load_credentials called?" - ) - - -class Section(BaseModel): - text: str - link: str | None - - -class BasicExpertInfo(BaseModel): - """Basic Information for the owner of a document, any of the fields can be left as None - Display fallback goes as follows: - - first_name + (optional middle_initial) + last_name - - display_name - - email - - first_name - """ - - display_name: str | None = None - first_name: str | None = None - middle_initial: str | None = None - last_name: str | None = None - email: str | None = None - - def get_semantic_name(self) -> str: - if self.first_name and self.last_name: - name_parts = [self.first_name] - if self.middle_initial: - name_parts.append(self.middle_initial + ".") - name_parts.append(self.last_name) - return " ".join([name_part.capitalize() for name_part in name_parts]) - - if self.display_name: - return self.display_name - - if self.email: - return self.email - - if self.first_name: - return self.first_name.capitalize() - - return "Unknown" - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, BasicExpertInfo): - return False - return ( - self.display_name, - self.first_name, - self.middle_initial, - self.last_name, - self.email, - ) == ( - other.display_name, - other.first_name, - other.middle_initial, - other.last_name, - other.email, - ) - - def __hash__(self) -> int: - return hash( - ( - self.display_name, - self.first_name, - self.middle_initial, - self.last_name, - self.email, - ) - ) - - -class DocumentBase(BaseModel): - """Used for Danswer ingestion api, the ID is inferred before use if not provided""" - - id: str | None = None - sections: list[Section] - source: DocumentSource | None = None - semantic_identifier: str # displayed in the UI as the main identifier for the doc - metadata: dict[str, str | list[str]] - # UTC time - doc_updated_at: datetime | None = None - # Owner, creator, etc. - primary_owners: list[BasicExpertInfo] | None = None - # Assignee, space owner, etc. - secondary_owners: list[BasicExpertInfo] | None = None - # title is used for search whereas semantic_identifier is used for displaying in the UI - # different because Slack message may display as #general but general should not be part - # of the search, at least not in the same way as a document title should be for like Confluence - # The default title is semantic_identifier though unless otherwise specified - title: str | None = None - from_ingestion_api: bool = False - - def get_title_for_document_index( - self, - ) -> str | None: - # If title is explicitly empty, return a None here for embedding purposes - if self.title == "": - return None - replace_chars = set(RETURN_SEPARATOR) - title = self.semantic_identifier if self.title is None else self.title - for char in replace_chars: - title = title.replace(char, " ") - title = title.strip() - return title - - def get_metadata_str_attributes(self) -> list[str] | None: - if not self.metadata: - return None - # Combined string for the key/value for easy filtering - attributes: list[str] = [] - for k, v in self.metadata.items(): - if isinstance(v, list): - attributes.extend([k + INDEX_SEPARATOR + vi for vi in v]) - else: - attributes.append(k + INDEX_SEPARATOR + v) - return attributes - - -class Document(DocumentBase): - id: str # This must be unique or during indexing/reindexing, chunks will be overwritten - source: DocumentSource - - def to_short_descriptor(self) -> str: - """Used when logging the identity of a document""" - return f"ID: '{self.id}'; Semantic ID: '{self.semantic_identifier}'" - - @classmethod - def from_base(cls, base: DocumentBase) -> "Document": - return cls( - id=make_url_compatible(base.id) - if base.id - else "ingestion_api_" + make_url_compatible(base.semantic_identifier), - sections=base.sections, - source=base.source or DocumentSource.INGESTION_API, - semantic_identifier=base.semantic_identifier, - metadata=base.metadata, - doc_updated_at=base.doc_updated_at, - primary_owners=base.primary_owners, - secondary_owners=base.secondary_owners, - title=base.title, - from_ingestion_api=base.from_ingestion_api, - ) - - -class DocumentErrorSummary(BaseModel): - id: str - semantic_id: str - section_link: str | None - - @classmethod - def from_document(cls, doc: Document) -> "DocumentErrorSummary": - section_link = doc.sections[0].link if len(doc.sections) > 0 else None - return cls( - id=doc.id, semantic_id=doc.semantic_identifier, section_link=section_link - ) - - @classmethod - def from_dict(cls, data: dict) -> "DocumentErrorSummary": - return cls( - id=str(data.get("id")), - semantic_id=str(data.get("semantic_id")), - section_link=str(data.get("section_link")), - ) - - def to_dict(self) -> dict[str, str | None]: - return { - "id": self.id, - "semantic_id": self.semantic_id, - "section_link": self.section_link, - } - - -class IndexAttemptMetadata(BaseModel): - batch_num: int | None = None - num_exceptions: int = 0 - connector_id: int - credential_id: int diff --git a/backend/danswer/connectors/notion/__init__.py b/backend/danswer/connectors/notion/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/notion/connector.py b/backend/danswer/connectors/notion/connector.py deleted file mode 100644 index fd607e4f97a..00000000000 --- a/backend/danswer/connectors/notion/connector.py +++ /dev/null @@ -1,465 +0,0 @@ -import time -from collections.abc import Generator -from dataclasses import dataclass -from dataclasses import fields -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import Optional - -from retry import retry - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.app_configs import NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rl_requests, -) -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.batching import batch_generator -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -_NOTION_CALL_TIMEOUT = 30 # 30 seconds - - -@dataclass -class NotionPage: - """Represents a Notion Page object""" - - id: str - created_time: str - last_edited_time: str - archived: bool - properties: dict[str, Any] - url: str - - def __init__(self, **kwargs: dict[str, Any]) -> None: - names = set([f.name for f in fields(self)]) - for k, v in kwargs.items(): - if k in names: - setattr(self, k, v) - - -@dataclass -class NotionSearchResponse: - """Represents the response from the Notion Search API""" - - results: list[dict[str, Any]] - next_cursor: Optional[str] - has_more: bool = False - - def __init__(self, **kwargs: dict[str, Any]) -> None: - names = set([f.name for f in fields(self)]) - for k, v in kwargs.items(): - if k in names: - setattr(self, k, v) - - -# TODO - Add the ability to optionally limit to specific Notion databases -class NotionConnector(LoadConnector, PollConnector): - """Notion Page connector that reads all Notion pages - this integration has been granted access to. - - Arguments: - batch_size (int): Number of objects to index in a batch - """ - - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - recursive_index_enabled: bool = NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP, - root_page_id: str | None = None, - ) -> None: - """Initialize with parameters.""" - self.batch_size = batch_size - self.headers = { - "Content-Type": "application/json", - "Notion-Version": "2022-06-28", - } - self.indexed_pages: set[str] = set() - self.root_page_id = root_page_id - # if enabled, will recursively index child pages as they are found rather - # relying entirely on the `search` API. We have received reports that the - # `search` API misses many pages - in those cases, this might need to be - # turned on. It's not currently known why/when this is required. - # NOTE: this also removes all benefits polling, since we need to traverse - # all pages regardless of if they are updated. If the notion workspace is - # very large, this may not be practical. - self.recursive_index_enabled = recursive_index_enabled or self.root_page_id - - @retry(tries=3, delay=1, backoff=2) - def _fetch_child_blocks( - self, block_id: str, cursor: str | None = None - ) -> dict[str, Any] | None: - """Fetch all child blocks via the Notion API.""" - logger.debug(f"Fetching children of block with ID '{block_id}'") - block_url = f"https://api.notion.com/v1/blocks/{block_id}/children" - query_params = None if not cursor else {"start_cursor": cursor} - res = rl_requests.get( - block_url, - headers=self.headers, - params=query_params, - timeout=_NOTION_CALL_TIMEOUT, - ) - try: - res.raise_for_status() - except Exception as e: - if res.status_code == 404: - # this happens when a page is not shared with the integration - # in this case, we should just ignore the page - logger.error( - f"Unable to access block with ID '{block_id}'. " - f"This is likely due to the block not being shared " - f"with the Danswer integration. Exact exception:\n\n{e}" - ) - return None - logger.exception(f"Error fetching blocks - {res.json()}") - raise e - return res.json() - - @retry(tries=3, delay=1, backoff=2) - def _fetch_page(self, page_id: str) -> NotionPage: - """Fetch a page from it's ID via the Notion API.""" - logger.debug(f"Fetching page for ID '{page_id}'") - block_url = f"https://api.notion.com/v1/pages/{page_id}" - res = rl_requests.get( - block_url, - headers=self.headers, - timeout=_NOTION_CALL_TIMEOUT, - ) - try: - res.raise_for_status() - except Exception as e: - logger.exception(f"Error fetching page - {res.json()}") - raise e - return NotionPage(**res.json()) - - @retry(tries=3, delay=1, backoff=2) - def _fetch_database( - self, database_id: str, cursor: str | None = None - ) -> dict[str, Any]: - """Fetch a database from it's ID via the Notion API.""" - logger.debug(f"Fetching database for ID '{database_id}'") - block_url = f"https://api.notion.com/v1/databases/{database_id}/query" - body = None if not cursor else {"start_cursor": cursor} - res = rl_requests.post( - block_url, - headers=self.headers, - json=body, - timeout=_NOTION_CALL_TIMEOUT, - ) - try: - res.raise_for_status() - except Exception as e: - if res.json().get("code") == "object_not_found": - # this happens when a database is not shared with the integration - # in this case, we should just ignore the database - logger.error( - f"Unable to access database with ID '{database_id}'. " - f"This is likely due to the database not being shared " - f"with the Danswer integration. Exact exception:\n{e}" - ) - return {"results": [], "next_cursor": None} - logger.exception(f"Error fetching database - {res.json()}") - raise e - return res.json() - - def _read_pages_from_database(self, database_id: str) -> list[str]: - """Returns a list of all page IDs in the database""" - result_pages: list[str] = [] - cursor = None - while True: - data = self._fetch_database(database_id, cursor) - - for result in data["results"]: - obj_id = result["id"] - obj_type = result["object"] - if obj_type == "page": - logger.debug( - f"Found page with ID '{obj_id}' in database '{database_id}'" - ) - result_pages.append(result["id"]) - elif obj_type == "database": - logger.debug( - f"Found database with ID '{obj_id}' in database '{database_id}'" - ) - result_pages.extend(self._read_pages_from_database(obj_id)) - - if data["next_cursor"] is None: - break - - cursor = data["next_cursor"] - - return result_pages - - def _read_blocks( - self, base_block_id: str - ) -> tuple[list[tuple[str, str]], list[str]]: - """Reads all child blocks for the specified block""" - result_lines: list[tuple[str, str]] = [] - child_pages: list[str] = [] - cursor = None - while True: - data = self._fetch_child_blocks(base_block_id, cursor) - - # this happens when a block is not shared with the integration - if data is None: - return result_lines, child_pages - - for result in data["results"]: - logger.debug( - f"Found child block for block with ID '{base_block_id}': {result}" - ) - result_block_id = result["id"] - result_type = result["type"] - result_obj = result[result_type] - - if result_type == "ai_block": - logger.warning( - f"Skipping 'ai_block' ('{result_block_id}') for base block '{base_block_id}': " - f"Notion API does not currently support reading AI blocks (as of 24/02/09) " - f"(discussion: https://github.com/danswer-ai/danswer/issues/1053)" - ) - continue - - if result_type == "unsupported": - logger.warning( - f"Skipping unsupported block type '{result_type}' " - f"('{result_block_id}') for base block '{base_block_id}': " - f"(discussion: https://github.com/danswer-ai/danswer/issues/1230)" - ) - continue - - cur_result_text_arr = [] - if "rich_text" in result_obj: - for rich_text in result_obj["rich_text"]: - # skip if doesn't have text object - if "text" in rich_text: - text = rich_text["text"]["content"] - cur_result_text_arr.append(text) - - if result["has_children"]: - if result_type == "child_page": - child_pages.append(result_block_id) - else: - logger.debug(f"Entering sub-block: {result_block_id}") - subblock_result_lines, subblock_child_pages = self._read_blocks( - result_block_id - ) - logger.debug(f"Finished sub-block: {result_block_id}") - result_lines.extend(subblock_result_lines) - child_pages.extend(subblock_child_pages) - - if result_type == "child_database" and self.recursive_index_enabled: - child_pages.extend(self._read_pages_from_database(result_block_id)) - - cur_result_text = "\n".join(cur_result_text_arr) - if cur_result_text: - result_lines.append((cur_result_text, result_block_id)) - - if data["next_cursor"] is None: - break - - cursor = data["next_cursor"] - - return result_lines, child_pages - - def _read_page_title(self, page: NotionPage) -> str: - """Extracts the title from a Notion page""" - page_title = None - for _, prop in page.properties.items(): - if prop["type"] == "title" and len(prop["title"]) > 0: - page_title = " ".join([t["plain_text"] for t in prop["title"]]).strip() - break - if page_title is None: - page_title = f"Untitled Page [{page.id}]" - return page_title - - def _read_pages( - self, - pages: list[NotionPage], - ) -> Generator[Document, None, None]: - """Reads pages for rich text content and generates Documents""" - all_child_page_ids: list[str] = [] - for page in pages: - if page.id in self.indexed_pages: - logger.debug(f"Already indexed page with ID '{page.id}'. Skipping.") - continue - - logger.info(f"Reading page with ID '{page.id}', with url {page.url}") - page_blocks, child_page_ids = self._read_blocks(page.id) - all_child_page_ids.extend(child_page_ids) - page_title = self._read_page_title(page) - yield ( - Document( - id=page.id, - # Will add title to the first section later in processing - sections=[Section(link=page.url, text="")] - + [ - Section( - link=f"{page.url}#{block_id.replace('-', '')}", - text=block_text, - ) - for block_text, block_id in page_blocks - ], - source=DocumentSource.NOTION, - semantic_identifier=page_title, - doc_updated_at=datetime.fromisoformat( - page.last_edited_time - ).astimezone(timezone.utc), - metadata={}, - ) - ) - self.indexed_pages.add(page.id) - - if self.recursive_index_enabled and all_child_page_ids: - # NOTE: checking if page_id is in self.indexed_pages to prevent extra - # calls to `_fetch_page` for pages we've already indexed - for child_page_batch_ids in batch_generator( - all_child_page_ids, batch_size=INDEX_BATCH_SIZE - ): - child_page_batch = [ - self._fetch_page(page_id) - for page_id in child_page_batch_ids - if page_id not in self.indexed_pages - ] - yield from self._read_pages(child_page_batch) - - @retry(tries=3, delay=1, backoff=2) - def _search_notion(self, query_dict: dict[str, Any]) -> NotionSearchResponse: - """Search for pages from a Notion database. Includes some small number of - retries to handle misc, flakey failures.""" - logger.debug(f"Searching for pages in Notion with query_dict: {query_dict}") - res = rl_requests.post( - "https://api.notion.com/v1/search", - headers=self.headers, - json=query_dict, - timeout=_NOTION_CALL_TIMEOUT, - ) - res.raise_for_status() - return NotionSearchResponse(**res.json()) - - def _filter_pages_by_time( - self, - pages: list[dict[str, Any]], - start: SecondsSinceUnixEpoch, - end: SecondsSinceUnixEpoch, - filter_field: str = "last_edited_time", - ) -> list[NotionPage]: - """A helper function to filter out pages outside of a time - range. This functionality doesn't yet exist in the Notion Search API, - but when it does, this approach can be deprecated. - - Arguments: - pages (list[dict]) - Pages to filter - start (float) - start epoch time to filter from - end (float) - end epoch time to filter to - filter_field (str) - the attribute on the page to apply the filter - """ - filtered_pages: list[NotionPage] = [] - for page in pages: - compare_time = time.mktime( - time.strptime(page[filter_field], "%Y-%m-%dT%H:%M:%S.000Z") - ) - if compare_time > start and compare_time <= end: - filtered_pages += [NotionPage(**page)] - return filtered_pages - - def _recursive_load(self) -> Generator[list[Document], None, None]: - if self.root_page_id is None or not self.recursive_index_enabled: - raise RuntimeError( - "Recursive page lookup is not enabled, but we are trying to " - "recursively load pages. This should never happen." - ) - - logger.info( - "Recursively loading pages from Notion based on root page with " - f"ID: {self.root_page_id}" - ) - pages = [self._fetch_page(page_id=self.root_page_id)] - yield from batch_generator(self._read_pages(pages), self.batch_size) - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - """Applies integration token to headers""" - self.headers[ - "Authorization" - ] = f'Bearer {credentials["notion_integration_token"]}' - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - """Loads all page data from a Notion workspace. - - Returns: - list[Document]: list of documents. - """ - # TODO: remove once Notion search issue is discovered - if self.recursive_index_enabled and self.root_page_id: - yield from self._recursive_load() - return - - query_dict = { - "filter": {"property": "object", "value": "page"}, - "page_size": self.batch_size, - } - while True: - db_res = self._search_notion(query_dict) - pages = [NotionPage(**page) for page in db_res.results] - yield from batch_generator(self._read_pages(pages), self.batch_size) - if db_res.has_more: - query_dict["start_cursor"] = db_res.next_cursor - else: - break - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - """Uses the Notion search API to fetch updated pages - within a time period. - Unfortunately the search API doesn't yet support filtering by times, - so until they add that, we're just going to page through results until, - we reach ones that are older than our search criteria. - """ - # TODO: remove once Notion search issue is discovered - if self.recursive_index_enabled and self.root_page_id: - yield from self._recursive_load() - return - - query_dict = { - "page_size": self.batch_size, - "sort": {"timestamp": "last_edited_time", "direction": "descending"}, - "filter": {"property": "object", "value": "page"}, - } - while True: - db_res = self._search_notion(query_dict) - pages = self._filter_pages_by_time( - db_res.results, start, end, filter_field="last_edited_time" - ) - if len(pages) > 0: - yield from batch_generator(self._read_pages(pages), self.batch_size) - if db_res.has_more: - query_dict["start_cursor"] = db_res.next_cursor - else: - break - else: - break - - -if __name__ == "__main__": - import os - - root_page_id = os.environ.get("NOTION_ROOT_PAGE_ID") - connector = NotionConnector(root_page_id=root_page_id) - connector.load_credentials( - {"notion_integration_token": os.environ.get("NOTION_INTEGRATION_TOKEN")} - ) - document_batches = connector.load_from_state() - for doc_batch in document_batches: - for doc in doc_batch: - print(doc) diff --git a/backend/danswer/connectors/productboard/__init__.py b/backend/danswer/connectors/productboard/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/productboard/connector.py b/backend/danswer/connectors/productboard/connector.py deleted file mode 100644 index 9ef301aa76d..00000000000 --- a/backend/danswer/connectors/productboard/connector.py +++ /dev/null @@ -1,265 +0,0 @@ -from collections.abc import Generator -from itertools import chain -from typing import Any -from typing import cast - -import requests -from bs4 import BeautifulSoup -from dateutil import parser -from retry import retry - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -_PRODUCT_BOARD_BASE_URL = "https://api.productboard.com" - - -class ProductboardApiError(Exception): - pass - - -class ProductboardConnector(PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.batch_size = batch_size - self.access_token: str | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.access_token = credentials["productboard_access_token"] - return None - - def _build_headers(self) -> dict[str, str]: - return { - "Authorization": f"Bearer {self.access_token}", - "X-Version": "1", - } - - @staticmethod - def _parse_description_html(description_html: str) -> str: - soup = BeautifulSoup(description_html, "html.parser") - return soup.get_text() - - @staticmethod - def _get_owner_email(productboard_obj: dict[str, Any]) -> str | None: - owner_dict = cast(dict[str, str] | None, productboard_obj.get("owner")) - if not owner_dict: - return None - return owner_dict.get("email") - - def _fetch_documents( - self, - initial_link: str, - ) -> Generator[dict[str, Any], None, None]: - headers = self._build_headers() - - @retry(tries=3, delay=1, backoff=2) - def fetch(link: str) -> dict[str, Any]: - response = requests.get(link, headers=headers) - if not response.ok: - # rate-limiting is at 50 requests per second. - # The delay in this retry should handle this while this is - # not parallelized. - raise ProductboardApiError( - "Failed to fetch from productboard - status code:" - f" {response.status_code} - response: {response.text}" - ) - - return response.json() - - curr_link = initial_link - while True: - response_json = fetch(curr_link) - for entity in response_json["data"]: - yield entity - - curr_link = response_json.get("links", {}).get("next") - if not curr_link: - break - - def _get_features(self) -> Generator[Document, None, None]: - """A Feature is like a ticket in Jira""" - for feature in self._fetch_documents( - initial_link=f"{_PRODUCT_BOARD_BASE_URL}/features" - ): - owner = self._get_owner_email(feature) - experts = [BasicExpertInfo(email=owner)] if owner else None - - yield Document( - id=feature["id"], - sections=[ - Section( - link=feature["links"]["html"], - text=self._parse_description_html(feature["description"]), - ) - ], - semantic_identifier=feature["name"], - source=DocumentSource.PRODUCTBOARD, - doc_updated_at=time_str_to_utc(feature["updatedAt"]), - primary_owners=experts, - metadata={ - "entity_type": feature["type"], - "status": feature["status"]["name"], - }, - ) - - def _get_components(self) -> Generator[Document, None, None]: - """A Component is like an epic in Jira. It contains Features""" - for component in self._fetch_documents( - initial_link=f"{_PRODUCT_BOARD_BASE_URL}/components" - ): - owner = self._get_owner_email(component) - experts = [BasicExpertInfo(email=owner)] if owner else None - - yield Document( - id=component["id"], - sections=[ - Section( - link=component["links"]["html"], - text=self._parse_description_html(component["description"]), - ) - ], - semantic_identifier=component["name"], - source=DocumentSource.PRODUCTBOARD, - doc_updated_at=time_str_to_utc(component["updatedAt"]), - primary_owners=experts, - metadata={ - "entity_type": "component", - }, - ) - - def _get_products(self) -> Generator[Document, None, None]: - """A Product is the highest level of organization. - A Product contains components, which contains features.""" - for product in self._fetch_documents( - initial_link=f"{_PRODUCT_BOARD_BASE_URL}/products" - ): - owner = self._get_owner_email(product) - experts = [BasicExpertInfo(email=owner)] if owner else None - - yield Document( - id=product["id"], - sections=[ - Section( - link=product["links"]["html"], - text=self._parse_description_html(product["description"]), - ) - ], - semantic_identifier=product["name"], - source=DocumentSource.PRODUCTBOARD, - doc_updated_at=time_str_to_utc(product["updatedAt"]), - primary_owners=experts, - metadata={ - "entity_type": "product", - }, - ) - - def _get_objectives(self) -> Generator[Document, None, None]: - for objective in self._fetch_documents( - initial_link=f"{_PRODUCT_BOARD_BASE_URL}/objectives" - ): - owner = self._get_owner_email(objective) - experts = [BasicExpertInfo(email=owner)] if owner else None - - yield Document( - id=objective["id"], - sections=[ - Section( - link=objective["links"]["html"], - text=self._parse_description_html(objective["description"]), - ) - ], - semantic_identifier=objective["name"], - source=DocumentSource.PRODUCTBOARD, - doc_updated_at=time_str_to_utc(objective["updatedAt"]), - primary_owners=experts, - metadata={ - "entity_type": "release", - "state": objective["state"], - }, - ) - - def _is_updated_at_out_of_time_range( - self, - document: Document, - start: SecondsSinceUnixEpoch, - end: SecondsSinceUnixEpoch, - ) -> bool: - updated_at = cast(str, document.metadata.get("updated_at", "")) - if updated_at: - updated_at_datetime = parser.parse(updated_at) - if ( - updated_at_datetime.timestamp() < start - or updated_at_datetime.timestamp() > end - ): - return True - else: - logger.debug(f"Unable to find updated_at for document '{document.id}'") - - return False - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.access_token is None: - raise PermissionError( - "Access token is not set up, was load_credentials called?" - ) - - document_batch: list[Document] = [] - - # NOTE: there is a concept of a "Note" in productboard, however - # there is no read API for it atm. Additionally, comments are not - # included with features. Finally, "Releases" are not fetched atm, - # since they do not provide an updatedAt. - feature_documents = self._get_features() - component_documents = self._get_components() - product_documents = self._get_products() - objective_documents = self._get_objectives() - for document in chain( - feature_documents, - component_documents, - product_documents, - objective_documents, - ): - # skip documents that are not in the time range - if self._is_updated_at_out_of_time_range(document, start, end): - continue - - document_batch.append(document) - if len(document_batch) >= self.batch_size: - yield document_batch - document_batch = [] - - if document_batch: - yield document_batch - - -if __name__ == "__main__": - import os - import time - - connector = ProductboardConnector() - connector.load_credentials( - { - "productboard_access_token": os.environ["PRODUCTBOARD_ACCESS_TOKEN"], - } - ) - - current = time.time() - one_year_ago = current - 24 * 60 * 60 * 360 - latest_docs = connector.poll_source(one_year_ago, current) - print(next(latest_docs)) diff --git a/backend/danswer/connectors/requesttracker/.gitignore b/backend/danswer/connectors/requesttracker/.gitignore deleted file mode 100644 index 4c49bd78f1d..00000000000 --- a/backend/danswer/connectors/requesttracker/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env diff --git a/backend/danswer/connectors/requesttracker/__init__.py b/backend/danswer/connectors/requesttracker/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/requesttracker/connector.py b/backend/danswer/connectors/requesttracker/connector.py deleted file mode 100644 index 9c4590fc2ef..00000000000 --- a/backend/danswer/connectors/requesttracker/connector.py +++ /dev/null @@ -1,153 +0,0 @@ -from datetime import datetime -from datetime import timezone -from logging import DEBUG as LOG_LVL_DEBUG -from typing import Any -from typing import List -from typing import Optional - -from rt.rest1 import ALL_QUEUES -from rt.rest1 import Rt - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class RequestTrackerError(Exception): - pass - - -class RequestTrackerConnector(PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.batch_size = batch_size - - def txn_link(self, tid: int, txn: int) -> str: - return f"{self.rt_base_url}/Ticket/Display.html?id={tid}&txn={txn}" - - def build_doc_sections_from_txn( - self, connection: Rt, ticket_id: int - ) -> List[Section]: - Sections: List[Section] = [] - - get_history_resp = connection.get_history(ticket_id) - - if get_history_resp is None: - raise RequestTrackerError(f"Ticket {ticket_id} cannot be found") - - for tx in get_history_resp: - Sections.append( - Section( - link=self.txn_link(ticket_id, int(tx["id"])), - text="\n".join( - [ - f"{k}:\n{v}\n" if k != "Attachments" else "" - for (k, v) in tx.items() - ] - ), - ) - ) - return Sections - - def load_credentials(self, credentials: dict[str, Any]) -> Optional[dict[str, Any]]: - self.rt_username = credentials.get("requesttracker_username") - self.rt_password = credentials.get("requesttracker_password") - self.rt_base_url = credentials.get("requesttracker_base_url") - return None - - # This does not include RT file attachments yet. - def _process_tickets( - self, start: datetime, end: datetime - ) -> GenerateDocumentsOutput: - if any([self.rt_username, self.rt_password, self.rt_base_url]) is None: - raise ConnectorMissingCredentialError("requesttracker") - - Rt0 = Rt( - f"{self.rt_base_url}/REST/1.0/", - self.rt_username, - self.rt_password, - ) - - Rt0.login() - - d0 = start.strftime("%Y-%m-%d %H:%M:%S") - d1 = end.strftime("%Y-%m-%d %H:%M:%S") - - tickets = Rt0.search( - Queue=ALL_QUEUES, - raw_query=f"Updated > '{d0}' AND Updated < '{d1}'", - ) - - doc_batch: List[Document] = [] - - for ticket in tickets: - ticket_keys_to_omit = ["id", "Subject"] - tid: int = int(ticket["numerical_id"]) - ticketLink: str = f"{self.rt_base_url}/Ticket/Display.html?id={tid}" - logger.info(f"Processing ticket {tid}") - doc = Document( - id=ticket["id"], - # Will add title to the first section later in processing - sections=[Section(link=ticketLink, text="")] - + self.build_doc_sections_from_txn(Rt0, tid), - source=DocumentSource.REQUESTTRACKER, - semantic_identifier=ticket["Subject"], - metadata={ - key: value - for key, value in ticket.items() - if key not in ticket_keys_to_omit - }, - ) - - doc_batch.append(doc) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if doc_batch: - yield doc_batch - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - # Keep query short, only look behind 1 day at maximum - one_day_ago: float = end - (24 * 60 * 60) - _start: float = start if start > one_day_ago else one_day_ago - start_datetime = datetime.fromtimestamp(_start, tz=timezone.utc) - end_datetime = datetime.fromtimestamp(end, tz=timezone.utc) - yield from self._process_tickets(start_datetime, end_datetime) - - -if __name__ == "__main__": - import time - import os - from dotenv import load_dotenv - - load_dotenv() - logger.setLevel(LOG_LVL_DEBUG) - rt_connector = RequestTrackerConnector() - rt_connector.load_credentials( - { - "requesttracker_username": os.getenv("RT_USERNAME"), - "requesttracker_password": os.getenv("RT_PASSWORD"), - "requesttracker_base_url": os.getenv("RT_BASE_URL"), - } - ) - - current = time.time() - one_day_ago = current - (24 * 60 * 60) # 1 days - latest_docs = rt_connector.poll_source(one_day_ago, current) - - for doc in latest_docs: - print(doc) diff --git a/backend/danswer/connectors/salesforce/__init__.py b/backend/danswer/connectors/salesforce/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/salesforce/connector.py b/backend/danswer/connectors/salesforce/connector.py deleted file mode 100644 index 03326df4efd..00000000000 --- a/backend/danswer/connectors/salesforce/connector.py +++ /dev/null @@ -1,274 +0,0 @@ -import os -from collections.abc import Iterator -from datetime import datetime -from datetime import timezone -from typing import Any - -from simple_salesforce import Salesforce -from simple_salesforce import SFType - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import IdConnector -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.connectors.salesforce.utils import extract_dict_text -from danswer.utils.logger import setup_logger - -DEFAULT_PARENT_OBJECT_TYPES = ["Account"] -MAX_QUERY_LENGTH = 10000 # max query length is 20,000 characters -ID_PREFIX = "SALESFORCE_" - -logger = setup_logger() - - -class SalesforceConnector(LoadConnector, PollConnector, IdConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - requested_objects: list[str] = [], - ) -> None: - self.batch_size = batch_size - self.sf_client: Salesforce | None = None - self.parent_object_list = ( - [obj.capitalize() for obj in requested_objects] - if requested_objects - else DEFAULT_PARENT_OBJECT_TYPES - ) - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.sf_client = Salesforce( - username=credentials["sf_username"], - password=credentials["sf_password"], - security_token=credentials["sf_security_token"], - ) - - return None - - def _get_sf_type_object_json(self, type_name: str) -> Any: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - sf_object = SFType( - type_name, self.sf_client.session_id, self.sf_client.sf_instance - ) - return sf_object.describe() - - def _get_name_from_id(self, id: str) -> str: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - try: - user_object_info = self.sf_client.query( - f"SELECT Name FROM User WHERE Id = '{id}'" - ) - name = user_object_info.get("Records", [{}])[0].get("Name", "Null User") - return name - except Exception: - logger.warning(f"Couldnt find name for object id: {id}") - return "Null User" - - def _convert_object_instance_to_document( - self, object_dict: dict[str, Any] - ) -> Document: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - - salesforce_id = object_dict["Id"] - danswer_salesforce_id = f"{ID_PREFIX}{salesforce_id}" - extracted_link = f"https://{self.sf_client.sf_instance}/{salesforce_id}" - extracted_doc_updated_at = time_str_to_utc(object_dict["LastModifiedDate"]) - extracted_object_text = extract_dict_text(object_dict) - extracted_semantic_identifier = object_dict.get("Name", "Unknown Object") - extracted_primary_owners = [ - BasicExpertInfo( - display_name=self._get_name_from_id(object_dict["LastModifiedById"]) - ) - ] - - doc = Document( - id=danswer_salesforce_id, - sections=[Section(link=extracted_link, text=extracted_object_text)], - source=DocumentSource.SALESFORCE, - semantic_identifier=extracted_semantic_identifier, - doc_updated_at=extracted_doc_updated_at, - primary_owners=extracted_primary_owners, - metadata={}, - ) - return doc - - def _is_valid_child_object(self, child_relationship: dict) -> bool: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - - if not child_relationship["childSObject"]: - return False - if not child_relationship["relationshipName"]: - return False - - sf_type = child_relationship["childSObject"] - object_description = self._get_sf_type_object_json(sf_type) - if not object_description["queryable"]: - return False - - try: - query = f"SELECT Count() FROM {sf_type} LIMIT 1" - result = self.sf_client.query(query) - if result["totalSize"] == 0: - return False - except Exception as e: - logger.warning(f"Object type {sf_type} doesn't support query: {e}") - return False - - if child_relationship["field"]: - if child_relationship["field"] == "RelatedToId": - return False - else: - return False - - return True - - def _get_all_children_of_sf_type(self, sf_type: str) -> list[dict]: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - - object_description = self._get_sf_type_object_json(sf_type) - - children_objects: list[dict] = [] - for child_relationship in object_description["childRelationships"]: - if self._is_valid_child_object(child_relationship): - children_objects.append( - { - "relationship_name": child_relationship["relationshipName"], - "object_type": child_relationship["childSObject"], - } - ) - return children_objects - - def _get_all_fields_for_sf_type(self, sf_type: str) -> list[str]: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - - object_description = self._get_sf_type_object_json(sf_type) - - fields = [ - field.get("name") - for field in object_description["fields"] - if field.get("type", "base64") != "base64" - ] - - return fields - - def _generate_query_per_parent_type(self, parent_sf_type: str) -> Iterator[str]: - """ - This function takes in an object_type and generates query(s) designed to grab - information associated to objects of that type. - It does that by getting all the fields of the parent object type. - Then it gets all the child objects of that object type and all the fields of - those children as well. - """ - parent_fields = self._get_all_fields_for_sf_type(parent_sf_type) - child_sf_types = self._get_all_children_of_sf_type(parent_sf_type) - - query = f"SELECT {', '.join(parent_fields)}" - for child_object_dict in child_sf_types: - fields = self._get_all_fields_for_sf_type(child_object_dict["object_type"]) - query_addition = f", \n(SELECT {', '.join(fields)} FROM {child_object_dict['relationship_name']})" - - if len(query_addition) + len(query) > MAX_QUERY_LENGTH: - query += f"\n FROM {parent_sf_type}" - yield query - query = "SELECT Id" + query_addition - else: - query += query_addition - - query += f"\n FROM {parent_sf_type}" - - yield query - - def _fetch_from_salesforce( - self, - start: datetime | None = None, - end: datetime | None = None, - ) -> GenerateDocumentsOutput: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - - doc_batch: list[Document] = [] - for parent_object_type in self.parent_object_list: - logger.debug(f"Processing: {parent_object_type}") - - query_results: dict = {} - for query in self._generate_query_per_parent_type(parent_object_type): - if start is not None and end is not None: - if start and start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) - if end and end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) - query += f" WHERE LastModifiedDate > {start.isoformat()} AND LastModifiedDate < {end.isoformat()}" - - query_result = self.sf_client.query_all(query) - - for record_dict in query_result["records"]: - query_results.setdefault(record_dict["Id"], {}).update(record_dict) - - logger.info( - f"Number of {parent_object_type} Objects processed: {len(query_results)}" - ) - - for combined_object_dict in query_results.values(): - doc_batch.append( - self._convert_object_instance_to_document(combined_object_dict) - ) - - if len(doc_batch) > self.batch_size: - yield doc_batch - doc_batch = [] - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_from_salesforce() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - return self._fetch_from_salesforce(start=start_datetime, end=end_datetime) - - def retrieve_all_source_ids(self) -> set[str]: - if self.sf_client is None: - raise ConnectorMissingCredentialError("Salesforce") - all_retrieved_ids: set[str] = set() - for parent_object_type in self.parent_object_list: - query = f"SELECT Id FROM {parent_object_type}" - query_result = self.sf_client.query_all(query) - all_retrieved_ids.update( - f"{ID_PREFIX}{instance_dict.get('Id', '')}" - for instance_dict in query_result["records"] - ) - - return all_retrieved_ids - - -if __name__ == "__main__": - connector = SalesforceConnector( - requested_objects=os.environ["REQUESTED_OBJECTS"].split(",") - ) - - connector.load_credentials( - { - "sf_username": os.environ["SF_USERNAME"], - "sf_password": os.environ["SF_PASSWORD"], - "sf_security_token": os.environ["SF_SECURITY_TOKEN"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/salesforce/utils.py b/backend/danswer/connectors/salesforce/utils.py deleted file mode 100644 index db8edf073f1..00000000000 --- a/backend/danswer/connectors/salesforce/utils.py +++ /dev/null @@ -1,66 +0,0 @@ -import re -from typing import Union - -SF_JSON_FILTER = r"Id$|Date$|stamp$|url$" - - -def _clean_salesforce_dict(data: Union[dict, list]) -> Union[dict, list]: - if isinstance(data, dict): - if "records" in data.keys(): - data = data["records"] - if isinstance(data, dict): - if "attributes" in data.keys(): - if isinstance(data["attributes"], dict): - data.update(data.pop("attributes")) - - if isinstance(data, dict): - filtered_dict = {} - for key, value in data.items(): - if not re.search(SF_JSON_FILTER, key, re.IGNORECASE): - if "__c" in key: # remove the custom object indicator for display - key = key[:-3] - if isinstance(value, (dict, list)): - filtered_value = _clean_salesforce_dict(value) - if filtered_value: # Only add non-empty dictionaries or lists - filtered_dict[key] = filtered_value - elif value is not None: - filtered_dict[key] = value - return filtered_dict - elif isinstance(data, list): - filtered_list = [] - for item in data: - if isinstance(item, (dict, list)): - filtered_item = _clean_salesforce_dict(item) - if filtered_item: # Only add non-empty dictionaries or lists - filtered_list.append(filtered_item) - elif item is not None: - filtered_list.append(filtered_item) - return filtered_list - else: - return data - - -def _json_to_natural_language(data: Union[dict, list], indent: int = 0) -> str: - result = [] - indent_str = " " * indent - - if isinstance(data, dict): - for key, value in data.items(): - if isinstance(value, (dict, list)): - result.append(f"{indent_str}{key}:") - result.append(_json_to_natural_language(value, indent + 2)) - else: - result.append(f"{indent_str}{key}: {value}") - elif isinstance(data, list): - for item in data: - result.append(_json_to_natural_language(item, indent)) - else: - result.append(f"{indent_str}{data}") - - return "\n".join(result) - - -def extract_dict_text(raw_dict: dict) -> str: - processed_dict = _clean_salesforce_dict(raw_dict) - natural_language_dict = _json_to_natural_language(processed_dict) - return natural_language_dict diff --git a/backend/danswer/connectors/sharepoint/__init__.py b/backend/danswer/connectors/sharepoint/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/sharepoint/connector.py b/backend/danswer/connectors/sharepoint/connector.py deleted file mode 100644 index b66c010d77f..00000000000 --- a/backend/danswer/connectors/sharepoint/connector.py +++ /dev/null @@ -1,211 +0,0 @@ -import io -import os -from dataclasses import dataclass -from dataclasses import field -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import Optional - -import msal # type: ignore -from office365.graph_client import GraphClient # type: ignore -from office365.onedrive.driveitems.driveItem import DriveItem # type: ignore -from office365.onedrive.sites.site import Site # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -@dataclass -class SiteData: - url: str | None - folder: Optional[str] - sites: list = field(default_factory=list) - driveitems: list = field(default_factory=list) - - -def _convert_driveitem_to_document( - driveitem: DriveItem, -) -> Document: - file_text = extract_file_text( - file_name=driveitem.name, - file=io.BytesIO(driveitem.get_content().execute_query().value), - break_on_unprocessable=False, - ) - - doc = Document( - id=driveitem.id, - sections=[Section(link=driveitem.web_url, text=file_text)], - source=DocumentSource.SHAREPOINT, - semantic_identifier=driveitem.name, - doc_updated_at=driveitem.last_modified_datetime.replace(tzinfo=timezone.utc), - primary_owners=[ - BasicExpertInfo( - display_name=driveitem.last_modified_by.user.displayName, - email=driveitem.last_modified_by.user.email, - ) - ], - metadata={}, - ) - return doc - - -class SharepointConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - sites: list[str] = [], - ) -> None: - self.batch_size = batch_size - self.graph_client: GraphClient | None = None - self.site_data: list[SiteData] = self._extract_site_and_folder(sites) - - @staticmethod - def _extract_site_and_folder(site_urls: list[str]) -> list[SiteData]: - site_data_list = [] - for url in site_urls: - parts = url.strip().split("/") - if "sites" in parts: - sites_index = parts.index("sites") - site_url = "/".join(parts[: sites_index + 2]) - folder = ( - parts[sites_index + 2] if len(parts) > sites_index + 2 else None - ) - site_data_list.append( - SiteData(url=site_url, folder=folder, sites=[], driveitems=[]) - ) - return site_data_list - - def _populate_sitedata_driveitems( - self, - start: datetime | None = None, - end: datetime | None = None, - ) -> None: - filter_str = "" - if start is not None and end is not None: - filter_str = f"last_modified_datetime ge {start.isoformat()} and last_modified_datetime le {end.isoformat()}" - - for element in self.site_data: - sites: list[Site] = [] - for site in element.sites: - site_sublist = site.lists.get().execute_query() - sites.extend(site_sublist) - - for site in sites: - try: - query = site.drive.root.get_files(True, 1000) - if filter_str: - query = query.filter(filter_str) - driveitems = query.execute_query() - if element.folder: - filtered_driveitems = [ - item - for item in driveitems - if element.folder in item.parent_reference.path - ] - element.driveitems.extend(filtered_driveitems) - else: - element.driveitems.extend(driveitems) - - except Exception: - # Sites include things that do not contain .drive.root so this fails - # but this is fine, as there are no actually documents in those - pass - - def _populate_sitedata_sites(self) -> None: - if self.graph_client is None: - raise ConnectorMissingCredentialError("Sharepoint") - - if self.site_data: - for element in self.site_data: - element.sites = [ - self.graph_client.sites.get_by_url(element.url) - .get() - .execute_query() - ] - else: - sites = self.graph_client.sites.get().execute_query() - self.site_data = [ - SiteData(url=None, folder=None, sites=sites, driveitems=[]) - ] - - def _fetch_from_sharepoint( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.graph_client is None: - raise ConnectorMissingCredentialError("Sharepoint") - - self._populate_sitedata_sites() - self._populate_sitedata_driveitems(start=start, end=end) - - # goes over all urls, converts them into Document objects and then yields them in batches - doc_batch: list[Document] = [] - for element in self.site_data: - for driveitem in element.driveitems: - logger.debug(f"Processing: {driveitem.web_url}") - doc_batch.append(_convert_driveitem_to_document(driveitem)) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - yield doc_batch - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - sp_client_id = credentials["sp_client_id"] - sp_client_secret = credentials["sp_client_secret"] - sp_directory_id = credentials["sp_directory_id"] - - def _acquire_token_func() -> dict[str, Any]: - """ - Acquire token via MSAL - """ - authority_url = f"https://login.microsoftonline.com/{sp_directory_id}" - app = msal.ConfidentialClientApplication( - authority=authority_url, - client_id=sp_client_id, - client_credential=sp_client_secret, - ) - token = app.acquire_token_for_client( - scopes=["https://graph.microsoft.com/.default"] - ) - return token - - self.graph_client = GraphClient(_acquire_token_func) - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_from_sharepoint() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - return self._fetch_from_sharepoint(start=start_datetime, end=end_datetime) - - -if __name__ == "__main__": - connector = SharepointConnector(sites=os.environ["SITES"].split(",")) - - connector.load_credentials( - { - "sp_client_id": os.environ["SP_CLIENT_ID"], - "sp_client_secret": os.environ["SP_CLIENT_SECRET"], - "sp_directory_id": os.environ["SP_CLIENT_DIRECTORY_ID"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/slab/__init__.py b/backend/danswer/connectors/slab/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/slab/connector.py b/backend/danswer/connectors/slab/connector.py deleted file mode 100644 index 80380ff7c29..00000000000 --- a/backend/danswer/connectors/slab/connector.py +++ /dev/null @@ -1,226 +0,0 @@ -import json -from collections.abc import Callable -from collections.abc import Generator -from datetime import datetime -from datetime import timezone -from typing import Any -from urllib.parse import urljoin - -import requests -from dateutil import parser - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.utils.logger import setup_logger - -# Fairly generous retry because it's not understood why occasionally GraphQL requests fail even with timeout > 1 min -SLAB_GRAPHQL_MAX_TRIES = 10 -SLAB_API_URL = "https://api.slab.com/v1/graphql" -logger = setup_logger() - - -def run_graphql_request( - graphql_query: dict, bot_token: str, max_tries: int = SLAB_GRAPHQL_MAX_TRIES -) -> str: - headers = {"Authorization": bot_token, "Content-Type": "application/json"} - - for try_count in range(max_tries): - try: - response = requests.post( - SLAB_API_URL, headers=headers, json=graphql_query, timeout=60 - ) - response.raise_for_status() - - if response.status_code != 200: - raise ValueError(f"GraphQL query failed: {graphql_query}") - - return response.text - - except (requests.exceptions.Timeout, ValueError) as e: - if try_count < max_tries - 1: - logger.warning("A Slab GraphQL error occurred. Retrying...") - continue - - if isinstance(e, requests.exceptions.Timeout): - raise TimeoutError("Slab API timed out after 3 attempts") - else: - raise ValueError("Slab GraphQL query failed after 3 attempts") - - raise RuntimeError( - "Unexpected execution from Slab Connector. This should not happen." - ) # for static checker - - -def get_all_post_ids(bot_token: str) -> list[str]: - query = """ - query GetAllPostIds { - organization { - posts { - id - } - } - } - """ - - graphql_query = {"query": query} - - results = json.loads(run_graphql_request(graphql_query, bot_token)) - posts = results["data"]["organization"]["posts"] - return [post["id"] for post in posts] - - -def get_post_by_id(post_id: str, bot_token: str) -> dict[str, str]: - query = """ - query GetPostById($postId: ID!) { - post(id: $postId) { - title - content - linkAccess - updatedAt - } - } - """ - graphql_query = {"query": query, "variables": {"postId": post_id}} - results = json.loads(run_graphql_request(graphql_query, bot_token)) - return results["data"]["post"] - - -def iterate_post_batches( - batch_size: int, bot_token: str -) -> Generator[list[dict[str, str]], None, None]: - """This may not be safe to use, not sure if page edits will change the order of results""" - query = """ - query IteratePostBatches($query: String!, $first: Int, $types: [SearchType], $after: String) { - search(query: $query, first: $first, types: $types, after: $after) { - edges { - node { - ... on PostSearchResult { - post { - id - title - content - updatedAt - } - } - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - pagination_start = None - exists_more_pages = True - while exists_more_pages: - graphql_query = { - "query": query, - "variables": { - "query": "", - "first": batch_size, - "types": ["POST"], - "after": pagination_start, - }, - } - results = json.loads(run_graphql_request(graphql_query, bot_token)) - pagination_start = results["data"]["search"]["pageInfo"]["endCursor"] - hits = results["data"]["search"]["edges"] - - posts = [hit["node"] for hit in hits] - if posts: - yield posts - - exists_more_pages = results["data"]["search"]["pageInfo"]["hasNextPage"] - - -def get_slab_url_from_title_id(base_url: str, title: str, page_id: str) -> str: - """This is not a documented approach but seems to be the way it works currently - May be subject to change without notification""" - title = ( - title.replace("[", "") - .replace("]", "") - .replace(":", "") - .replace(" ", "-") - .lower() - ) - url_id = title + "-" + page_id - return urljoin(urljoin(base_url, "posts/"), url_id) - - -class SlabConnector(LoadConnector, PollConnector): - def __init__( - self, - base_url: str, - batch_size: int = INDEX_BATCH_SIZE, - slab_bot_token: str | None = None, - ) -> None: - self.base_url = base_url - self.batch_size = batch_size - self.slab_bot_token = slab_bot_token - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - self.slab_bot_token = credentials["slab_bot_token"] - return None - - def _iterate_posts( - self, time_filter: Callable[[datetime], bool] | None = None - ) -> GenerateDocumentsOutput: - doc_batch: list[Document] = [] - - if self.slab_bot_token is None: - raise ConnectorMissingCredentialError("Slab") - - all_post_ids: list[str] = get_all_post_ids(self.slab_bot_token) - - for post_id in all_post_ids: - post = get_post_by_id(post_id, self.slab_bot_token) - last_modified = parser.parse(post["updatedAt"]) - if time_filter is not None and not time_filter(last_modified): - continue - - page_url = get_slab_url_from_title_id(self.base_url, post["title"], post_id) - - content_text = "" - contents = json.loads(post["content"]) - for content_segment in contents: - insert = content_segment.get("insert") - if insert and isinstance(insert, str): - content_text += insert - - doc_batch.append( - Document( - id=post_id, # can't be url as this changes with the post title - sections=[Section(link=page_url, text=content_text)], - source=DocumentSource.SLAB, - semantic_identifier=post["title"], - metadata={}, - ) - ) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - - if doc_batch: - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - yield from self._iterate_posts() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_time = datetime.fromtimestamp(start, tz=timezone.utc) - end_time = datetime.fromtimestamp(end, tz=timezone.utc) - - yield from self._iterate_posts( - time_filter=lambda t: start_time <= t <= end_time - ) diff --git a/backend/danswer/connectors/slack/__init__.py b/backend/danswer/connectors/slack/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/slack/connector.py b/backend/danswer/connectors/slack/connector.py deleted file mode 100644 index 6c451389932..00000000000 --- a/backend/danswer/connectors/slack/connector.py +++ /dev/null @@ -1,393 +0,0 @@ -import re -from collections.abc import Callable -from collections.abc import Generator -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import cast - -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from slack_sdk.web import SlackResponse - -from danswer.configs.app_configs import ENABLE_EXPENSIVE_EXPERT_CALLS -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.retry_wrapper import retry_builder -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.connectors.slack.utils import expert_info_from_slack_id -from danswer.connectors.slack.utils import get_message_link -from danswer.connectors.slack.utils import make_slack_api_call_logged -from danswer.connectors.slack.utils import make_slack_api_call_paginated -from danswer.connectors.slack.utils import make_slack_api_rate_limited -from danswer.connectors.slack.utils import SlackTextCleaner -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -ChannelType = dict[str, Any] -MessageType = dict[str, Any] -# list of messages in a thread -ThreadType = list[MessageType] - -basic_retry_wrapper = retry_builder() - - -def _make_paginated_slack_api_call( - call: Callable[..., SlackResponse], **kwargs: Any -) -> Generator[dict[str, Any], None, None]: - return make_slack_api_call_paginated( - basic_retry_wrapper( - make_slack_api_rate_limited(make_slack_api_call_logged(call)) - ) - )(**kwargs) - - -def _make_slack_api_call( - call: Callable[..., SlackResponse], **kwargs: Any -) -> SlackResponse: - return basic_retry_wrapper( - make_slack_api_rate_limited(make_slack_api_call_logged(call)) - )(**kwargs) - - -def get_channel_info(client: WebClient, channel_id: str) -> ChannelType: - """Get information about a channel. Needed to convert channel ID to channel name""" - return _make_slack_api_call(client.conversations_info, channel=channel_id)[0][ - "channel" - ] - - -def _get_channels( - client: WebClient, - exclude_archived: bool, - get_private: bool, -) -> list[ChannelType]: - channels: list[dict[str, Any]] = [] - for result in _make_paginated_slack_api_call( - client.conversations_list, - exclude_archived=exclude_archived, - # also get private channels the bot is added to - types=["public_channel", "private_channel"] - if get_private - else ["public_channel"], - ): - channels.extend(result["channels"]) - - return channels - - -def get_channels( - client: WebClient, - exclude_archived: bool = True, -) -> list[ChannelType]: - """Get all channels in the workspace""" - # try getting private channels as well at first - try: - return _get_channels( - client=client, exclude_archived=exclude_archived, get_private=True - ) - except SlackApiError as e: - logger.info(f"Unable to fetch private channels due to - {e}") - - return _get_channels( - client=client, exclude_archived=exclude_archived, get_private=False - ) - - -def get_channel_messages( - client: WebClient, - channel: dict[str, Any], - oldest: str | None = None, - latest: str | None = None, -) -> Generator[list[MessageType], None, None]: - """Get all messages in a channel""" - # join so that the bot can access messages - if not channel["is_member"]: - _make_slack_api_call( - client.conversations_join, - channel=channel["id"], - is_private=channel["is_private"], - ) - logger.info(f"Successfully joined '{channel['name']}'") - - for result in _make_paginated_slack_api_call( - client.conversations_history, - channel=channel["id"], - oldest=oldest, - latest=latest, - ): - yield cast(list[MessageType], result["messages"]) - - -def get_thread(client: WebClient, channel_id: str, thread_id: str) -> ThreadType: - """Get all messages in a thread""" - threads: list[MessageType] = [] - for result in _make_paginated_slack_api_call( - client.conversations_replies, channel=channel_id, ts=thread_id - ): - threads.extend(result["messages"]) - return threads - - -def get_latest_message_time(thread: ThreadType) -> datetime: - max_ts = max([float(msg.get("ts", 0)) for msg in thread]) - return datetime.fromtimestamp(max_ts, tz=timezone.utc) - - -def thread_to_doc( - workspace: str, - channel: ChannelType, - thread: ThreadType, - slack_cleaner: SlackTextCleaner, - client: WebClient, - user_cache: dict[str, BasicExpertInfo | None], -) -> Document: - channel_id = channel["id"] - - initial_sender_expert_info = expert_info_from_slack_id( - user_id=thread[0].get("user"), client=client, user_cache=user_cache - ) - initial_sender_name = ( - initial_sender_expert_info.get_semantic_name() - if initial_sender_expert_info - else "Unknown" - ) - - valid_experts = None - if ENABLE_EXPENSIVE_EXPERT_CALLS: - all_sender_ids = [m.get("user") for m in thread] - experts = [ - expert_info_from_slack_id( - user_id=sender_id, client=client, user_cache=user_cache - ) - for sender_id in all_sender_ids - if sender_id - ] - valid_experts = [expert for expert in experts if expert] - - first_message = slack_cleaner.index_clean(cast(str, thread[0]["text"])) - snippet = ( - first_message[:50].rstrip() + "..." - if len(first_message) > 50 - else first_message - ) - - doc_sem_id = f"{initial_sender_name} in #{channel['name']}: {snippet}" - - return Document( - id=f"{channel_id}__{thread[0]['ts']}", - sections=[ - Section( - link=get_message_link( - event=m, workspace=workspace, channel_id=channel_id - ), - text=slack_cleaner.index_clean(cast(str, m["text"])), - ) - for m in thread - ], - source=DocumentSource.SLACK, - semantic_identifier=doc_sem_id, - doc_updated_at=get_latest_message_time(thread), - title="", # slack docs don't really have a "title" - primary_owners=valid_experts, - metadata={"Channel": channel["name"]}, - ) - - -# list of subtypes can be found here: https://api.slack.com/events/message -_DISALLOWED_MSG_SUBTYPES = { - "channel_join", - "channel_leave", - "channel_archive", - "channel_unarchive", - "pinned_item", - "unpinned_item", - "ekm_access_denied", - "channel_posting_permissions", - "group_join", - "group_leave", - "group_archive", - "group_unarchive", -} - - -def _default_msg_filter(message: MessageType) -> bool: - # Don't keep messages from bots - if message.get("bot_id") or message.get("app_id"): - return True - - # Uninformative - if message.get("subtype", "") in _DISALLOWED_MSG_SUBTYPES: - return True - - return False - - -def filter_channels( - all_channels: list[dict[str, Any]], - channels_to_connect: list[str] | None, - regex_enabled: bool, -) -> list[dict[str, Any]]: - if not channels_to_connect: - return all_channels - - if regex_enabled: - return [ - channel - for channel in all_channels - if any( - re.fullmatch(channel_to_connect, channel["name"]) - for channel_to_connect in channels_to_connect - ) - ] - - # validate that all channels in `channels_to_connect` are valid - # fail loudly in the case of an invalid channel so that the user - # knows that one of the channels they've specified is typo'd or private - all_channel_names = {channel["name"] for channel in all_channels} - for channel in channels_to_connect: - if channel not in all_channel_names: - raise ValueError( - f"Channel '{channel}' not found in workspace. " - f"Available channels: {all_channel_names}" - ) - - return [ - channel for channel in all_channels if channel["name"] in channels_to_connect - ] - - -def get_all_docs( - client: WebClient, - workspace: str, - channels: list[str] | None = None, - channel_name_regex_enabled: bool = False, - oldest: str | None = None, - latest: str | None = None, - msg_filter_func: Callable[[MessageType], bool] = _default_msg_filter, -) -> Generator[Document, None, None]: - """Get all documents in the workspace, channel by channel""" - slack_cleaner = SlackTextCleaner(client=client) - - # Cache to prevent refetching via API since users - user_cache: dict[str, BasicExpertInfo | None] = {} - - all_channels = get_channels(client) - filtered_channels = filter_channels( - all_channels, channels, channel_name_regex_enabled - ) - - for channel in filtered_channels: - channel_docs = 0 - channel_message_batches = get_channel_messages( - client=client, channel=channel, oldest=oldest, latest=latest - ) - - seen_thread_ts: set[str] = set() - for message_batch in channel_message_batches: - for message in message_batch: - filtered_thread: ThreadType | None = None - thread_ts = message.get("thread_ts") - if thread_ts: - # skip threads we've already seen, since we've already processed all - # messages in that thread - if thread_ts in seen_thread_ts: - continue - seen_thread_ts.add(thread_ts) - thread = get_thread( - client=client, channel_id=channel["id"], thread_id=thread_ts - ) - filtered_thread = [ - message for message in thread if not msg_filter_func(message) - ] - elif not msg_filter_func(message): - filtered_thread = [message] - - if filtered_thread: - channel_docs += 1 - yield thread_to_doc( - workspace=workspace, - channel=channel, - thread=filtered_thread, - slack_cleaner=slack_cleaner, - client=client, - user_cache=user_cache, - ) - - logger.info( - f"Pulled {channel_docs} documents from slack channel {channel['name']}" - ) - - -class SlackPollConnector(PollConnector): - def __init__( - self, - workspace: str, - channels: list[str] | None = None, - # if specified, will treat the specified channel strings as - # regexes, and will only index channels that fully match the regexes - channel_regex_enabled: bool = False, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.workspace = workspace - self.channels = channels - self.channel_regex_enabled = channel_regex_enabled - self.batch_size = batch_size - self.client: WebClient | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - bot_token = credentials["slack_bot_token"] - self.client = WebClient(token=bot_token) - return None - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - if self.client is None: - raise ConnectorMissingCredentialError("Slack") - - documents: list[Document] = [] - for document in get_all_docs( - client=self.client, - workspace=self.workspace, - channels=self.channels, - channel_name_regex_enabled=self.channel_regex_enabled, - # NOTE: need to impute to `None` instead of using 0.0, since Slack will - # throw an error if we use 0.0 on an account without infinite data - # retention - oldest=str(start) if start else None, - latest=str(end), - ): - documents.append(document) - if len(documents) >= self.batch_size: - yield documents - documents = [] - - if documents: - yield documents - - -if __name__ == "__main__": - import os - import time - - slack_channel = os.environ.get("SLACK_CHANNEL") - connector = SlackPollConnector( - workspace=os.environ["SLACK_WORKSPACE"], - channels=[slack_channel] if slack_channel else None, - ) - connector.load_credentials({"slack_bot_token": os.environ["SLACK_BOT_TOKEN"]}) - - current = time.time() - one_day_ago = current - 24 * 60 * 60 # 1 day - document_batches = connector.poll_source(one_day_ago, current) - - print(next(document_batches)) diff --git a/backend/danswer/connectors/slack/load_connector.py b/backend/danswer/connectors/slack/load_connector.py deleted file mode 100644 index ebcfce5b845..00000000000 --- a/backend/danswer/connectors/slack/load_connector.py +++ /dev/null @@ -1,139 +0,0 @@ -import json -import os -from datetime import datetime -from datetime import timezone -from pathlib import Path -from typing import Any -from typing import cast - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.connectors.slack.connector import filter_channels -from danswer.connectors.slack.utils import get_message_link -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_event_time(event: dict[str, Any]) -> datetime | None: - ts = event.get("ts") - if not ts: - return None - return datetime.fromtimestamp(float(ts), tz=timezone.utc) - - -class SlackLoadConnector(LoadConnector): - # WARNING: DEPRECATED, DO NOT USE - def __init__( - self, - workspace: str, - export_path_str: str, - channels: list[str] | None = None, - # if specified, will treat the specified channel strings as - # regexes, and will only index channels that fully match the regexes - channel_regex_enabled: bool = False, - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.workspace = workspace - self.channels = channels - self.channel_regex_enabled = channel_regex_enabled - self.export_path_str = export_path_str - self.batch_size = batch_size - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - if credentials: - logger.warning("Unexpected credentials provided for Slack Load Connector") - return None - - @staticmethod - def _process_batch_event( - slack_event: dict[str, Any], - channel: dict[str, Any], - matching_doc: Document | None, - workspace: str, - ) -> Document | None: - if ( - slack_event["type"] == "message" - and slack_event.get("subtype") != "channel_join" - ): - if matching_doc: - return Document( - id=matching_doc.id, - sections=matching_doc.sections - + [ - Section( - link=get_message_link( - event=slack_event, - workspace=workspace, - channel_id=channel["id"], - ), - text=slack_event["text"], - ) - ], - source=matching_doc.source, - semantic_identifier=matching_doc.semantic_identifier, - title="", # slack docs don't really have a "title" - doc_updated_at=get_event_time(slack_event), - metadata=matching_doc.metadata, - ) - - return Document( - id=slack_event["ts"], - sections=[ - Section( - link=get_message_link( - event=slack_event, - workspace=workspace, - channel_id=channel["id"], - ), - text=slack_event["text"], - ) - ], - source=DocumentSource.SLACK, - semantic_identifier=channel["name"], - title="", # slack docs don't really have a "title" - doc_updated_at=get_event_time(slack_event), - metadata={}, - ) - - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - export_path = Path(self.export_path_str) - - with open(export_path / "channels.json") as f: - all_channels = json.load(f) - - filtered_channels = filter_channels( - all_channels, self.channels, self.channel_regex_enabled - ) - - document_batch: dict[str, Document] = {} - for channel_info in filtered_channels: - channel_dir_path = export_path / cast(str, channel_info["name"]) - channel_file_paths = [ - channel_dir_path / file_name - for file_name in os.listdir(channel_dir_path) - ] - for path in channel_file_paths: - with open(path) as f: - events = cast(list[dict[str, Any]], json.load(f)) - for slack_event in events: - doc = self._process_batch_event( - slack_event=slack_event, - channel=channel_info, - matching_doc=document_batch.get( - slack_event.get("thread_ts", "") - ), - workspace=self.workspace, - ) - if doc: - document_batch[doc.id] = doc - if len(document_batch) >= self.batch_size: - yield list(document_batch.values()) - - yield list(document_batch.values()) diff --git a/backend/danswer/connectors/slack/utils.py b/backend/danswer/connectors/slack/utils.py deleted file mode 100644 index 8650ce9ddc9..00000000000 --- a/backend/danswer/connectors/slack/utils.py +++ /dev/null @@ -1,274 +0,0 @@ -import re -import time -from collections.abc import Callable -from collections.abc import Generator -from functools import wraps -from typing import Any -from typing import cast - -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from slack_sdk.web import SlackResponse - -from danswer.connectors.models import BasicExpertInfo -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -# number of messages we request per page when fetching paginated slack messages -_SLACK_LIMIT = 900 - - -def get_message_link( - event: dict[str, Any], workspace: str, channel_id: str | None = None -) -> str: - channel_id = channel_id or cast( - str, event["channel"] - ) # channel must either be present in the event or passed in - message_ts = cast(str, event["ts"]) - message_ts_without_dot = message_ts.replace(".", "") - thread_ts = cast(str | None, event.get("thread_ts")) - return ( - f"https://{workspace}.slack.com/archives/{channel_id}/p{message_ts_without_dot}" - + (f"?thread_ts={thread_ts}" if thread_ts else "") - ) - - -def make_slack_api_call_logged( - call: Callable[..., SlackResponse], -) -> Callable[..., SlackResponse]: - @wraps(call) - def logged_call(**kwargs: Any) -> SlackResponse: - logger.debug(f"Making call to Slack API '{call.__name__}' with args '{kwargs}'") - result = call(**kwargs) - logger.debug(f"Call to Slack API '{call.__name__}' returned '{result}'") - return result - - return logged_call - - -def make_slack_api_call_paginated( - call: Callable[..., SlackResponse], -) -> Callable[..., Generator[dict[str, Any], None, None]]: - """Wraps calls to slack API so that they automatically handle pagination""" - - @wraps(call) - def paginated_call(**kwargs: Any) -> Generator[dict[str, Any], None, None]: - cursor: str | None = None - has_more = True - while has_more: - response = call(cursor=cursor, limit=_SLACK_LIMIT, **kwargs) - yield cast(dict[str, Any], response.validate()) - cursor = cast(dict[str, Any], response.get("response_metadata", {})).get( - "next_cursor", "" - ) - has_more = bool(cursor) - - return paginated_call - - -def make_slack_api_rate_limited( - call: Callable[..., SlackResponse], max_retries: int = 7 -) -> Callable[..., SlackResponse]: - """Wraps calls to slack API so that they automatically handle rate limiting""" - - @wraps(call) - def rate_limited_call(**kwargs: Any) -> SlackResponse: - last_exception = None - for _ in range(max_retries): - try: - # Make the API call - response = call(**kwargs) - - # Check for errors in the response, will raise `SlackApiError` - # if anything went wrong - response.validate() - return response - - except SlackApiError as e: - last_exception = e - try: - error = e.response["error"] - except KeyError: - error = "unknown error" - - if error == "ratelimited": - # Handle rate limiting: get the 'Retry-After' header value and sleep for that duration - retry_after = int(e.response.headers.get("Retry-After", 1)) - logger.info( - f"Slack call rate limited, retrying after {retry_after} seconds. Exception: {e}" - ) - time.sleep(retry_after) - elif error in ["already_reacted", "no_reaction"]: - # The response isn't used for reactions, this is basically just a pass - return e.response - else: - # Raise the error for non-transient errors - raise - - # If the code reaches this point, all retries have been exhausted - msg = f"Max retries ({max_retries}) exceeded" - if last_exception: - raise Exception(msg) from last_exception - else: - raise Exception(msg) - - return rate_limited_call - - -def expert_info_from_slack_id( - user_id: str | None, - client: WebClient, - user_cache: dict[str, BasicExpertInfo | None], -) -> BasicExpertInfo | None: - if not user_id: - return None - - if user_id in user_cache: - return user_cache[user_id] - - response = make_slack_api_rate_limited(client.users_info)(user=user_id) - - if not response["ok"]: - user_cache[user_id] = None - return None - - user: dict = cast(dict[Any, dict], response.data).get("user", {}) - profile = user.get("profile", {}) - - expert = BasicExpertInfo( - display_name=user.get("real_name") or profile.get("display_name"), - first_name=profile.get("first_name"), - last_name=profile.get("last_name"), - email=profile.get("email"), - ) - - user_cache[user_id] = expert - - return expert - - -class SlackTextCleaner: - """Utility class to replace user IDs with usernames in a message. - Handles caching, so the same request is not made multiple times - for the same user ID""" - - def __init__(self, client: WebClient) -> None: - self._client = client - self._id_to_name_map: dict[str, str] = {} - - def _get_slack_name(self, user_id: str) -> str: - if user_id not in self._id_to_name_map: - try: - response = make_slack_api_rate_limited(self._client.users_info)( - user=user_id - ) - # prefer display name if set, since that is what is shown in Slack - self._id_to_name_map[user_id] = ( - response["user"]["profile"]["display_name"] - or response["user"]["profile"]["real_name"] - ) - except SlackApiError as e: - logger.exception( - f"Error fetching data for user {user_id}: {e.response['error']}" - ) - raise - - return self._id_to_name_map[user_id] - - def _replace_user_ids_with_names(self, message: str) -> str: - # Find user IDs in the message - user_ids = re.findall("<@(.*?)>", message) - - # Iterate over each user ID found - for user_id in user_ids: - try: - if user_id in self._id_to_name_map: - user_name = self._id_to_name_map[user_id] - else: - user_name = self._get_slack_name(user_id) - - # Replace the user ID with the username in the message - message = message.replace(f"<@{user_id}>", f"@{user_name}") - except Exception: - logger.exception( - f"Unable to replace user ID with username for user_id '{user_id}'" - ) - - return message - - def index_clean(self, message: str) -> str: - """During indexing, replace pattern sets that may cause confusion to the model - Some special patterns are left in as they can provide information - ie. links that contain format text|link, both the text and the link may be informative - """ - message = self._replace_user_ids_with_names(message) - message = self.replace_tags_basic(message) - message = self.replace_channels_basic(message) - message = self.replace_special_mentions(message) - message = self.replace_special_catchall(message) - return message - - @staticmethod - def replace_tags_basic(message: str) -> str: - """Simply replaces all tags with `@` in order to prevent us from - tagging users in Slack when we don't want to""" - # Find user IDs in the message - user_ids = re.findall("<@(.*?)>", message) - for user_id in user_ids: - message = message.replace(f"<@{user_id}>", f"@{user_id}") - return message - - @staticmethod - def replace_channels_basic(message: str) -> str: - """Simply replaces all channel mentions with `#` in order - to make a message work as part of a link""" - # Find user IDs in the message - channel_matches = re.findall(r"<#(.*?)\|(.*?)>", message) - for channel_id, channel_name in channel_matches: - message = message.replace( - f"<#{channel_id}|{channel_name}>", f"#{channel_name}" - ) - return message - - @staticmethod - def replace_special_mentions(message: str) -> str: - """Simply replaces @channel, @here, and @everyone so we don't tag - a bunch of people in Slack when we don't want to""" - # Find user IDs in the message - message = message.replace("", "@channel") - message = message.replace("", "@here") - message = message.replace("", "@everyone") - return message - - @staticmethod - def replace_links(message: str) -> str: - """Replaces slack links e.g. `` -> `URL` and `` -> `DISPLAY`""" - # Find user IDs in the message - possible_link_matches = re.findall(r"<(.*?)>", message) - for possible_link in possible_link_matches: - if not possible_link: - continue - # Special slack patterns that aren't for links - if possible_link[0] not in ["#", "@", "!"]: - link_display = ( - possible_link - if "|" not in possible_link - else possible_link.split("|")[1] - ) - message = message.replace(f"<{possible_link}>", link_display) - return message - - @staticmethod - def replace_special_catchall(message: str) -> str: - """Replaces pattern of with another-thing - This is added for but may match other cases as well - """ - - pattern = r"]+)>" - return re.sub(pattern, r"\2", message) - - @staticmethod - def add_zero_width_whitespace_after_tag(message: str) -> str: - """Add a 0 width whitespace after every @""" - return message.replace("@", "@\u200B") diff --git a/backend/danswer/connectors/teams/__init__.py b/backend/danswer/connectors/teams/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/teams/connector.py b/backend/danswer/connectors/teams/connector.py deleted file mode 100644 index 3b9340878ff..00000000000 --- a/backend/danswer/connectors/teams/connector.py +++ /dev/null @@ -1,278 +0,0 @@ -import os -from datetime import datetime -from datetime import timezone -from typing import Any - -import msal # type: ignore -from office365.graph_client import GraphClient # type: ignore -from office365.teams.channels.channel import Channel # type: ignore -from office365.teams.chats.messages.message import ChatMessage # type: ignore -from office365.teams.team import Team # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_created_datetime(chat_message: ChatMessage) -> datetime: - # Extract the 'createdDateTime' value from the 'properties' dictionary and convert it to a datetime object - return time_str_to_utc(chat_message.properties["createdDateTime"]) - - -def _extract_channel_members(channel: Channel) -> list[BasicExpertInfo]: - channel_members_list: list[BasicExpertInfo] = [] - members = channel.members.get().execute_query() - for member in members: - channel_members_list.append(BasicExpertInfo(display_name=member.display_name)) - return channel_members_list - - -def _get_threads_from_channel( - channel: Channel, - start: datetime | None = None, - end: datetime | None = None, -) -> list[list[ChatMessage]]: - # Ensure start and end are timezone-aware - if start and start.tzinfo is None: - start = start.replace(tzinfo=timezone.utc) - if end and end.tzinfo is None: - end = end.replace(tzinfo=timezone.utc) - - query = channel.messages.get() - base_messages: list[ChatMessage] = query.execute_query() - - threads: list[list[ChatMessage]] = [] - for base_message in base_messages: - message_datetime = time_str_to_utc( - base_message.properties["lastModifiedDateTime"] - ) - - if start and message_datetime < start: - continue - if end and message_datetime > end: - continue - - reply_query = base_message.replies.get_all() - replies = reply_query.execute_query() - - # start a list containing the base message and its replies - thread: list[ChatMessage] = [base_message] - thread.extend(replies) - - threads.append(thread) - - return threads - - -def _get_channels_from_teams( - teams: list[Team], -) -> list[Channel]: - channels_list: list[Channel] = [] - for team in teams: - query = team.channels.get() - channels = query.execute_query() - channels_list.extend(channels) - - return channels_list - - -def _construct_semantic_identifier(channel: Channel, top_message: ChatMessage) -> str: - first_poster = ( - top_message.properties.get("from", {}) - .get("user", {}) - .get("displayName", "Unknown User") - ) - channel_name = channel.properties.get("displayName", "Unknown") - thread_subject = top_message.properties.get("subject", "Unknown") - - snippet = parse_html_page_basic(top_message.body.content.rstrip()) - snippet = snippet[:50] + "..." if len(snippet) > 50 else snippet - - return f"{first_poster} in {channel_name} about {thread_subject}: {snippet}" - - -def _convert_thread_to_document( - channel: Channel, - thread: list[ChatMessage], -) -> Document | None: - if len(thread) == 0: - return None - - most_recent_message_datetime: datetime | None = None - top_message = thread[0] - post_members_list: list[BasicExpertInfo] = [] - thread_text = "" - - sorted_thread = sorted(thread, key=get_created_datetime, reverse=True) - - if sorted_thread: - most_recent_message = sorted_thread[0] - most_recent_message_datetime = time_str_to_utc( - most_recent_message.properties["createdDateTime"] - ) - - for message in thread: - # add text and a newline - if message.body.content: - message_text = parse_html_page_basic(message.body.content) - thread_text += message_text - - # if it has a subject, that means its the top level post message, so grab its id, url, and subject - if message.properties["subject"]: - top_message = message - - # check to make sure there is a valid display name - if message.properties["from"]: - if message.properties["from"]["user"]: - if message.properties["from"]["user"]["displayName"]: - message_sender = message.properties["from"]["user"]["displayName"] - # if its not a duplicate, add it to the list - if message_sender not in [ - member.display_name for member in post_members_list - ]: - post_members_list.append( - BasicExpertInfo(display_name=message_sender) - ) - - # if there are no found post members, grab the members from the parent channel - if not post_members_list: - post_members_list = _extract_channel_members(channel) - - if not thread_text: - return None - - semantic_string = _construct_semantic_identifier(channel, top_message) - - post_id = top_message.properties["id"] - web_url = top_message.web_url - - doc = Document( - id=post_id, - sections=[Section(link=web_url, text=thread_text)], - source=DocumentSource.TEAMS, - semantic_identifier=semantic_string, - title="", # teams threads don't really have a "title" - doc_updated_at=most_recent_message_datetime, - primary_owners=post_members_list, - metadata={}, - ) - return doc - - -class TeamsConnector(LoadConnector, PollConnector): - def __init__( - self, - batch_size: int = INDEX_BATCH_SIZE, - teams: list[str] = [], - ) -> None: - self.batch_size = batch_size - self.graph_client: GraphClient | None = None - self.requested_team_list: list[str] = teams - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - teams_client_id = credentials["teams_client_id"] - teams_client_secret = credentials["teams_client_secret"] - teams_directory_id = credentials["teams_directory_id"] - - def _acquire_token_func() -> dict[str, Any]: - """ - Acquire token via MSAL - """ - authority_url = f"https://login.microsoftonline.com/{teams_directory_id}" - app = msal.ConfidentialClientApplication( - authority=authority_url, - client_id=teams_client_id, - client_credential=teams_client_secret, - ) - token = app.acquire_token_for_client( - scopes=["https://graph.microsoft.com/.default"] - ) - return token - - self.graph_client = GraphClient(_acquire_token_func) - return None - - def _get_all_teams(self) -> list[Team]: - if self.graph_client is None: - raise ConnectorMissingCredentialError("Teams") - - teams_list: list[Team] = [] - - teams = self.graph_client.teams.get().execute_query() - - if len(self.requested_team_list) > 0: - adjusted_request_strings = [ - requested_team.replace(" ", "") - for requested_team in self.requested_team_list - ] - teams_list = [ - team - for team in teams - if team.display_name.replace(" ", "") in adjusted_request_strings - ] - else: - teams_list.extend(teams) - - return teams_list - - def _fetch_from_teams( - self, start: datetime | None = None, end: datetime | None = None - ) -> GenerateDocumentsOutput: - if self.graph_client is None: - raise ConnectorMissingCredentialError("Teams") - - teams = self._get_all_teams() - - channels = _get_channels_from_teams( - teams=teams, - ) - - # goes over channels, converts them into Document objects and then yields them in batches - doc_batch: list[Document] = [] - for channel in channels: - thread_list = _get_threads_from_channel(channel, start=start, end=end) - for thread in thread_list: - converted_doc = _convert_thread_to_document(channel, thread) - if converted_doc: - doc_batch.append(converted_doc) - - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch = [] - yield doc_batch - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._fetch_from_teams() - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - start_datetime = datetime.utcfromtimestamp(start) - end_datetime = datetime.utcfromtimestamp(end) - return self._fetch_from_teams(start=start_datetime, end=end_datetime) - - -if __name__ == "__main__": - connector = TeamsConnector(teams=os.environ["TEAMS"].split(",")) - - connector.load_credentials( - { - "teams_client_id": os.environ["TEAMS_CLIENT_ID"], - "teams_client_secret": os.environ["TEAMS_CLIENT_SECRET"], - "teams_directory_id": os.environ["TEAMS_CLIENT_DIRECTORY_ID"], - } - ) - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/web/__init__.py b/backend/danswer/connectors/web/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/web/connector.py b/backend/danswer/connectors/web/connector.py deleted file mode 100644 index 6e76e404acd..00000000000 --- a/backend/danswer/connectors/web/connector.py +++ /dev/null @@ -1,369 +0,0 @@ -import io -import ipaddress -import socket -from enum import Enum -from typing import Any -from typing import cast -from typing import Tuple -from urllib.parse import urljoin -from urllib.parse import urlparse - -import requests -from bs4 import BeautifulSoup -from oauthlib.oauth2 import BackendApplicationClient -from playwright.sync_api import BrowserContext -from playwright.sync_api import Playwright -from playwright.sync_api import sync_playwright -from requests_oauthlib import OAuth2Session # type:ignore -from urllib3.exceptions import MaxRetryError - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.app_configs import WEB_CONNECTOR_OAUTH_CLIENT_ID -from danswer.configs.app_configs import WEB_CONNECTOR_OAUTH_CLIENT_SECRET -from danswer.configs.app_configs import WEB_CONNECTOR_OAUTH_TOKEN_URL -from danswer.configs.app_configs import WEB_CONNECTOR_VALIDATE_URLS -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.extract_file_text import pdf_to_text -from danswer.file_processing.html_utils import web_html_cleanup -from danswer.utils.logger import setup_logger -from danswer.utils.sitemap import list_pages_for_site - -logger = setup_logger() - - -class WEB_CONNECTOR_VALID_SETTINGS(str, Enum): - # Given a base site, index everything under that path - RECURSIVE = "recursive" - # Given a URL, index only the given page - SINGLE = "single" - # Given a sitemap.xml URL, parse all the pages in it - SITEMAP = "sitemap" - # Given a file upload where every line is a URL, parse all the URLs provided - UPLOAD = "upload" - - -def protected_url_check(url: str) -> None: - """Couple considerations: - - DNS mapping changes over time so we don't want to cache the results - - Fetching this is assumed to be relatively fast compared to other bottlenecks like reading - the page or embedding the contents - - To be extra safe, all IPs associated with the URL must be global - - This is to prevent misuse and not explicit attacks - """ - if not WEB_CONNECTOR_VALIDATE_URLS: - return - - parse = urlparse(url) - if parse.scheme != "http" and parse.scheme != "https": - raise ValueError("URL must be of scheme https?://") - - if not parse.hostname: - raise ValueError("URL must include a hostname") - - try: - # This may give a large list of IP addresses for domains with extensive DNS configurations - # such as large distributed systems of CDNs - info = socket.getaddrinfo(parse.hostname, None) - except socket.gaierror as e: - raise ConnectionError(f"DNS resolution failed for {parse.hostname}: {e}") - - for address in info: - ip = address[4][0] - if not ipaddress.ip_address(ip).is_global: - raise ValueError( - f"Non-global IP address detected: {ip}, skipping page {url}. " - f"The Web Connector is not allowed to read loopback, link-local, or private ranges" - ) - - -def check_internet_connection(url: str) -> None: - try: - response = requests.get(url, timeout=3) - response.raise_for_status() - except requests.exceptions.HTTPError as e: - status_code = e.response.status_code - error_msg = { - 400: "Bad Request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not Found", - 500: "Internal Server Error", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - }.get(status_code, "HTTP Error") - raise Exception(f"{error_msg} ({status_code}) for {url} - {e}") - except requests.exceptions.SSLError as e: - cause = ( - e.args[0].reason - if isinstance(e.args, tuple) and isinstance(e.args[0], MaxRetryError) - else e.args - ) - raise Exception(f"SSL error {str(cause)}") - except (requests.RequestException, ValueError) as e: - raise Exception(f"Unable to reach {url} - check your internet connection: {e}") - - -def is_valid_url(url: str) -> bool: - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except ValueError: - return False - - -def get_internal_links( - base_url: str, url: str, soup: BeautifulSoup, should_ignore_pound: bool = True -) -> set[str]: - internal_links = set() - for link in cast(list[dict[str, Any]], soup.find_all("a")): - href = cast(str | None, link.get("href")) - if not href: - continue - - if should_ignore_pound and "#" in href: - href = href.split("#")[0] - - if not is_valid_url(href): - # Relative path handling - href = urljoin(url, href) - - if urlparse(href).netloc == urlparse(url).netloc and base_url in href: - internal_links.add(href) - return internal_links - - -def start_playwright() -> Tuple[Playwright, BrowserContext]: - playwright = sync_playwright().start() - browser = playwright.chromium.launch(headless=True) - - context = browser.new_context() - - if ( - WEB_CONNECTOR_OAUTH_CLIENT_ID - and WEB_CONNECTOR_OAUTH_CLIENT_SECRET - and WEB_CONNECTOR_OAUTH_TOKEN_URL - ): - client = BackendApplicationClient(client_id=WEB_CONNECTOR_OAUTH_CLIENT_ID) - oauth = OAuth2Session(client=client) - token = oauth.fetch_token( - token_url=WEB_CONNECTOR_OAUTH_TOKEN_URL, - client_id=WEB_CONNECTOR_OAUTH_CLIENT_ID, - client_secret=WEB_CONNECTOR_OAUTH_CLIENT_SECRET, - ) - context.set_extra_http_headers( - {"Authorization": "Bearer {}".format(token["access_token"])} - ) - - return playwright, context - - -def extract_urls_from_sitemap(sitemap_url: str) -> list[str]: - response = requests.get(sitemap_url) - response.raise_for_status() - - soup = BeautifulSoup(response.content, "html.parser") - urls = [ - _ensure_absolute_url(sitemap_url, loc_tag.text) - for loc_tag in soup.find_all("loc") - ] - - if len(urls) == 0 and len(soup.find_all("urlset")) == 0: - # the given url doesn't look like a sitemap, let's try to find one - urls = list_pages_for_site(sitemap_url) - - if len(urls) == 0: - raise ValueError( - f"No URLs found in sitemap {sitemap_url}. Try using the 'single' or 'recursive' scraping options instead." - ) - - return urls - - -def _ensure_absolute_url(source_url: str, maybe_relative_url: str) -> str: - if not urlparse(maybe_relative_url).netloc: - return urljoin(source_url, maybe_relative_url) - return maybe_relative_url - - -def _ensure_valid_url(url: str) -> str: - if "://" not in url: - return "https://" + url - return url - - -def _read_urls_file(location: str) -> list[str]: - with open(location, "r") as f: - urls = [_ensure_valid_url(line.strip()) for line in f if line.strip()] - return urls - - -class WebConnector(LoadConnector): - def __init__( - self, - base_url: str, # Can't change this without disrupting existing users - web_connector_type: str = WEB_CONNECTOR_VALID_SETTINGS.RECURSIVE.value, - mintlify_cleanup: bool = True, # Mostly ok to apply to other websites as well - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - self.mintlify_cleanup = mintlify_cleanup - self.batch_size = batch_size - self.recursive = False - - if web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.RECURSIVE.value: - self.recursive = True - self.to_visit_list = [_ensure_valid_url(base_url)] - return - - elif web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.SINGLE.value: - self.to_visit_list = [_ensure_valid_url(base_url)] - - elif web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.SITEMAP: - self.to_visit_list = extract_urls_from_sitemap(_ensure_valid_url(base_url)) - - elif web_connector_type == WEB_CONNECTOR_VALID_SETTINGS.UPLOAD: - logger.warning( - "This is not a UI supported Web Connector flow, " - "are you sure you want to do this?" - ) - self.to_visit_list = _read_urls_file(base_url) - - else: - raise ValueError( - "Invalid Web Connector Config, must choose a valid type between: " "" - ) - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - if credentials: - logger.warning("Unexpected credentials provided for Web Connector") - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - """Traverses through all pages found on the website - and converts them into documents""" - visited_links: set[str] = set() - to_visit: list[str] = self.to_visit_list - - if not to_visit: - raise ValueError("No URLs to visit") - - base_url = to_visit[0] # For the recursive case - doc_batch: list[Document] = [] - - # Needed to report error - at_least_one_doc = False - last_error = None - - playwright, context = start_playwright() - restart_playwright = False - while to_visit: - current_url = to_visit.pop() - if current_url in visited_links: - continue - visited_links.add(current_url) - - try: - protected_url_check(current_url) - except Exception as e: - last_error = f"Invalid URL {current_url} due to {e}" - logger.warning(last_error) - continue - - logger.info(f"Visiting {current_url}") - - try: - check_internet_connection(current_url) - if restart_playwright: - playwright, context = start_playwright() - restart_playwright = False - - if current_url.split(".")[-1] == "pdf": - # PDF files are not checked for links - response = requests.get(current_url) - page_text = pdf_to_text(file=io.BytesIO(response.content)) - - doc_batch.append( - Document( - id=current_url, - sections=[Section(link=current_url, text=page_text)], - source=DocumentSource.WEB, - semantic_identifier=current_url.split("/")[-1], - metadata={}, - ) - ) - continue - - page = context.new_page() - page_response = page.goto(current_url) - final_page = page.url - if final_page != current_url: - logger.info(f"Redirected to {final_page}") - protected_url_check(final_page) - current_url = final_page - if current_url in visited_links: - logger.info("Redirected page already indexed") - continue - visited_links.add(current_url) - - content = page.content() - soup = BeautifulSoup(content, "html.parser") - - if self.recursive: - internal_links = get_internal_links(base_url, current_url, soup) - for link in internal_links: - if link not in visited_links: - to_visit.append(link) - - if page_response and str(page_response.status)[0] in ("4", "5"): - last_error = f"Skipped indexing {current_url} due to HTTP {page_response.status} response" - logger.info(last_error) - continue - - parsed_html = web_html_cleanup(soup, self.mintlify_cleanup) - - doc_batch.append( - Document( - id=current_url, - sections=[ - Section(link=current_url, text=parsed_html.cleaned_text) - ], - source=DocumentSource.WEB, - semantic_identifier=parsed_html.title or current_url, - metadata={}, - ) - ) - - page.close() - except Exception as e: - last_error = f"Failed to fetch '{current_url}': {e}" - logger.error(last_error) - playwright.stop() - restart_playwright = True - continue - - if len(doc_batch) >= self.batch_size: - playwright.stop() - restart_playwright = True - at_least_one_doc = True - yield doc_batch - doc_batch = [] - - if doc_batch: - playwright.stop() - at_least_one_doc = True - yield doc_batch - - if not at_least_one_doc: - if last_error: - raise RuntimeError(last_error) - raise RuntimeError("No valid pages found.") - - -if __name__ == "__main__": - connector = WebConnector("https://docs.danswer.dev/") - document_batches = connector.load_from_state() - print(next(document_batches)) diff --git a/backend/danswer/connectors/wikipedia/__init__.py b/backend/danswer/connectors/wikipedia/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/wikipedia/connector.py b/backend/danswer/connectors/wikipedia/connector.py deleted file mode 100644 index 109e647f113..00000000000 --- a/backend/danswer/connectors/wikipedia/connector.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import ClassVar - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.mediawiki import wiki - - -class WikipediaConnector(wiki.MediaWikiConnector): - """Connector for Wikipedia.""" - - document_source_type: ClassVar[DocumentSource] = DocumentSource.WIKIPEDIA - - def __init__( - self, - categories: list[str], - pages: list[str], - recurse_depth: int, - language_code: str = "en", - batch_size: int = INDEX_BATCH_SIZE, - ) -> None: - super().__init__( - hostname="wikipedia.org", - categories=categories, - pages=pages, - recurse_depth=recurse_depth, - language_code=language_code, - batch_size=batch_size, - ) diff --git a/backend/danswer/connectors/zendesk/__init__.py b/backend/danswer/connectors/zendesk/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/zendesk/connector.py b/backend/danswer/connectors/zendesk/connector.py deleted file mode 100644 index b6d4220b9ce..00000000000 --- a/backend/danswer/connectors/zendesk/connector.py +++ /dev/null @@ -1,176 +0,0 @@ -from typing import Any - -import requests -from retry import retry -from zenpy import Zenpy # type: ignore -from zenpy.lib.api_objects.help_centre_objects import Article # type: ignore - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.app_configs import ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import ( - time_str_to_utc, -) -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import BasicExpertInfo -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.file_processing.html_utils import parse_html_page_basic - - -def _article_to_document(article: Article, content_tags: dict[str, str]) -> Document: - author = BasicExpertInfo( - display_name=article.author.name, email=article.author.email - ) - update_time = time_str_to_utc(article.updated_at) - - # build metadata - metadata: dict[str, str | list[str]] = { - "labels": [str(label) for label in article.label_names if label], - "content_tags": [ - content_tags[tag_id] - for tag_id in article.content_tag_ids - if tag_id in content_tags - ], - } - - # remove empty values - metadata = {k: v for k, v in metadata.items() if v} - - return Document( - id=f"article:{article.id}", - sections=[ - Section(link=article.html_url, text=parse_html_page_basic(article.body)) - ], - source=DocumentSource.ZENDESK, - semantic_identifier=article.title, - doc_updated_at=update_time, - primary_owners=[author], - metadata=metadata, - ) - - -class ZendeskClientNotSetUpError(PermissionError): - def __init__(self) -> None: - super().__init__("Zendesk Client is not set up, was load_credentials called?") - - -class ZendeskConnector(LoadConnector, PollConnector): - def __init__(self, batch_size: int = INDEX_BATCH_SIZE) -> None: - self.batch_size = batch_size - self.zendesk_client: Zenpy | None = None - self.content_tags: dict[str, str] = {} - - @retry(tries=3, delay=2, backoff=2) - def _set_content_tags( - self, subdomain: str, email: str, token: str, page_size: int = 30 - ) -> None: - # Construct the base URL - base_url = f"https://{subdomain}.zendesk.com/api/v2/guide/content_tags" - - # Set up authentication - auth = (f"{email}/token", token) - - # Set up pagination parameters - params = {"page[size]": page_size} - - try: - while True: - # Make the GET request - response = requests.get(base_url, auth=auth, params=params) - - # Check if the request was successful - if response.status_code == 200: - data = response.json() - content_tag_list = data.get("records", []) - for tag in content_tag_list: - self.content_tags[tag["id"]] = tag["name"] - - # Check if there are more pages - if data.get("meta", {}).get("has_more", False): - params["page[after]"] = data["meta"]["after_cursor"] - else: - break - else: - raise Exception(f"Error: {response.status_code}\n{response.text}") - except Exception as e: - raise Exception(f"Error fetching content tags: {str(e)}") - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - # Subdomain is actually the whole URL - subdomain = ( - credentials["zendesk_subdomain"] - .replace("https://", "") - .split(".zendesk.com")[0] - ) - - self.zendesk_client = Zenpy( - subdomain=subdomain, - email=credentials["zendesk_email"], - token=credentials["zendesk_token"], - ) - self._set_content_tags( - subdomain, - credentials["zendesk_email"], - credentials["zendesk_token"], - ) - return None - - def load_from_state(self) -> GenerateDocumentsOutput: - return self.poll_source(None, None) - - def poll_source( - self, start: SecondsSinceUnixEpoch | None, end: SecondsSinceUnixEpoch | None - ) -> GenerateDocumentsOutput: - if self.zendesk_client is None: - raise ZendeskClientNotSetUpError() - - articles = ( - self.zendesk_client.help_center.articles(cursor_pagination=True) - if start is None - else self.zendesk_client.help_center.articles.incremental( - start_time=int(start) - ) - ) - doc_batch = [] - for article in articles: - if ( - article.body is None - or article.draft - or any( - label in ZENDESK_CONNECTOR_SKIP_ARTICLE_LABELS - for label in article.label_names - ) - ): - continue - - doc_batch.append(_article_to_document(article, self.content_tags)) - if len(doc_batch) >= self.batch_size: - yield doc_batch - doc_batch.clear() - - if doc_batch: - yield doc_batch - - -if __name__ == "__main__": - import os - import time - - connector = ZendeskConnector() - connector.load_credentials( - { - "zendesk_subdomain": os.environ["ZENDESK_SUBDOMAIN"], - "zendesk_email": os.environ["ZENDESK_EMAIL"], - "zendesk_token": os.environ["ZENDESK_TOKEN"], - } - ) - - current = time.time() - one_day_ago = current - 24 * 60 * 60 # 1 day - document_batches = connector.poll_source(one_day_ago, current) - - print(next(document_batches)) diff --git a/backend/danswer/connectors/zulip/__init__.py b/backend/danswer/connectors/zulip/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/connectors/zulip/connector.py b/backend/danswer/connectors/zulip/connector.py deleted file mode 100644 index f1d47c57e1a..00000000000 --- a/backend/danswer/connectors/zulip/connector.py +++ /dev/null @@ -1,140 +0,0 @@ -import os -import tempfile -from collections.abc import Generator -from typing import Any -from typing import List -from typing import Tuple - -from zulip import Client - -from danswer.configs.app_configs import INDEX_BATCH_SIZE -from danswer.configs.constants import DocumentSource -from danswer.connectors.interfaces import GenerateDocumentsOutput -from danswer.connectors.interfaces import LoadConnector -from danswer.connectors.interfaces import PollConnector -from danswer.connectors.interfaces import SecondsSinceUnixEpoch -from danswer.connectors.models import ConnectorMissingCredentialError -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.connectors.zulip.schemas import GetMessagesResponse -from danswer.connectors.zulip.schemas import Message -from danswer.connectors.zulip.utils import build_search_narrow -from danswer.connectors.zulip.utils import call_api -from danswer.connectors.zulip.utils import encode_zulip_narrow_operand -from danswer.utils.logger import setup_logger - -# Potential improvements -# 1. Group documents messages into topics, make 1 document per topic per week -# 2. Add end date support once https://github.com/zulip/zulip/issues/25436 is solved - -logger = setup_logger() - - -class ZulipConnector(LoadConnector, PollConnector): - def __init__( - self, realm_name: str, realm_url: str, batch_size: int = INDEX_BATCH_SIZE - ) -> None: - self.batch_size = batch_size - self.realm_name = realm_name - self.realm_url = realm_url if realm_url.endswith("/") else realm_url + "/" - self.client: Client | None = None - - def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None: - contents = credentials["zuliprc_content"] - # The input field converts newlines to spaces in the provided - # zuliprc file. This reverts them back to newlines. - contents_spaces_to_newlines = contents.replace(" ", "\n") - # create a temporary zuliprc file - tempdir = tempfile.tempdir - if tempdir is None: - raise Exception("Could not determine tempfile directory") - config_file = os.path.join(tempdir, f"zuliprc-{self.realm_name}") - with open(config_file, "w") as f: - f.write(contents_spaces_to_newlines) - self.client = Client(config_file=config_file) - return None - - def _message_to_narrow_link(self, m: Message) -> str: - stream_name = m.display_recipient # assume str - stream_operand = encode_zulip_narrow_operand(f"{m.stream_id}-{stream_name}") - topic_operand = encode_zulip_narrow_operand(m.subject) - - narrow_link = f"{self.realm_url}#narrow/stream/{stream_operand}/topic/{topic_operand}/near/{m.id}" - return narrow_link - - def _get_message_batch(self, anchor: str) -> Tuple[bool, List[Message]]: - if self.client is None: - raise ConnectorMissingCredentialError("Zulip") - - logger.info(f"Fetching messages starting with anchor={anchor}") - request = build_search_narrow( - limit=INDEX_BATCH_SIZE, anchor=anchor, apply_md=False - ) - response = GetMessagesResponse(**call_api(self.client.get_messages, request)) - - end = False - if len(response.messages) == 0 or response.found_oldest: - end = True - - # reverse, so that the last message is the new anchor - # and the order is from newest to oldest - return end, response.messages[::-1] - - def _message_to_doc(self, message: Message) -> Document: - text = f"{message.sender_full_name}: {message.content}" - - return Document( - id=f"{message.stream_id}__{message.id}", - sections=[ - Section( - link=self._message_to_narrow_link(message), - text=text, - ) - ], - source=DocumentSource.ZULIP, - semantic_identifier=message.display_recipient or message.subject, - metadata={}, - ) - - def _get_docs( - self, anchor: str, start: SecondsSinceUnixEpoch | None = None - ) -> Generator[Document, None, None]: - message: Message | None = None - while True: - end, message_batch = self._get_message_batch(anchor) - - for message in message_batch: - if start is not None and float(message.timestamp) < start: - return - yield self._message_to_doc(message) - - if end or message is None: - return - - # Last message is oldest, use as next anchor - anchor = str(message.id) - - def _poll_source( - self, start: SecondsSinceUnixEpoch | None, end: SecondsSinceUnixEpoch | None - ) -> GenerateDocumentsOutput: - # Since Zulip doesn't support searching by timestamp, - # we have to always start from the newest message - # and go backwards. - anchor = "newest" - - docs = [] - for doc in self._get_docs(anchor=anchor, start=start): - docs.append(doc) - if len(docs) == self.batch_size: - yield docs - docs = [] - if docs: - yield docs - - def load_from_state(self) -> GenerateDocumentsOutput: - return self._poll_source(start=None, end=None) - - def poll_source( - self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch - ) -> GenerateDocumentsOutput: - return self._poll_source(start, end) diff --git a/backend/danswer/connectors/zulip/schemas.py b/backend/danswer/connectors/zulip/schemas.py deleted file mode 100644 index 385272cb412..00000000000 --- a/backend/danswer/connectors/zulip/schemas.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Any -from typing import List -from typing import Optional - -from pydantic import BaseModel -from pydantic import Field - - -class Message(BaseModel): - id: int - sender_id: int - content: str - recipient_id: int - timestamp: int - client: str - is_me_message: bool - sender_full_name: str - sender_email: str - sender_realm_str: str - subject: str - topic_links: Optional[List[Any]] = None - last_edit_timestamp: Optional[int] - edit_history: Any = None - reactions: List[Any] - submessages: List[Any] - flags: List[str] = Field(default_factory=list) - display_recipient: Optional[str] = None - type: Optional[str] = None - stream_id: int - avatar_url: Optional[str] - content_type: Optional[str] - rendered_content: Optional[str] = None - - -class GetMessagesResponse(BaseModel): - result: str - msg: str - found_anchor: Optional[bool] = None - found_oldest: Optional[bool] = None - found_newest: Optional[bool] = None - history_limited: Optional[bool] = None - anchor: Optional[str] = None - messages: List[Message] = Field(default_factory=list) diff --git a/backend/danswer/connectors/zulip/utils.py b/backend/danswer/connectors/zulip/utils.py deleted file mode 100644 index ffb3894e8ad..00000000000 --- a/backend/danswer/connectors/zulip/utils.py +++ /dev/null @@ -1,102 +0,0 @@ -import time -from collections.abc import Callable -from typing import Any -from typing import Dict -from typing import Optional -from urllib.parse import quote - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class ZulipAPIError(Exception): - def __init__(self, code: Any = None, msg: str | None = None) -> None: - self.code = code - self.msg = msg - - def __str__(self) -> str: - return ( - f"Error occurred during Zulip API call: {self.msg}" + "" - if self.code is None - else f" ({self.code})" - ) - - -class ZulipHTTPError(ZulipAPIError): - def __init__(self, msg: str | None = None, status_code: Any = None) -> None: - super().__init__(code=None, msg=msg) - self.status_code = status_code - - def __str__(self) -> str: - return f"HTTP error {self.status_code} occurred during Zulip API call" - - -def __call_with_retry(fun: Callable, *args: Any, **kwargs: Any) -> Dict[str, Any]: - result = fun(*args, **kwargs) - if result.get("result") == "error": - if result.get("code") == "RATE_LIMIT_HIT": - retry_after = float(result["retry-after"]) + 1 - logger.warn(f"Rate limit hit, retrying after {retry_after} seconds") - time.sleep(retry_after) - return __call_with_retry(fun, *args) - return result - - -def __raise_if_error(response: dict[str, Any]) -> None: - if response.get("result") == "error": - raise ZulipAPIError( - code=response.get("code"), - msg=response.get("msg"), - ) - elif response.get("result") == "http-error": - raise ZulipHTTPError( - msg=response.get("msg"), status_code=response.get("status_code") - ) - - -def call_api(fun: Callable, *args: Any, **kwargs: Any) -> Dict[str, Any]: - response = __call_with_retry(fun, *args, **kwargs) - __raise_if_error(response) - return response - - -def build_search_narrow( - *, - stream: Optional[str] = None, - topic: Optional[str] = None, - limit: int = 100, - content: Optional[str] = None, - apply_md: bool = False, - anchor: str = "newest", -) -> Dict[str, Any]: - narrow_filters = [] - - if stream: - narrow_filters.append({"operator": "stream", "operand": stream}) - - if topic: - narrow_filters.append({"operator": "topic", "operand": topic}) - - if content: - narrow_filters.append({"operator": "has", "operand": content}) - - if not stream and not topic and not content: - narrow_filters.append({"operator": "streams", "operand": "public"}) - - narrow = { - "anchor": anchor, - "num_before": limit, - "num_after": 0, - "narrow": narrow_filters, - } - narrow["apply_markdown"] = apply_md - - return narrow - - -def encode_zulip_narrow_operand(value: str) -> str: - # like https://github.com/zulip/zulip/blob/1577662a6/static/js/hash_util.js#L18-L25 - # safe characters necessary to make Python match Javascript's escaping behaviour, - # see: https://stackoverflow.com/a/74439601 - return quote(value, safe="!~*'()").replace(".", "%2E").replace("%", ".") diff --git a/backend/danswer/danswerbot/slack/blocks.py b/backend/danswer/danswerbot/slack/blocks.py deleted file mode 100644 index da4a867e233..00000000000 --- a/backend/danswer/danswerbot/slack/blocks.py +++ /dev/null @@ -1,502 +0,0 @@ -import re -from datetime import datetime -from re import Match - -import pytz -import timeago # type: ignore -from slack_sdk.models.blocks import ActionsBlock -from slack_sdk.models.blocks import Block -from slack_sdk.models.blocks import ButtonElement -from slack_sdk.models.blocks import ContextBlock -from slack_sdk.models.blocks import DividerBlock -from slack_sdk.models.blocks import HeaderBlock -from slack_sdk.models.blocks import Option -from slack_sdk.models.blocks import RadioButtonsElement -from slack_sdk.models.blocks import SectionBlock -from slack_sdk.models.blocks.basic_components import MarkdownTextObject -from slack_sdk.models.blocks.block_elements import ImageElement - -from danswer.chat.models import DanswerQuote -from danswer.configs.app_configs import DISABLE_GENERATIVE_AI -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import SearchFeedbackType -from danswer.configs.danswerbot_configs import DANSWER_BOT_NUM_DOCS_TO_DISPLAY -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 -from danswer.danswerbot.slack.utils import build_feedback_id -from danswer.danswerbot.slack.utils import remove_slack_text_interactions -from danswer.danswerbot.slack.utils import translate_vespa_highlight_to_slack -from danswer.search.models import SavedSearchDoc -from danswer.utils.text_processing import decode_escapes -from danswer.utils.text_processing import replace_whitespaces_w_space - -_MAX_BLURB_LEN = 45 - - -def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Block: - text = ( - f"Please provide feedback on <{thread_link}|this answer>. " - "This is essential to help us to improve the quality of the answers. " - "Please rate it by clicking the `Helpful` or `Not helpful` button. " - ) - if include_followup: - text += "\n\nIf you need more help, click the `I need more help from a human!` button. " - - text += "\n\nThanks!" - - return SectionBlock(text=text) - - -def _process_citations_for_slack(text: str) -> str: - """ - Converts instances of [[x]](LINK) in the input text to Slack's link format . - - Args: - - text (str): The input string containing markdown links. - - Returns: - - str: The string with markdown links converted to Slack format. - """ - # Regular expression to find all instances of [[x]](LINK) - pattern = r"\[\[(.*?)\]\]\((.*?)\)" - - # Function to replace each found instance with Slack's format - def slack_link_format(match: Match) -> str: - link_text = match.group(1) - link_url = match.group(2) - - # Account for empty link citations - if link_url == "": - return f"[{link_text}]" - return f"<{link_url}|[{link_text}]>" - - # Substitute all matches in the input text - return re.sub(pattern, slack_link_format, text) - - -def _split_text(text: str, limit: int = 3000) -> list[str]: - if len(text) <= limit: - return [text] - - chunks = [] - while text: - if len(text) <= limit: - chunks.append(text) - break - - # Find the nearest space before the limit to avoid splitting a word - split_at = text.rfind(" ", 0, limit) - if split_at == -1: # No spaces found, force split - split_at = limit - - chunk = text[:split_at] - chunks.append(chunk) - text = text[split_at:].lstrip() # Remove leading spaces from the next chunk - - return chunks - - -def clean_markdown_link_text(text: str) -> str: - # Remove any newlines within the text - return text.replace("\n", " ").strip() - - -def build_qa_feedback_block( - message_id: int, feedback_reminder_id: str | None = None -) -> Block: - return ActionsBlock( - block_id=build_feedback_id(message_id), - elements=[ - ButtonElement( - action_id=LIKE_BLOCK_ACTION_ID, - text="👍 Helpful", - style="primary", - value=feedback_reminder_id, - ), - ButtonElement( - action_id=DISLIKE_BLOCK_ACTION_ID, - text="👎 Not helpful", - value=feedback_reminder_id, - ), - ], - ) - - -def get_document_feedback_blocks() -> Block: - return SectionBlock( - text=( - "- 'Up-Boost' if this document is a good source of information and should be " - "shown more often.\n" - "- 'Down-boost' if this document is a poor source of information and should be " - "shown less often.\n" - "- 'Hide' if this document is deprecated and should never be shown anymore." - ), - accessory=RadioButtonsElement( - options=[ - Option( - text=":thumbsup: Up-Boost", - value=SearchFeedbackType.ENDORSE.value, - ), - Option( - text=":thumbsdown: Down-Boost", - value=SearchFeedbackType.REJECT.value, - ), - Option( - text=":x: Hide", - value=SearchFeedbackType.HIDE.value, - ), - ] - ), - ) - - -def build_doc_feedback_block( - message_id: int, - document_id: str, - document_rank: int, -) -> ButtonElement: - feedback_id = build_feedback_id(message_id, document_id, document_rank) - return ButtonElement( - action_id=FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID, - value=feedback_id, - text="Give Feedback", - ) - - -def get_restate_blocks( - msg: str, - is_bot_msg: bool, -) -> list[Block]: - # Only the slash command needs this context because the user doesn't see their own input - if not is_bot_msg: - return [] - - return [ - HeaderBlock(text="Responding to the Query"), - SectionBlock(text=f"```{msg}```"), - ] - - -def build_documents_blocks( - documents: list[SavedSearchDoc], - message_id: int | None, - num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY, -) -> list[Block]: - header_text = ( - "Retrieved Documents" if DISABLE_GENERATIVE_AI else "Reference Documents" - ) - seen_docs_identifiers = set() - section_blocks: list[Block] = [HeaderBlock(text=header_text)] - included_docs = 0 - for rank, d in enumerate(documents): - if d.document_id in seen_docs_identifiers: - continue - seen_docs_identifiers.add(d.document_id) - - doc_sem_id = d.semantic_identifier - if d.source_type == DocumentSource.SLACK.value: - doc_sem_id = "#" + doc_sem_id - - used_chars = len(doc_sem_id) + 3 - match_str = translate_vespa_highlight_to_slack(d.match_highlights, used_chars) - - included_docs += 1 - - header_line = f"{doc_sem_id}\n" - if d.link: - header_line = f"<{d.link}|{doc_sem_id}>\n" - - updated_at_line = "" - if d.updated_at is not None: - updated_at_line = ( - f"_Updated {timeago.format(d.updated_at, datetime.now(pytz.utc))}_\n" - ) - - body_text = f">{remove_slack_text_interactions(match_str)}" - - block_text = header_line + updated_at_line + body_text - - feedback: ButtonElement | dict = {} - if message_id is not None: - feedback = build_doc_feedback_block( - message_id=message_id, - document_id=d.document_id, - document_rank=rank, - ) - - section_blocks.append( - SectionBlock(text=block_text, accessory=feedback), - ) - - section_blocks.append(DividerBlock()) - - if included_docs >= num_docs_to_display: - break - - return section_blocks - - -def build_sources_blocks( - cited_documents: list[tuple[int, SavedSearchDoc]], - num_docs_to_display: int = DANSWER_BOT_NUM_DOCS_TO_DISPLAY, -) -> list[Block]: - if not cited_documents: - return [ - SectionBlock( - text="*Warning*: no sources were cited for this answer, so it may be unreliable 😔" - ) - ] - - seen_docs_identifiers = set() - section_blocks: list[Block] = [SectionBlock(text="*Sources:*")] - included_docs = 0 - for citation_num, d in cited_documents: - if d.document_id in seen_docs_identifiers: - continue - seen_docs_identifiers.add(d.document_id) - - doc_sem_id = d.semantic_identifier - if d.source_type == DocumentSource.SLACK.value: - # for legacy reasons, before the switch to how Slack semantic identifiers are constructed - if "#" not in doc_sem_id: - doc_sem_id = "#" + doc_sem_id - - # this is needed to try and prevent the line from overflowing - # if it does overflow, the image gets placed above the title and it - # looks bad - doc_sem_id = ( - doc_sem_id[:_MAX_BLURB_LEN] + "..." - if len(doc_sem_id) > _MAX_BLURB_LEN - else doc_sem_id - ) - - owner_str = f"By {d.primary_owners[0]}" if d.primary_owners else None - days_ago_str = ( - timeago.format(d.updated_at, datetime.now(pytz.utc)) - if d.updated_at - else None - ) - final_metadata_str = " | ".join( - ([owner_str] if owner_str else []) - + ([days_ago_str] if days_ago_str else []) - ) - - document_title = clean_markdown_link_text(doc_sem_id) - img_link = source_to_github_img_link(d.source_type) - - section_blocks.append( - ContextBlock( - elements=( - [ - ImageElement( - image_url=img_link, - alt_text=f"{d.source_type.value} logo", - ) - ] - if img_link - else [] - ) - + [ - MarkdownTextObject(text=f"{document_title}") - if d.link == "" - else MarkdownTextObject( - text=f"*<{d.link}|[{citation_num}] {document_title}>*\n{final_metadata_str}" - ), - ] - ) - ) - - if included_docs >= num_docs_to_display: - break - - return section_blocks - - -def build_quotes_block( - quotes: list[DanswerQuote], -) -> list[Block]: - quote_lines: list[str] = [] - doc_to_quotes: dict[str, list[str]] = {} - doc_to_link: dict[str, str] = {} - doc_to_sem_id: dict[str, str] = {} - for q in quotes: - quote = q.quote - doc_id = q.document_id - doc_link = q.link - doc_name = q.semantic_identifier - if doc_link and doc_name and doc_id and quote: - if doc_id not in doc_to_quotes: - doc_to_quotes[doc_id] = [quote] - doc_to_link[doc_id] = doc_link - doc_to_sem_id[doc_id] = ( - doc_name - if q.source_type != DocumentSource.SLACK.value - else "#" + doc_name - ) - else: - doc_to_quotes[doc_id].append(quote) - - for doc_id, quote_strs in doc_to_quotes.items(): - quotes_str_clean = [ - replace_whitespaces_w_space(q_str).strip() for q_str in quote_strs - ] - longest_quotes = sorted(quotes_str_clean, key=len, reverse=True)[:5] - single_quote_str = "\n".join([f"```{q_str}```" for q_str in longest_quotes]) - link = doc_to_link[doc_id] - sem_id = doc_to_sem_id[doc_id] - quote_lines.append( - f"<{link}|{sem_id}>:\n{remove_slack_text_interactions(single_quote_str)}" - ) - - if not doc_to_quotes: - return [] - - 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, - quotes: list[DanswerQuote] | None, - source_filters: list[DocumentSource] | None, - time_cutoff: datetime | None, - favor_recent: bool, - skip_quotes: bool = False, - process_message_for_citations: bool = False, - skip_ai_feedback: bool = False, - feedback_reminder_id: str | None = None, -) -> list[Block]: - if DISABLE_GENERATIVE_AI: - return [] - - quotes_blocks: list[Block] = [] - - filter_block: Block | None = None - if time_cutoff or favor_recent or source_filters: - filter_text = "Filters: " - if source_filters: - sources_str = ", ".join([s.value for s in source_filters]) - filter_text += f"`Sources in [{sources_str}]`" - if time_cutoff or favor_recent: - filter_text += " and " - if time_cutoff is not None: - time_str = time_cutoff.strftime("%b %d, %Y") - filter_text += f"`Docs Updated >= {time_str}` " - if favor_recent: - if time_cutoff is not None: - filter_text += "+ " - filter_text += "`Prioritize Recently Updated Docs`" - - filter_block = SectionBlock(text=f"_{filter_text}_") - - if not answer: - answer_blocks = [ - SectionBlock( - text="Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓" - ) - ] - else: - answer_processed = decode_escapes(remove_slack_text_interactions(answer)) - if process_message_for_citations: - answer_processed = _process_citations_for_slack(answer_processed) - answer_blocks = [ - SectionBlock(text=text) for text in _split_text(answer_processed) - ] - if quotes: - quotes_blocks = build_quotes_block(quotes) - - # if no quotes OR `build_quotes_block()` did not give back any blocks - if not quotes_blocks: - quotes_blocks = [ - SectionBlock( - text="*Warning*: no sources were quoted for this answer, so it may be unreliable 😔" - ) - ] - - response_blocks: list[Block] = [] - - if filter_block is not None: - response_blocks.append(filter_block) - - response_blocks.extend(answer_blocks) - - if message_id is not None and not skip_ai_feedback: - response_blocks.append( - build_qa_feedback_block( - message_id=message_id, feedback_reminder_id=feedback_reminder_id - ) - ) - - if not skip_quotes: - response_blocks.extend(quotes_blocks) - - return response_blocks - - -def build_follow_up_block(message_id: int | None) -> ActionsBlock: - return ActionsBlock( - block_id=build_feedback_id(message_id) if message_id is not None else None, - elements=[ - ButtonElement( - action_id=IMMEDIATE_RESOLVED_BUTTON_ACTION_ID, - style="primary", - text="I'm all set!", - ), - ButtonElement( - action_id=FOLLOWUP_BUTTON_ACTION_ID, - style="danger", - text="I need more help from a human!", - ), - ], - ) - - -def build_follow_up_resolved_blocks( - tag_ids: list[str], group_ids: list[str] -) -> list[Block]: - tag_str = " ".join([f"<@{tag}>" for tag in tag_ids]) - if tag_str: - tag_str += " " - - group_str = " ".join([f"" for group_id in group_ids]) - if group_str: - group_str += " " - - text = ( - tag_str - + group_str - + "Someone has requested more help.\n\n:point_down:Please mark this resolved after answering!" - ) - text_block = SectionBlock(text=text) - button_block = ActionsBlock( - elements=[ - ButtonElement( - action_id=FOLLOWUP_BUTTON_RESOLVED_ACTION_ID, - style="primary", - text="Mark Resolved", - ) - ] - ) - return [text_block, button_block] diff --git a/backend/danswer/danswerbot/slack/config.py b/backend/danswer/danswerbot/slack/config.py deleted file mode 100644 index 29e1bd0a8a1..00000000000 --- a/backend/danswer/danswerbot/slack/config.py +++ /dev/null @@ -1,50 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.db.models import SlackBotConfig -from danswer.db.slack_bot_config import fetch_slack_bot_configs - - -VALID_SLACK_FILTERS = [ - "answerable_prefilter", - "well_answered_postfilter", - "questionmark_prefilter", -] - - -def get_slack_bot_config_for_channel( - channel_name: str | None, db_session: Session -) -> SlackBotConfig | None: - if not channel_name: - return None - - slack_bot_configs = fetch_slack_bot_configs(db_session=db_session) - for config in slack_bot_configs: - if channel_name in config.channel_config["channel_names"]: - return config - - return None - - -def validate_channel_names( - channel_names: list[str], - current_slack_bot_config_id: int | None, - db_session: Session, -) -> list[str]: - """Make sure that these channel_names don't exist in other slack bot configs. - Returns a list of cleaned up channel names (e.g. '#' removed if present)""" - slack_bot_configs = fetch_slack_bot_configs(db_session=db_session) - cleaned_channel_names = [ - channel_name.lstrip("#").lower() for channel_name in channel_names - ] - for slack_bot_config in slack_bot_configs: - if slack_bot_config.id == current_slack_bot_config_id: - continue - - for channel_name in cleaned_channel_names: - if channel_name in slack_bot_config.channel_config["channel_names"]: - raise ValueError( - f"Channel name '{channel_name}' already exists in " - "another slack bot config" - ) - - return cleaned_channel_names diff --git a/backend/danswer/danswerbot/slack/constants.py b/backend/danswer/danswerbot/slack/constants.py deleted file mode 100644 index cf2b38032c3..00000000000 --- a/backend/danswer/danswerbot/slack/constants.py +++ /dev/null @@ -1,16 +0,0 @@ -from enum import Enum - -LIKE_BLOCK_ACTION_ID = "feedback-like" -DISLIKE_BLOCK_ACTION_ID = "feedback-dislike" -FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID = "feedback-doc-button" -IMMEDIATE_RESOLVED_BUTTON_ACTION_ID = "immediate-resolved-button" -FOLLOWUP_BUTTON_ACTION_ID = "followup-button" -FOLLOWUP_BUTTON_RESOLVED_ACTION_ID = "followup-resolved-button" -VIEW_DOC_FEEDBACK_ID = "view-doc-feedback" -GENERATE_ANSWER_BUTTON_ACTION_ID = "generate-answer-button" - - -class FeedbackVisibility(str, Enum): - PRIVATE = "private" - ANONYMOUS = "anonymous" - PUBLIC = "public" diff --git a/backend/danswer/danswerbot/slack/handlers/__init__.py b/backend/danswer/danswerbot/slack/handlers/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py b/backend/danswer/danswerbot/slack/handlers/handle_buttons.py deleted file mode 100644 index 732be8df9db..00000000000 --- a/backend/danswer/danswerbot/slack/handlers/handle_buttons.py +++ /dev/null @@ -1,361 +0,0 @@ -from typing import Any -from typing import cast - -from slack_sdk import WebClient -from slack_sdk.models.blocks import SectionBlock -from slack_sdk.models.views import View -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 -from danswer.danswerbot.slack.blocks import build_follow_up_resolved_blocks -from danswer.danswerbot.slack.blocks import get_document_feedback_blocks -from danswer.danswerbot.slack.config import get_slack_bot_config_for_channel -from danswer.danswerbot.slack.constants import DISLIKE_BLOCK_ACTION_ID -from danswer.danswerbot.slack.constants import FeedbackVisibility -from danswer.danswerbot.slack.constants import LIKE_BLOCK_ACTION_ID -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_group_ids_from_names -from danswer.danswerbot.slack.utils import fetch_user_ids_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 -from danswer.db.feedback import create_chat_message_feedback -from danswer.db.feedback import create_doc_retrieval_feedback -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.document_index.factory import get_default_document_index -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def handle_doc_feedback_button( - req: SocketModeRequest, - client: SocketModeClient, -) -> None: - if not (actions := req.payload.get("actions")): - logger.error("Missing actions. Unable to build the source feedback view") - return - - # Extracts the feedback_id coming from the 'source feedback' button - # and generates a new one for the View, to keep track of the doc info - query_event_id, doc_id, doc_rank = decompose_action_id(actions[0].get("value")) - external_id = build_feedback_id(query_event_id, doc_id, doc_rank) - - channel_id = req.payload["container"]["channel_id"] - thread_ts = req.payload["container"]["thread_ts"] - - data = View( - type="modal", - callback_id=VIEW_DOC_FEEDBACK_ID, - external_id=external_id, - # We use the private metadata to keep track of the channel id and thread ts - private_metadata=f"{channel_id}_{thread_ts}", - title="Give Feedback", - blocks=[get_document_feedback_blocks()], - submit="send", - close="cancel", - ) - - client.web_client.views_open( - trigger_id=req.payload["trigger_id"], view=data.to_dict() - ) - - -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=logger, - feedback_reminder_id=None, - ) - - -def handle_slack_feedback( - feedback_id: str, - feedback_type: str, - feedback_msg_reminder: str, - client: WebClient, - user_id_to_post_confirmation: str, - channel_id_to_post_confirmation: str, - thread_ts_to_post_confirmation: str, -) -> None: - engine = get_sqlalchemy_engine() - - message_id, doc_id, doc_rank = decompose_action_id(feedback_id) - - with Session(engine) as db_session: - if feedback_type in [LIKE_BLOCK_ACTION_ID, DISLIKE_BLOCK_ACTION_ID]: - create_chat_message_feedback( - is_positive=feedback_type == LIKE_BLOCK_ACTION_ID, - feedback_text="", - chat_message_id=message_id, - user_id=None, # no "user" for Slack bot for now - db_session=db_session, - ) - remove_scheduled_feedback_reminder( - client=client, - channel=user_id_to_post_confirmation, - msg_id=feedback_msg_reminder, - ) - elif feedback_type in [ - SearchFeedbackType.ENDORSE.value, - SearchFeedbackType.REJECT.value, - SearchFeedbackType.HIDE.value, - ]: - if doc_id is None or doc_rank is None: - raise ValueError("Missing information for Document Feedback") - - if feedback_type == SearchFeedbackType.ENDORSE.value: - feedback = SearchFeedbackType.ENDORSE - elif feedback_type == SearchFeedbackType.REJECT.value: - feedback = SearchFeedbackType.REJECT - else: - feedback = SearchFeedbackType.HIDE - - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - create_doc_retrieval_feedback( - message_id=message_id, - document_id=doc_id, - document_rank=doc_rank, - document_index=document_index, - db_session=db_session, - clicked=False, # Not tracking this for Slack - feedback=feedback, - ) - else: - logger.error(f"Feedback type '{feedback_type}' not supported") - - if get_feedback_visibility() == FeedbackVisibility.PRIVATE or feedback_type not in [ - LIKE_BLOCK_ACTION_ID, - DISLIKE_BLOCK_ACTION_ID, - ]: - client.chat_postEphemeral( - channel=channel_id_to_post_confirmation, - user=user_id_to_post_confirmation, - thread_ts=thread_ts_to_post_confirmation, - text="Thanks for your feedback!", - ) - else: - feedback_response_txt = ( - "liked" if feedback_type == LIKE_BLOCK_ACTION_ID else "disliked" - ) - - if get_feedback_visibility() == FeedbackVisibility.ANONYMOUS: - msg = f"A user has {feedback_response_txt} the AI Answer" - else: - msg = f"<@{user_id_to_post_confirmation}> has {feedback_response_txt} the AI Answer" - - respond_in_thread( - client=client, - channel=channel_id_to_post_confirmation, - text=msg, - thread_ts=thread_ts_to_post_confirmation, - unfurl=False, - ) - - -def handle_followup_button( - req: SocketModeRequest, - client: SocketModeClient, -) -> None: - action_id = None - if actions := req.payload.get("actions"): - action = cast(dict[str, Any], actions[0]) - action_id = cast(str, action.get("block_id")) - - channel_id = req.payload["container"]["channel_id"] - thread_ts = req.payload["container"]["thread_ts"] - - update_emote_react( - emoji=DANSWER_FOLLOWUP_EMOJI, - channel=channel_id, - message_ts=thread_ts, - remove=False, - client=client.web_client, - ) - - tag_ids: list[str] = [] - group_ids: list[str] = [] - with Session(get_sqlalchemy_engine()) as db_session: - channel_name, is_dm = get_channel_name_from_id( - client=client.web_client, channel_id=channel_id - ) - slack_bot_config = get_slack_bot_config_for_channel( - channel_name=channel_name, db_session=db_session - ) - if slack_bot_config: - tag_names = slack_bot_config.channel_config.get("follow_up_tags") - remaining = None - if tag_names: - tag_ids, remaining = fetch_user_ids_from_emails( - tag_names, client.web_client - ) - if remaining: - group_ids, _ = fetch_group_ids_from_names(remaining, client.web_client) - - blocks = build_follow_up_resolved_blocks(tag_ids=tag_ids, group_ids=group_ids) - - respond_in_thread( - client=client.web_client, - channel=channel_id, - text="Received your request for more help", - blocks=blocks, - thread_ts=thread_ts, - unfurl=False, - ) - - if action_id is not None: - message_id, _, _ = decompose_action_id(action_id) - - create_chat_message_feedback( - is_positive=None, - feedback_text="", - chat_message_id=message_id, - user_id=None, # no "user" for Slack bot for now - db_session=db_session, - required_followup=True, - ) - - -def get_clicker_name( - req: SocketModeRequest, - client: SocketModeClient, -) -> str: - clicker_name = req.payload.get("user", {}).get("name", "Someone") - clicker_real_name = None - try: - clicker = client.web_client.users_info(user=req.payload["user"]["id"]) - clicker_real_name = ( - cast(dict, clicker.data).get("user", {}).get("profile", {}).get("real_name") - ) - except Exception: - # Likely a scope issue - pass - - if clicker_real_name: - clicker_name = clicker_real_name - - return clicker_name - - -def handle_followup_resolved_button( - req: SocketModeRequest, - client: SocketModeClient, - immediate: bool = False, -) -> None: - channel_id = req.payload["container"]["channel_id"] - message_ts = req.payload["container"]["message_ts"] - thread_ts = req.payload["container"]["thread_ts"] - - clicker_name = get_clicker_name(req, client) - - update_emote_react( - emoji=DANSWER_FOLLOWUP_EMOJI, - channel=channel_id, - message_ts=thread_ts, - remove=True, - client=client.web_client, - ) - - # Delete the message with the option to mark resolved - if not immediate: - slack_call = make_slack_api_rate_limited(client.web_client.chat_delete) - response = slack_call( - channel=channel_id, - ts=message_ts, - ) - - if not response.get("ok"): - logger.error("Unable to delete message for resolved") - - if immediate: - msg_text = f"{clicker_name} has marked this question as resolved!" - else: - msg_text = ( - f"{clicker_name} has marked this question as resolved! " - f'\n\n You can always click the "I need more help button" to let the team ' - f"know that your problem still needs attention." - ) - - resolved_block = SectionBlock(text=msg_text) - - respond_in_thread( - client=client.web_client, - channel=channel_id, - text="Your request for help as been addressed!", - blocks=[resolved_block], - thread_ts=thread_ts, - unfurl=False, - ) diff --git a/backend/danswer/danswerbot/slack/handlers/handle_message.py b/backend/danswer/danswerbot/slack/handlers/handle_message.py deleted file mode 100644 index 2edbd973553..00000000000 --- a/backend/danswer/danswerbot/slack/handlers/handle_message.py +++ /dev/null @@ -1,235 +0,0 @@ -import datetime - -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from sqlalchemy.orm import Session - -from danswer.configs.danswerbot_configs import DANSWER_BOT_FEEDBACK_REMINDER -from danswer.configs.danswerbot_configs import DANSWER_REACT_EMOJI -from danswer.danswerbot.slack.blocks import get_feedback_reminder_blocks -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 fetch_user_ids_from_emails -from danswer.danswerbot.slack.utils import fetch_user_ids_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 update_emote_react -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.models import SlackBotConfig -from danswer.utils.logger import setup_logger -from shared_configs.configs import SLACK_CHANNEL_ID - -logger_base = setup_logger() - - -def send_msg_ack_to_user(details: SlackMessageInfo, client: WebClient) -> None: - if details.is_bot_msg and details.sender: - respond_in_thread( - client=client, - channel=details.channel_to_respond, - thread_ts=details.msg_to_respond, - receiver_ids=[details.sender], - text="Hi, we're evaluating your query :face_with_monocle:", - ) - return - - update_emote_react( - emoji=DANSWER_REACT_EMOJI, - channel=details.channel_to_respond, - message_ts=details.msg_to_respond, - remove=False, - client=client, - ) - - -def schedule_feedback_reminder( - details: SlackMessageInfo, include_followup: bool, client: WebClient -) -> str | None: - logger = setup_logger(extra={SLACK_CHANNEL_ID: details.channel_to_respond}) - - if not DANSWER_BOT_FEEDBACK_REMINDER: - logger.info("Scheduled feedback reminder disabled...") - return None - - try: - permalink = client.chat_getPermalink( - channel=details.channel_to_respond, - message_ts=details.msg_to_respond, # type:ignore - ) - except SlackApiError as e: - logger.error(f"Unable to generate the feedback reminder permalink: {e}") - return None - - now = datetime.datetime.now() - future = now + datetime.timedelta(minutes=DANSWER_BOT_FEEDBACK_REMINDER) - - try: - response = client.chat_scheduleMessage( - channel=details.sender, # type:ignore - post_at=int(future.timestamp()), - blocks=[ - get_feedback_reminder_blocks( - thread_link=permalink.data["permalink"], # type:ignore - include_followup=include_followup, - ) - ], - text="", - ) - logger.info("Scheduled feedback reminder configured") - return response.data["scheduled_message_id"] # type:ignore - except SlackApiError as e: - logger.error(f"Unable to generate the feedback reminder message: {e}") - return None - - -def remove_scheduled_feedback_reminder( - client: WebClient, channel: str | None, msg_id: str -) -> None: - logger = setup_logger(extra={SLACK_CHANNEL_ID: channel}) - - try: - client.chat_deleteScheduledMessage( - channel=channel, scheduled_message_id=msg_id # type:ignore - ) - logger.info("Scheduled feedback reminder deleted") - except SlackApiError as e: - if e.response["error"] == "invalid_scheduled_message_id": - logger.info( - "Unable to delete the scheduled message. It must have already been posted" - ) - - -def handle_message( - message_info: SlackMessageInfo, - slack_bot_config: SlackBotConfig | None, - client: WebClient, - feedback_reminder_id: str | None, -) -> bool: - """Potentially respond to the user message depending on filters and if an answer was generated - - Returns True if need to respond with an additional message to the user(s) after this - function is finished. True indicates an unexpected failure that needs to be communicated - Query thrown out by filters due to config does not count as a failure that should be notified - Danswer failing to answer/retrieve docs does count and should be notified - """ - channel = message_info.channel_to_respond - - logger = setup_logger(extra={SLACK_CHANNEL_ID: channel}) - - messages = message_info.thread_messages - 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 = 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 - - respond_tag_only = False - respond_member_group_list = None - - channel_conf = None - 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: - if ( - "questionmark_prefilter" in channel_conf["answer_filters"] - and "?" not in messages[-1].message - ): - logger.info( - "Skipping message since it does not contain a question mark" - ) - return False - - logger.info( - "Found slack bot config for channel. Restricting bot to use document " - f"sets: {document_set_names}, " - f"validity checks enabled: {channel_conf.get('answer_filters', 'NA')}" - ) - - respond_tag_only = channel_conf.get("respond_tag_only") or False - respond_member_group_list = channel_conf.get("respond_member_group_list", None) - - if respond_tag_only and not bypass_filters: - logger.info( - "Skipping message since the channel is configured such that " - "DanswerBot only responds to tags" - ) - return False - - # List of user id to send message to, if None, send to everyone in channel - send_to: list[str] | None = None - missing_users: list[str] | None = None - if respond_member_group_list: - send_to, missing_ids = fetch_user_ids_from_emails( - respond_member_group_list, client - ) - - user_ids, missing_users = fetch_user_ids_from_groups(missing_ids, client) - send_to = list(set(send_to + user_ids)) if send_to else user_ids - - if missing_users: - logger.warning(f"Failed to find these users/groups: {missing_users}") - - # If configured to respond to team members only, then cannot be used with a /DanswerBot command - # which would just respond to the sender - if send_to and is_bot_msg: - if sender_id: - respond_in_thread( - client=client, - channel=channel, - receiver_ids=[sender_id], - text="The DanswerBot slash command is not enabled for this channel", - thread_ts=None, - ) - - try: - send_msg_ack_to_user(message_info, client) - except SlackApiError as e: - logger.error(f"Was not able to react to user message due to: {e}") - - 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, - ) - if used_standard_answer: - return False - - # 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, - logger=logger, - feedback_reminder_id=feedback_reminder_id, - ) - return issue_with_regular_answer diff --git a/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py b/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py deleted file mode 100644 index e3a78917a76..00000000000 --- a/backend/danswer/danswerbot/slack/handlers/handle_regular_answer.py +++ /dev/null @@ -1,479 +0,0 @@ -import functools -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 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.db.search_settings import get_current_search_settings -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 RerankingDetails -from danswer.search.models import RetrievalDetails -from danswer.utils.logger import DanswerLoggingAdapter - - -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: DanswerLoggingAdapter, - 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_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, - ) - @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, - user=None, - get_editable=False, - ), - ) - 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) - # Commenting this out because this is only available to the slackbot for now - # later we plan to implement this at the persona level where this will get - # commented back in - # auto_detect_filters = ( - # persona.llm_filter_extraction if persona is not None else True - # ) - auto_detect_filters = ( - slack_bot_config.enable_auto_filters - if slack_bot_config is not None - else False - ) - retrieval_details = RetrievalDetails( - run_search=OptionalSearchSetting.ALWAYS, - real_time=False, - filters=filters, - enable_auto_detect_filters=auto_detect_filters, - ) - - # Always apply reranking settings if it exists, this is the non-streaming flow - with Session(get_sqlalchemy_engine()) as db_session: - saved_search_settings = get_current_search_settings(db_session) - - # This includes throwing out answer via reflexion - answer = _get_answer( - DirectQARequest( - messages=messages, - multilingual_query_expansion=saved_search_settings.multilingual_expansion - if saved_search_settings - else None, - 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, - rerank_settings=RerankingDetails.from_db_model(saved_search_settings) - if saved_search_settings - else None, - ) - ) - except Exception as e: - logger.exception( - f"Unable to process message - did not successfully answer " - f"in {num_retries} attempts" - ) - # Optionally, respond in thread with the error message, Used primarily - # for debugging purposes - if should_respond_with_error_msgs: - respond_in_thread( - client=client, - channel=channel, - 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.notice( - "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.notice( - "Unable to find answer - not responding since the " - "`DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER` env variable is set" - ) - return True - - only_respond_with_citations_or_quotes = ( - channel_conf - and "well_answered_postfilter" in channel_conf.get("answer_filters", []) - ) - has_citations_or_quotes = bool(answer.citations or answer.quotes) - if ( - only_respond_with_citations_or_quotes - and not has_citations_or_quotes - and not message_info.bypass_filters - ): - logger.error( - f"Unable to find citations or quotes to answer: '{answer.rephrase}' - not answering!" - ) - # 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 citations or quotes when trying to answer.", - thread_ts=message_ts_to_respond_to, - ) - 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 diff --git a/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py b/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py deleted file mode 100644 index 8e1663c1a4c..00000000000 --- a/backend/danswer/danswerbot/slack/handlers/handle_standard_answers.py +++ /dev/null @@ -1,215 +0,0 @@ -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 fetch_standard_answer_categories_by_names -from danswer.db.standard_answer import find_matching_standard_answers -from danswer.server.manage.models import StandardAnswer -from danswer.utils.logger import DanswerLoggingAdapter -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def oneoff_standard_answers( - message: str, - slack_bot_categories: list[str], - db_session: Session, -) -> list[StandardAnswer]: - """ - Respond to the user message if it matches any configured standard answers. - - Returns a list of matching StandardAnswers if found, otherwise None. - """ - configured_standard_answers = { - standard_answer - for category in fetch_standard_answer_categories_by_names( - slack_bot_categories, db_session=db_session - ) - for standard_answer in category.standard_answers - } - - matching_standard_answers = find_matching_standard_answers( - query=message, - id_in=[answer.id for answer in configured_standard_answers], - db_session=db_session, - ) - - server_standard_answers = [ - StandardAnswer.from_model(db_answer) for db_answer in matching_standard_answers - ] - return server_standard_answers - - -def handle_standard_answers( - message_info: SlackMessageInfo, - receiver_ids: list[str] | None, - slack_bot_config: SlackBotConfig | None, - prompt: Prompt | None, - logger: DanswerLoggingAdapter, - client: WebClient, - db_session: Session, -) -> bool: - """ - Potentially respond to the user message depending on whether the user's message matches - any of the configured standard answers and also whether those answers have already been - provided in the current thread. - - Returns True if standard answers are found to match the user's message and therefore, - we still need to respond to the users. - """ - # if no channel config, then no standard answers are configured - if not slack_bot_config: - return False - - slack_thread_id = message_info.thread_to_respond - configured_standard_answer_categories = ( - slack_bot_config.standard_answer_categories if slack_bot_config else [] - ) - configured_standard_answers = set( - [ - standard_answer - for standard_answer_category in configured_standard_answer_categories - for standard_answer in standard_answer_category.standard_answers - ] - ) - query_msg = message_info.thread_messages[-1] - - if slack_thread_id is None: - used_standard_answer_ids = set([]) - else: - chat_sessions = get_chat_sessions_by_slack_thread_id( - slack_thread_id=slack_thread_id, - user_id=None, - db_session=db_session, - ) - chat_messages = get_chat_messages_by_sessions( - chat_session_ids=[chat_session.id for chat_session in chat_sessions], - user_id=None, - db_session=db_session, - skip_permission_check=True, - ) - used_standard_answer_ids = set( - [ - standard_answer.id - for chat_message in chat_messages - for standard_answer in chat_message.standard_answers - ] - ) - - usable_standard_answers = configured_standard_answers.difference( - used_standard_answer_ids - ) - 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 diff --git a/backend/danswer/danswerbot/slack/handlers/utils.py b/backend/danswer/danswerbot/slack/handlers/utils.py deleted file mode 100644 index 296b7b90d41..00000000000 --- a/backend/danswer/danswerbot/slack/handlers/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -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, - ) diff --git a/backend/danswer/danswerbot/slack/icons.py b/backend/danswer/danswerbot/slack/icons.py deleted file mode 100644 index 895fcfdc897..00000000000 --- a/backend/danswer/danswerbot/slack/icons.py +++ /dev/null @@ -1,58 +0,0 @@ -from danswer.configs.constants import DocumentSource - - -def source_to_github_img_link(source: DocumentSource) -> str | None: - # TODO: store these images somewhere better - if source == DocumentSource.WEB.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/Web.png" - if source == DocumentSource.FILE.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/File.png" - if source == DocumentSource.GOOGLE_SITES.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/GoogleSites.png" - if source == DocumentSource.SLACK.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Slack.png" - if source == DocumentSource.GMAIL.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Gmail.png" - if source == DocumentSource.GOOGLE_DRIVE.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/GoogleDrive.png" - if source == DocumentSource.GITHUB.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Github.png" - if source == DocumentSource.GITLAB.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Gitlab.png" - if source == DocumentSource.CONFLUENCE.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/Confluence.png" - if source == DocumentSource.JIRA.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/Jira.png" - if source == DocumentSource.NOTION.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Notion.png" - if source == DocumentSource.ZENDESK.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/Zendesk.png" - if source == DocumentSource.GONG.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Gong.png" - if source == DocumentSource.LINEAR.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Linear.png" - if source == DocumentSource.PRODUCTBOARD.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Productboard.webp" - if source == DocumentSource.SLAB.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/SlabLogo.png" - if source == DocumentSource.ZULIP.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Zulip.png" - if source == DocumentSource.GURU.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/Guru.png" - if source == DocumentSource.HUBSPOT.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/HubSpot.png" - if source == DocumentSource.DOCUMENT360.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Document360.png" - if source == DocumentSource.BOOKSTACK.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Bookstack.png" - if source == DocumentSource.LOOPIO.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Loopio.png" - if source == DocumentSource.SHAREPOINT.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/web/public/Sharepoint.png" - if source == DocumentSource.REQUESTTRACKER.value: - # just use file icon for now - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/File.png" - if source == DocumentSource.INGESTION_API.value: - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/File.png" - - return "https://raw.githubusercontent.com/danswer-ai/danswer/main/backend/slackbot_images/File.png" diff --git a/backend/danswer/danswerbot/slack/listener.py b/backend/danswer/danswerbot/slack/listener.py deleted file mode 100644 index c59f4caf1aa..00000000000 --- a/backend/danswer/danswerbot/slack/listener.py +++ /dev/null @@ -1,519 +0,0 @@ -import time -from threading import Event -from typing import Any -from typing import cast - -from slack_sdk import WebClient -from slack_sdk.socket_mode import SocketModeClient -from slack_sdk.socket_mode.request import SocketModeRequest -from slack_sdk.socket_mode.response import SocketModeResponse -from sqlalchemy.orm import Session - -from danswer.configs.constants import MessageType -from danswer.configs.danswerbot_configs import DANSWER_BOT_REPHRASE_MESSAGE -from danswer.configs.danswerbot_configs import DANSWER_BOT_RESPOND_EVERY_CHANNEL -from danswer.configs.danswerbot_configs import NOTIFY_SLACKBOT_NO_ANSWER -from danswer.danswerbot.slack.config import get_slack_bot_config_for_channel -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 VIEW_DOC_FEEDBACK_ID -from danswer.danswerbot.slack.handlers.handle_buttons import handle_doc_feedback_button -from danswer.danswerbot.slack.handlers.handle_buttons import handle_followup_button -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 ( - remove_scheduled_feedback_reminder, -) -from danswer.danswerbot.slack.handlers.handle_message import schedule_feedback_reminder -from danswer.danswerbot.slack.models import SlackMessageInfo -from danswer.danswerbot.slack.tokens import fetch_tokens -from danswer.danswerbot.slack.utils import check_message_limit -from danswer.danswerbot.slack.utils import decompose_action_id -from danswer.danswerbot.slack.utils import get_channel_name_from_id -from danswer.danswerbot.slack.utils import get_danswer_bot_app_id -from danswer.danswerbot.slack.utils import read_slack_thread -from danswer.danswerbot.slack.utils import remove_danswer_bot_tag -from danswer.danswerbot.slack.utils import rephrase_slack_message -from danswer.danswerbot.slack.utils import respond_in_thread -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.search_settings import get_current_search_settings -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.natural_language_processing.search_nlp_models import warm_up_bi_encoder -from danswer.one_shot_answer.models import ThreadMessage -from danswer.search.retrieval.search_runner import download_nltk_data -from danswer.server.manage.models import SlackBotTokens -from danswer.utils.logger import setup_logger -from shared_configs.configs import MODEL_SERVER_HOST -from shared_configs.configs import MODEL_SERVER_PORT -from shared_configs.configs import SLACK_CHANNEL_ID - -logger = setup_logger() - -# In rare cases, some users have been experiencing a massive amount of trivial messages coming through -# to the Slack Bot with trivial messages. Adding this to avoid exploding LLM costs while we track down -# the cause. -_SLACK_GREETINGS_TO_IGNORE = { - "Welcome back!", - "It's going to be a great day.", - "Salutations!", - "Greetings!", - "Feeling great!", - "Hi there", - ":wave:", -} - -# this is always (currently) the user id of Slack's official slackbot -_OFFICIAL_SLACKBOT_USER_ID = "USLACKBOT" - - -def prefilter_requests(req: SocketModeRequest, client: SocketModeClient) -> bool: - """True to keep going, False to ignore this Slack request""" - if req.type == "events_api": - # Verify channel is valid - event = cast(dict[str, Any], req.payload.get("event", {})) - msg = cast(str | None, event.get("text")) - channel = cast(str | None, event.get("channel")) - channel_specific_logger = setup_logger(extra={SLACK_CHANNEL_ID: channel}) - - # This should never happen, but we can't continue without a channel since - # we can't send a response without it - if not channel: - channel_specific_logger.warning("Found message without channel - skipping") - return False - - if not msg: - channel_specific_logger.warning( - "Cannot respond to empty message - skipping" - ) - return False - - if ( - req.payload.setdefault("event", {}).get("user", "") - == _OFFICIAL_SLACKBOT_USER_ID - ): - channel_specific_logger.info( - "Ignoring messages from Slack's official Slackbot" - ) - return False - - if ( - msg in _SLACK_GREETINGS_TO_IGNORE - or remove_danswer_bot_tag(msg, client=client.web_client) - in _SLACK_GREETINGS_TO_IGNORE - ): - channel_specific_logger.error( - f"Ignoring weird Slack greeting message: '{msg}'" - ) - channel_specific_logger.error( - f"Weird Slack greeting message payload: '{req.payload}'" - ) - return False - - # Ensure that the message is a new message of expected type - event_type = event.get("type") - if event_type not in ["app_mention", "message"]: - channel_specific_logger.info( - f"Ignoring non-message event of type '{event_type}' for channel '{channel}'" - ) - return False - - if event_type == "message": - bot_tag_id = get_danswer_bot_app_id(client.web_client) - - is_dm = event.get("channel_type") == "im" - is_tagged = bot_tag_id and bot_tag_id in msg - is_danswer_bot_msg = bot_tag_id and bot_tag_id in event.get("user", "") - - # DanswerBot should never respond to itself - if is_danswer_bot_msg: - logger.info("Ignoring message from DanswerBot") - return False - - # DMs with the bot don't pick up the @DanswerBot so we have to keep the - # caught events_api - if is_tagged and not is_dm: - # Let the tag flow handle this case, don't reply twice - return False - - if event.get("bot_profile"): - channel_name, _ = get_channel_name_from_id( - client=client.web_client, channel_id=channel - ) - - engine = get_sqlalchemy_engine() - with Session(engine) as db_session: - slack_bot_config = get_slack_bot_config_for_channel( - channel_name=channel_name, db_session=db_session - ) - if not slack_bot_config or not slack_bot_config.channel_config.get( - "respond_to_bots" - ): - channel_specific_logger.info("Ignoring message from bot") - return False - - # Ignore things like channel_join, channel_leave, etc. - # NOTE: "file_share" is just a message with a file attachment, so we - # should not ignore it - message_subtype = event.get("subtype") - if message_subtype not in [None, "file_share"]: - channel_specific_logger.info( - f"Ignoring message with subtype '{message_subtype}' since is is a special message type" - ) - return False - - message_ts = event.get("ts") - thread_ts = event.get("thread_ts") - # Pick the root of the thread (if a thread exists) - # Can respond in thread if it's an "im" directly to Danswer or @DanswerBot is tagged - if ( - thread_ts - and message_ts != thread_ts - and event_type != "app_mention" - and event.get("channel_type") != "im" - ): - channel_specific_logger.debug( - "Skipping message since it is not the root of a thread" - ) - return False - - msg = cast(str, event.get("text", "")) - if not msg: - channel_specific_logger.error("Unable to process empty message") - return False - - if req.type == "slash_commands": - # Verify that there's an associated channel - channel = req.payload.get("channel_id") - channel_specific_logger = setup_logger(extra={SLACK_CHANNEL_ID: channel}) - - if not channel: - channel_specific_logger.error( - "Received DanswerBot command without channel - skipping" - ) - return False - - sender = req.payload.get("user_id") - if not sender: - channel_specific_logger.error( - "Cannot respond to DanswerBot command without sender to respond to." - ) - return False - - if not check_message_limit(): - return False - - logger.debug(f"Handling Slack request with Payload: '{req.payload}'") - return True - - -def process_feedback(req: SocketModeRequest, client: SocketModeClient) -> None: - if actions := req.payload.get("actions"): - action = cast(dict[str, Any], actions[0]) - feedback_type = cast(str, action.get("action_id")) - feedback_msg_reminder = cast(str, action.get("value")) - feedback_id = cast(str, action.get("block_id")) - channel_id = cast(str, req.payload["container"]["channel_id"]) - thread_ts = cast(str, req.payload["container"]["thread_ts"]) - else: - logger.error("Unable to process feedback. Action not found") - return - - user_id = cast(str, req.payload["user"]["id"]) - - handle_slack_feedback( - feedback_id=feedback_id, - feedback_type=feedback_type, - feedback_msg_reminder=feedback_msg_reminder, - client=client.web_client, - user_id_to_post_confirmation=user_id, - channel_id_to_post_confirmation=channel_id, - thread_ts_to_post_confirmation=thread_ts, - ) - - query_event_id, _, _ = decompose_action_id(feedback_id) - logger.notice(f"Successfully handled QA feedback for event: {query_event_id}") - - -def build_request_details( - req: SocketModeRequest, client: SocketModeClient -) -> SlackMessageInfo: - if req.type == "events_api": - event = cast(dict[str, Any], req.payload["event"]) - msg = cast(str, event["text"]) - channel = cast(str, event["channel"]) - tagged = event.get("type") == "app_mention" - message_ts = event.get("ts") - thread_ts = event.get("thread_ts") - - msg = remove_danswer_bot_tag(msg, client=client.web_client) - - if DANSWER_BOT_REPHRASE_MESSAGE: - logger.notice(f"Rephrasing Slack message. Original message: {msg}") - try: - msg = rephrase_slack_message(msg) - logger.notice(f"Rephrased message: {msg}") - except Exception as e: - logger.error(f"Error while trying to rephrase the Slack message: {e}") - else: - logger.notice(f"Received Slack message: {msg}") - - if tagged: - logger.debug("User tagged DanswerBot") - - if thread_ts != message_ts and thread_ts is not None: - thread_messages = read_slack_thread( - channel=channel, thread=thread_ts, client=client.web_client - ) - else: - thread_messages = [ - ThreadMessage(message=msg, sender=None, role=MessageType.USER) - ] - - return SlackMessageInfo( - 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, - is_bot_dm=event.get("channel_type") == "im", - ) - - elif req.type == "slash_commands": - channel = req.payload["channel_id"] - msg = req.payload["text"] - sender = req.payload["user_id"] - - single_msg = ThreadMessage(message=msg, sender=None, role=MessageType.USER) - - return SlackMessageInfo( - 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, - is_bot_dm=False, - ) - - raise RuntimeError("Programming fault, this should never happen.") - - -def apologize_for_fail( - details: SlackMessageInfo, - client: SocketModeClient, -) -> None: - respond_in_thread( - client=client.web_client, - channel=details.channel_to_respond, - thread_ts=details.msg_to_respond, - text="Sorry, we weren't able to find anything relevant :cold_sweat:", - ) - - -def process_message( - req: SocketModeRequest, - client: SocketModeClient, - respond_every_channel: bool = DANSWER_BOT_RESPOND_EVERY_CHANNEL, - notify_no_answer: bool = NOTIFY_SLACKBOT_NO_ANSWER, -) -> None: - logger.debug(f"Received Slack request of type: '{req.type}'") - - # Throw out requests that can't or shouldn't be handled - if not prefilter_requests(req, client): - return - - details = build_request_details(req, client) - channel = details.channel_to_respond - channel_name, is_dm = get_channel_name_from_id( - client=client.web_client, channel_id=channel - ) - - engine = get_sqlalchemy_engine() - with Session(engine) as db_session: - slack_bot_config = get_slack_bot_config_for_channel( - channel_name=channel_name, db_session=db_session - ) - - # Be careful about this default, don't want to accidentally spam every channel - # Users should be able to DM slack bot in their private channels though - if ( - slack_bot_config is None - and not respond_every_channel - # Can't have configs for DMs so don't toss them out - and not is_dm - # If /DanswerBot (is_bot_msg) or @DanswerBot (bypass_filters) - # always respond with the default configs - and not (details.is_bot_msg or details.bypass_filters) - ): - return - - follow_up = bool( - slack_bot_config - and slack_bot_config.channel_config - and slack_bot_config.channel_config.get("follow_up_tags") is not None - ) - feedback_reminder_id = schedule_feedback_reminder( - details=details, client=client.web_client, include_followup=follow_up - ) - - failed = handle_message( - message_info=details, - slack_bot_config=slack_bot_config, - client=client.web_client, - feedback_reminder_id=feedback_reminder_id, - ) - - if failed: - if feedback_reminder_id: - remove_scheduled_feedback_reminder( - client=client.web_client, - channel=details.sender, - msg_id=feedback_reminder_id, - ) - # Skipping answering due to pre-filtering is not considered a failure - if notify_no_answer: - apologize_for_fail(details, client) - - -def acknowledge_message(req: SocketModeRequest, client: SocketModeClient) -> None: - response = SocketModeResponse(envelope_id=req.envelope_id) - client.send_socket_mode_response(response) - - -def action_routing(req: SocketModeRequest, client: SocketModeClient) -> None: - if actions := req.payload.get("actions"): - action = cast(dict[str, Any], actions[0]) - - if action["action_id"] in [DISLIKE_BLOCK_ACTION_ID, LIKE_BLOCK_ACTION_ID]: - # AI Answer feedback - return process_feedback(req, client) - elif action["action_id"] == FEEDBACK_DOC_BUTTON_BLOCK_ACTION_ID: - # Activation of the "source feedback" button - return handle_doc_feedback_button(req, client) - elif action["action_id"] == FOLLOWUP_BUTTON_ACTION_ID: - return handle_followup_button(req, client) - elif action["action_id"] == IMMEDIATE_RESOLVED_BUTTON_ACTION_ID: - 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: - if view := req.payload.get("view"): - if view["callback_id"] == VIEW_DOC_FEEDBACK_ID: - return process_feedback(req, client) - - -def process_slack_event(client: SocketModeClient, req: SocketModeRequest) -> None: - # Always respond right away, if Slack doesn't receive these frequently enough - # it will assume the Bot is DEAD!!! :( - acknowledge_message(req, client) - - try: - if req.type == "interactive": - if req.payload.get("type") == "block_actions": - return action_routing(req, client) - elif req.payload.get("type") == "view_submission": - return view_routing(req, client) - elif req.type == "events_api" or req.type == "slash_commands": - return process_message(req, client) - except Exception: - logger.exception("Failed to process slack event") - - -def _get_socket_client(slack_bot_tokens: SlackBotTokens) -> SocketModeClient: - # For more info on how to set this up, checkout the docs: - # https://docs.danswer.dev/slack_bot_setup - return SocketModeClient( - # This app-level token will be used only for establishing a connection - app_token=slack_bot_tokens.app_token, - web_client=WebClient(token=slack_bot_tokens.bot_token), - ) - - -def _initialize_socket_client(socket_client: SocketModeClient) -> None: - socket_client.socket_mode_request_listeners.append(process_slack_event) # type: ignore - - # Establish a WebSocket connection to the Socket Mode servers - logger.notice("Listening for messages from Slack...") - socket_client.connect() - - -# Follow the guide (https://docs.danswer.dev/slack_bot_setup) to set up -# the slack bot in your workspace, and then add the bot to any channels you want to -# try and answer questions for. Running this file will setup Danswer to listen to all -# messages in those channels and attempt to answer them. As of now, it will only respond -# to messages sent directly in the channel - it will not respond to messages sent within a -# thread. -# -# NOTE: we are using Web Sockets so that you can run this from within a firewalled VPC -# without issue. -if __name__ == "__main__": - slack_bot_tokens: SlackBotTokens | None = None - socket_client: SocketModeClient | None = None - - logger.notice("Verifying query preprocessing (NLTK) data is downloaded") - download_nltk_data() - - while True: - try: - latest_slack_bot_tokens = fetch_tokens() - - if latest_slack_bot_tokens != slack_bot_tokens: - if slack_bot_tokens is not None: - logger.notice("Slack Bot tokens have changed - reconnecting") - else: - # This happens on the very first time the listener process comes up - # or the tokens have updated (set up for the first time) - with Session(get_sqlalchemy_engine()) as db_session: - search_settings = get_current_search_settings(db_session) - embedding_model = EmbeddingModel.from_db_model( - search_settings=search_settings, - server_host=MODEL_SERVER_HOST, - server_port=MODEL_SERVER_PORT, - ) - - warm_up_bi_encoder( - embedding_model=embedding_model, - ) - - slack_bot_tokens = latest_slack_bot_tokens - # potentially may cause a message to be dropped, but it is complicated - # to avoid + (1) if the user is changing tokens, they are likely okay with some - # "migration downtime" and (2) if a single message is lost it is okay - # as this should be a very rare occurrence - if socket_client: - socket_client.close() - - socket_client = _get_socket_client(slack_bot_tokens) - _initialize_socket_client(socket_client) - - # Let the handlers run in the background + re-check for token updates every 60 seconds - Event().wait(timeout=60) - except ConfigNotFoundError: - # try again every 30 seconds. This is needed since the user may add tokens - # via the UI at any point in the programs lifecycle - if we just allow it to - # fail, then the user will need to restart the containers after adding tokens - logger.debug( - "Missing Slack Bot tokens - waiting 60 seconds and trying again" - ) - if socket_client: - socket_client.disconnect() - time.sleep(60) diff --git a/backend/danswer/danswerbot/slack/models.py b/backend/danswer/danswerbot/slack/models.py deleted file mode 100644 index e4521a759a7..00000000000 --- a/backend/danswer/danswerbot/slack/models.py +++ /dev/null @@ -1,14 +0,0 @@ -from pydantic import BaseModel - -from danswer.one_shot_answer.models import ThreadMessage - - -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 - is_bot_dm: bool # User is direct messaging to DanswerBot diff --git a/backend/danswer/danswerbot/slack/tokens.py b/backend/danswer/danswerbot/slack/tokens.py deleted file mode 100644 index 5de3a6a0135..00000000000 --- a/backend/danswer/danswerbot/slack/tokens.py +++ /dev/null @@ -1,28 +0,0 @@ -import os -from typing import cast - -from danswer.configs.constants import KV_SLACK_BOT_TOKENS_CONFIG_KEY -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.server.manage.models import SlackBotTokens - - -def fetch_tokens() -> SlackBotTokens: - # first check env variables - app_token = os.environ.get("DANSWER_BOT_SLACK_APP_TOKEN") - bot_token = os.environ.get("DANSWER_BOT_SLACK_BOT_TOKEN") - if app_token and bot_token: - return SlackBotTokens(app_token=app_token, bot_token=bot_token) - - dynamic_config_store = get_dynamic_config_store() - return SlackBotTokens( - **cast(dict, dynamic_config_store.load(key=KV_SLACK_BOT_TOKENS_CONFIG_KEY)) - ) - - -def save_tokens( - tokens: SlackBotTokens, -) -> None: - dynamic_config_store = get_dynamic_config_store() - dynamic_config_store.store( - key=KV_SLACK_BOT_TOKENS_CONFIG_KEY, val=dict(tokens), encrypt=True - ) diff --git a/backend/danswer/danswerbot/slack/utils.py b/backend/danswer/danswerbot/slack/utils.py deleted file mode 100644 index d762dde7826..00000000000 --- a/backend/danswer/danswerbot/slack/utils.py +++ /dev/null @@ -1,556 +0,0 @@ -import logging -import random -import re -import string -import time -from typing import Any -from typing import cast -from typing import Optional - -from retry import retry -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from slack_sdk.models.blocks import Block -from slack_sdk.models.metadata import Metadata -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import DISABLE_TELEMETRY -from danswer.configs.constants import ID_SEPARATOR -from danswer.configs.constants import MessageType -from danswer.configs.danswerbot_configs import DANSWER_BOT_FEEDBACK_VISIBILITY -from danswer.configs.danswerbot_configs import DANSWER_BOT_MAX_QPM -from danswer.configs.danswerbot_configs import DANSWER_BOT_MAX_WAIT_TIME -from danswer.configs.danswerbot_configs import DANSWER_BOT_NUM_RETRIES -from danswer.configs.danswerbot_configs import ( - DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD, -) -from danswer.configs.danswerbot_configs import ( - DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS, -) -from danswer.connectors.slack.utils import make_slack_api_rate_limited -from danswer.connectors.slack.utils import SlackTextCleaner -from danswer.danswerbot.slack.constants import FeedbackVisibility -from danswer.danswerbot.slack.tokens import fetch_tokens -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.users import get_user_by_email -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_default_llms -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.one_shot_answer.models import ThreadMessage -from danswer.prompts.miscellaneous_prompts import SLACK_LANGUAGE_REPHRASE_PROMPT -from danswer.utils.logger import setup_logger -from danswer.utils.telemetry import optional_telemetry -from danswer.utils.telemetry import RecordType -from danswer.utils.text_processing import replace_whitespaces_w_space - -logger = setup_logger() - - -_DANSWER_BOT_APP_ID: str | None = None -_DANSWER_BOT_MESSAGE_COUNT: int = 0 -_DANSWER_BOT_COUNT_START_TIME: float = time.time() - - -def get_danswer_bot_app_id(web_client: WebClient) -> Any: - global _DANSWER_BOT_APP_ID - if _DANSWER_BOT_APP_ID is None: - _DANSWER_BOT_APP_ID = web_client.auth_test().get("user_id") - return _DANSWER_BOT_APP_ID - - -def check_message_limit() -> bool: - """ - This isnt a perfect solution. - High traffic at the end of one period and start of another could cause - the limit to be exceeded. - """ - if DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD == 0: - return True - global _DANSWER_BOT_MESSAGE_COUNT - global _DANSWER_BOT_COUNT_START_TIME - time_since_start = time.time() - _DANSWER_BOT_COUNT_START_TIME - if time_since_start > DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS: - _DANSWER_BOT_MESSAGE_COUNT = 0 - _DANSWER_BOT_COUNT_START_TIME = time.time() - if (_DANSWER_BOT_MESSAGE_COUNT + 1) > DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD: - logger.error( - f"DanswerBot has reached the message limit {DANSWER_BOT_RESPONSE_LIMIT_PER_TIME_PERIOD}" - f" for the time period {DANSWER_BOT_RESPONSE_LIMIT_TIME_PERIOD_SECONDS} seconds." - " These limits are configurable in backend/danswer/configs/danswerbot_configs.py" - ) - return False - _DANSWER_BOT_MESSAGE_COUNT += 1 - return True - - -def rephrase_slack_message(msg: str) -> str: - def _get_rephrase_message() -> list[dict[str, str]]: - messages = [ - { - "role": "user", - "content": SLACK_LANGUAGE_REPHRASE_PROMPT.format(query=msg), - }, - ] - - return messages - - try: - llm, _ = get_default_llms(timeout=5) - except GenAIDisabledException: - logger.warning("Unable to rephrase Slack user message, Gen AI disabled") - return msg - messages = _get_rephrase_message() - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - return model_output - - -def update_emote_react( - emoji: str, - channel: str, - message_ts: str | None, - remove: bool, - client: WebClient, -) -> None: - 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, - ) - 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 remove_danswer_bot_tag(message_str: str, client: WebClient) -> str: - bot_tag_id = get_danswer_bot_app_id(web_client=client) - return re.sub(rf"<@{bot_tag_id}>\s", "", message_str) - - -def get_web_client() -> WebClient: - slack_tokens = fetch_tokens() - return WebClient(token=slack_tokens.bot_token) - - -@retry( - tries=DANSWER_BOT_NUM_RETRIES, - delay=0.25, - backoff=2, - logger=cast(logging.Logger, logger), -) -def respond_in_thread( - client: WebClient, - channel: str, - thread_ts: str | None, - text: str | None = None, - blocks: list[Block] | None = None, - receiver_ids: list[str] | None = None, - metadata: Metadata | None = None, - unfurl: bool = True, -) -> 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) - response = slack_call( - channel=channel, - text=text, - blocks=blocks, - thread_ts=thread_ts, - metadata=metadata, - unfurl_links=unfurl, - unfurl_media=unfurl, - ) - if not response.get("ok"): - raise RuntimeError(f"Failed to post message: {response}") - 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, - user=receiver, - text=text, - blocks=blocks, - thread_ts=thread_ts, - metadata=metadata, - unfurl_links=unfurl, - unfurl_media=unfurl, - ) - if not response.get("ok"): - raise RuntimeError(f"Failed to post message: {response}") - message_ids.append(response["message_ts"]) - - return message_ids - - -def build_feedback_id( - message_id: int, - document_id: str | None = None, - document_rank: int | None = None, -) -> str: - unique_prefix = "".join(random.choice(string.ascii_letters) for _ in range(10)) - if document_id is not None: - if not document_id or document_rank is None: - raise ValueError("Invalid document, missing information") - if ID_SEPARATOR in document_id: - raise ValueError( - "Separator pattern should not already exist in document id" - ) - feedback_id = ID_SEPARATOR.join( - [str(message_id), document_id, str(document_rank)] - ) - else: - feedback_id = str(message_id) - - return unique_prefix + ID_SEPARATOR + feedback_id - - -def decompose_action_id(feedback_id: str) -> tuple[int, str | None, int | None]: - """Decompose into query_id, document_id, document_rank, see above function""" - try: - components = feedback_id.split(ID_SEPARATOR) - if len(components) != 2 and len(components) != 4: - raise ValueError("Feedback ID does not contain right number of elements") - - if len(components) == 2: - return int(components[-1]), None, None - - return int(components[1]), components[2], int(components[3]) - - except Exception as e: - logger.error(e) - raise ValueError("Received invalid Feedback Identifier") - - -def get_view_values(state_values: dict[str, Any]) -> dict[str, str]: - """Extract view values - - Args: - state_values (dict): The Slack view-submission values - - Returns: - dict: keys/values of the view state content - """ - view_values = {} - for _, view_data in state_values.items(): - for k, v in view_data.items(): - if ( - "selected_option" in v - and isinstance(v["selected_option"], dict) - and "value" in v["selected_option"] - ): - view_values[k] = v["selected_option"]["value"] - elif "selected_options" in v and isinstance(v["selected_options"], list): - view_values[k] = [ - x["value"] for x in v["selected_options"] if "value" in x - ] - elif "selected_date" in v: - view_values[k] = v["selected_date"] - elif "value" in v: - view_values[k] = v["value"] - return view_values - - -def translate_vespa_highlight_to_slack(match_strs: list[str], used_chars: int) -> str: - def _replace_highlight(s: str) -> str: - s = re.sub(r"(?<=[^\s])(.*?)", r"\1", s) - s = s.replace("", "*").replace("", "*") - return s - - final_matches = [ - replace_whitespaces_w_space(_replace_highlight(match_str)).strip() - for match_str in match_strs - if match_str - ] - combined = "... ".join(final_matches) - - # Slack introduces "Show More" after 300 on desktop which is ugly - # But don't trim the message if there is still a highlight after 300 chars - remaining = 300 - used_chars - if len(combined) > remaining and "*" not in combined[remaining:]: - combined = combined[: remaining - 3] + "..." - - return combined - - -def remove_slack_text_interactions(slack_str: str) -> str: - slack_str = SlackTextCleaner.replace_tags_basic(slack_str) - slack_str = SlackTextCleaner.replace_channels_basic(slack_str) - slack_str = SlackTextCleaner.replace_special_mentions(slack_str) - slack_str = SlackTextCleaner.replace_links(slack_str) - slack_str = SlackTextCleaner.replace_special_catchall(slack_str) - slack_str = SlackTextCleaner.add_zero_width_whitespace_after_tag(slack_str) - return slack_str - - -def get_channel_from_id(client: WebClient, channel_id: str) -> dict[str, Any]: - response = client.conversations_info(channel=channel_id) - response.validate() - return response["channel"] - - -def get_channel_name_from_id( - client: WebClient, channel_id: str -) -> tuple[str | None, bool]: - try: - channel_info = get_channel_from_id(client, channel_id) - name = channel_info.get("name") - is_dm = any([channel_info.get("is_im"), channel_info.get("is_mpim")]) - return name, is_dm - except SlackApiError as e: - logger.exception(f"Couldn't fetch channel name from id: {channel_id}") - raise e - - -def fetch_user_ids_from_emails( - user_emails: list[str], client: WebClient -) -> tuple[list[str], list[str]]: - user_ids: list[str] = [] - failed_to_find: list[str] = [] - for email in user_emails: - try: - user = client.users_lookupByEmail(email=email) - user_ids.append(user.data["user"]["id"]) # type: ignore - except Exception: - logger.error(f"Was not able to find slack user by email: {email}") - failed_to_find.append(email) - - return user_ids, failed_to_find - - -def fetch_user_ids_from_groups( - given_names: list[str], client: WebClient -) -> tuple[list[str], list[str]]: - user_ids: list[str] = [] - failed_to_find: list[str] = [] - try: - response = client.usergroups_list() - if not isinstance(response.data, dict): - logger.error("Error fetching user groups") - return user_ids, given_names - - all_group_data = response.data.get("usergroups", []) - name_id_map = {d["name"]: d["id"] for d in all_group_data} - handle_id_map = {d["handle"]: d["id"] for d in all_group_data} - for given_name in given_names: - group_id = name_id_map.get(given_name) or handle_id_map.get( - given_name.lstrip("@") - ) - if not group_id: - failed_to_find.append(given_name) - continue - try: - response = client.usergroups_users_list(usergroup=group_id) - if isinstance(response.data, dict): - user_ids.extend(response.data.get("users", [])) - else: - failed_to_find.append(given_name) - except Exception as e: - logger.error(f"Error fetching user group ids: {str(e)}") - failed_to_find.append(given_name) - except Exception as e: - logger.error(f"Error fetching user groups: {str(e)}") - failed_to_find = given_names - - return user_ids, failed_to_find - - -def fetch_group_ids_from_names( - given_names: list[str], client: WebClient -) -> tuple[list[str], list[str]]: - group_data: list[str] = [] - failed_to_find: list[str] = [] - - try: - response = client.usergroups_list() - if not isinstance(response.data, dict): - logger.error("Error fetching user groups") - return group_data, given_names - - all_group_data = response.data.get("usergroups", []) - - name_id_map = {d["name"]: d["id"] for d in all_group_data} - handle_id_map = {d["handle"]: d["id"] for d in all_group_data} - - for given_name in given_names: - id = handle_id_map.get(given_name.lstrip("@")) - id = id or name_id_map.get(given_name) - if id: - group_data.append(id) - else: - failed_to_find.append(given_name) - except Exception as e: - failed_to_find = given_names - logger.error(f"Error fetching user groups: {str(e)}") - - return group_data, failed_to_find - - -def fetch_user_semantic_id_from_id( - user_id: str | None, client: WebClient -) -> str | None: - if not user_id: - return None - - response = make_slack_api_rate_limited(client.users_info)(user=user_id) - if not response["ok"]: - return None - - user: dict = cast(dict[Any, dict], response.data).get("user", {}) - - return ( - user.get("real_name") - or user.get("name") - or user.get("profile", {}).get("email") - ) - - -def read_slack_thread( - channel: str, thread: str, client: WebClient -) -> list[ThreadMessage]: - thread_messages: list[ThreadMessage] = [] - response = client.conversations_replies(channel=channel, ts=thread) - replies = cast(dict, response.data).get("messages", []) - for reply in replies: - if "user" in reply and "bot_id" not in reply: - message = remove_danswer_bot_tag(reply["text"], client=client) - user_sem_id = fetch_user_semantic_id_from_id(reply["user"], client) - message_type = MessageType.USER - else: - self_app_id = get_danswer_bot_app_id(client) - - # Only include bot messages from Danswer, other bots are not taken in as context - if self_app_id != reply.get("user"): - continue - - blocks = reply["blocks"] - if len(blocks) <= 1: - continue - - # For the old flow, the useful block is the second one after the header block that says AI Answer - if reply["blocks"][0]["text"]["text"] == "AI Answer": - message = reply["blocks"][1]["text"]["text"] - else: - # for the new flow, the answer is the first block - message = reply["blocks"][0]["text"]["text"] - - if message.startswith("_Filters"): - if len(blocks) <= 2: - continue - message = reply["blocks"][2]["text"]["text"] - - user_sem_id = "Assistant" - message_type = MessageType.ASSISTANT - - thread_messages.append( - ThreadMessage(message=message, sender=user_sem_id, role=message_type) - ) - - return thread_messages - - -def slack_usage_report(action: str, sender_id: str | None, client: WebClient) -> None: - if DISABLE_TELEMETRY: - return - - danswer_user = None - sender_email = None - try: - sender_email = client.users_info(user=sender_id).data["user"]["profile"]["email"] # type: ignore - except Exception: - logger.warning("Unable to find sender email") - - if sender_email is not None: - with Session(get_sqlalchemy_engine()) as db_session: - danswer_user = get_user_by_email(email=sender_email, db_session=db_session) - - optional_telemetry( - record_type=RecordType.USAGE, - data={"action": action}, - user_id=str(danswer_user.id) if danswer_user else "Non-Danswer-Or-No-Auth-User", - ) - - -class SlackRateLimiter: - def __init__(self) -> None: - self.max_qpm: int | None = DANSWER_BOT_MAX_QPM - self.max_wait_time = DANSWER_BOT_MAX_WAIT_TIME - self.active_question = 0 - self.last_reset_time = time.time() - self.waiting_questions: list[int] = [] - - def refill(self) -> None: - # If elapsed time is greater than the period, reset the active question count - if (time.time() - self.last_reset_time) > 60: - self.active_question = 0 - self.last_reset_time = time.time() - - def notify( - self, client: WebClient, channel: str, position: int, thread_ts: Optional[str] - ) -> None: - respond_in_thread( - client=client, - channel=channel, - receiver_ids=None, - text=f"Your question has been queued. You are in position {position}.\n" - f"Please wait a moment :hourglass_flowing_sand:", - thread_ts=thread_ts, - ) - - def is_available(self) -> bool: - if self.max_qpm is None: - return True - - self.refill() - return self.active_question < self.max_qpm - - def acquire_slot(self) -> None: - self.active_question += 1 - - def init_waiter(self) -> tuple[int, int]: - func_randid = random.getrandbits(128) - self.waiting_questions.append(func_randid) - position = self.waiting_questions.index(func_randid) + 1 - - return func_randid, position - - def waiter(self, func_randid: int) -> None: - if self.max_qpm is None: - return - - wait_time = 0 - while ( - self.active_question >= self.max_qpm - or self.waiting_questions[0] != func_randid - ): - if wait_time > self.max_wait_time: - raise TimeoutError - time.sleep(2) - wait_time += 2 - self.refill() - - del self.waiting_questions[0] - - -def get_feedback_visibility() -> FeedbackVisibility: - try: - return FeedbackVisibility(DANSWER_BOT_FEEDBACK_VISIBILITY.lower()) - except ValueError: - return FeedbackVisibility.PRIVATE diff --git a/backend/danswer/db/__init__.py b/backend/danswer/db/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/db/auth.py b/backend/danswer/db/auth.py deleted file mode 100644 index 161fdc8f10b..00000000000 --- a/backend/danswer/db/auth.py +++ /dev/null @@ -1,66 +0,0 @@ -from collections.abc import AsyncGenerator -from collections.abc import Callable -from typing import Any -from typing import Dict - -from fastapi import Depends -from fastapi_users.models import UP -from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase -from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase -from sqlalchemy import func -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select - -from danswer.auth.schemas import UserRole -from danswer.db.engine import get_async_session -from danswer.db.engine import get_sqlalchemy_async_engine -from danswer.db.models import AccessToken -from danswer.db.models import OAuthAccount -from danswer.db.models import User -from danswer.utils.variable_functionality import ( - fetch_versioned_implementation_with_fallback, -) - - -def get_default_admin_user_emails() -> list[str]: - """Returns a list of emails who should default to Admin role. - Only used in the EE version. For MIT, just return empty list.""" - get_default_admin_user_emails_fn: Callable[ - [], list[str] - ] = fetch_versioned_implementation_with_fallback( - "danswer.auth.users", "get_default_admin_user_emails_", lambda: [] - ) - return get_default_admin_user_emails_fn() - - -async def get_user_count() -> int: - async with AsyncSession(get_sqlalchemy_async_engine()) as asession: - stmt = select(func.count(User.id)) - result = await asession.execute(stmt) - user_count = result.scalar() - if user_count is None: - raise RuntimeError("Was not able to fetch the user count.") - return user_count - - -# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow -class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase): - async def create(self, create_dict: Dict[str, Any]) -> UP: - user_count = await get_user_count() - if user_count == 0 or create_dict["email"] in get_default_admin_user_emails(): - create_dict["role"] = UserRole.ADMIN - else: - create_dict["role"] = UserRole.BASIC - return await super().create(create_dict) - - -async def get_user_db( - session: AsyncSession = Depends(get_async_session), -) -> AsyncGenerator[SQLAlchemyUserAdminDB, None]: - yield SQLAlchemyUserAdminDB(session, User, OAuthAccount) # type: ignore - - -async def get_access_token_db( - session: AsyncSession = Depends(get_async_session), -) -> AsyncGenerator[SQLAlchemyAccessTokenDatabase, None]: - yield SQLAlchemyAccessTokenDatabase(session, AccessToken) # type: ignore diff --git a/backend/danswer/db/chat.py b/backend/danswer/db/chat.py deleted file mode 100644 index 3cb991dd43b..00000000000 --- a/backend/danswer/db/chat.py +++ /dev/null @@ -1,728 +0,0 @@ -from collections.abc import Sequence -from datetime import datetime -from datetime import timedelta -from uuid import UUID - -from sqlalchemy import and_ -from sqlalchemy import delete -from sqlalchemy import desc -from sqlalchemy import func -from sqlalchemy import nullsfirst -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.exc import MultipleResultsFound -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import Session - -from danswer.auth.schemas import UserRole -from danswer.chat.models import DocumentRelevance -from danswer.configs.chat_configs import HARD_DELETE_CHATS -from danswer.configs.constants import MessageType -from danswer.db.models import ChatMessage -from danswer.db.models import ChatMessage__SearchDoc -from danswer.db.models import ChatSession -from danswer.db.models import ChatSessionSharedStatus -from danswer.db.models import Prompt -from danswer.db.models import SearchDoc -from danswer.db.models import SearchDoc as DBSearchDoc -from danswer.db.models import ToolCall -from danswer.db.models import User -from danswer.db.pg_file_store import delete_lobj_by_name -from danswer.file_store.models import FileDescriptor -from danswer.llm.override_models import LLMOverride -from danswer.llm.override_models import PromptOverride -from danswer.search.models import RetrievalDocs -from danswer.search.models import SavedSearchDoc -from danswer.search.models import SearchDoc as ServerSearchDoc -from danswer.server.query_and_chat.models import ChatMessageDetail -from danswer.tools.tool_runner import ToolCallFinalResult -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def get_chat_session_by_id( - chat_session_id: int, - user_id: UUID | None, - db_session: Session, - include_deleted: bool = False, - is_shared: bool = False, -) -> ChatSession: - stmt = select(ChatSession).where(ChatSession.id == chat_session_id) - - if is_shared: - stmt = stmt.where(ChatSession.shared_status == ChatSessionSharedStatus.PUBLIC) - else: - # if user_id is None, assume this is an admin who should be able - # to view all chat sessions - if user_id is not None: - stmt = stmt.where( - or_(ChatSession.user_id == user_id, ChatSession.user_id.is_(None)) - ) - - result = db_session.execute(stmt) - chat_session = result.scalar_one_or_none() - - if not chat_session: - raise ValueError("Invalid Chat Session ID provided") - - if not include_deleted and chat_session.deleted: - raise ValueError("Chat session has been deleted") - - 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_first_messages_for_chat_sessions( - chat_session_ids: list[int], db_session: Session -) -> dict[int, str]: - subquery = ( - select(ChatMessage.chat_session_id, func.min(ChatMessage.id).label("min_id")) - .where( - and_( - ChatMessage.chat_session_id.in_(chat_session_ids), - ChatMessage.message_type == MessageType.USER, # Select USER messages - ) - ) - .group_by(ChatMessage.chat_session_id) - .subquery() - ) - - query = select(ChatMessage.chat_session_id, ChatMessage.message).join( - subquery, - (ChatMessage.chat_session_id == subquery.c.chat_session_id) - & (ChatMessage.id == subquery.c.min_id), - ) - - first_messages = db_session.execute(query).all() - return dict([(row.chat_session_id, row.message) for row in first_messages]) - - -def get_chat_sessions_by_user( - user_id: UUID | None, - deleted: bool | None, - db_session: Session, - only_one_shot: bool = False, - limit: int = 50, -) -> list[ChatSession]: - stmt = select(ChatSession).where(ChatSession.user_id == user_id) - - if only_one_shot: - stmt = stmt.where(ChatSession.one_shot.is_(True)) - else: - stmt = stmt.where(ChatSession.one_shot.is_(False)) - - stmt = stmt.order_by(desc(ChatSession.time_created)) - - if deleted is not None: - stmt = stmt.where(ChatSession.deleted == deleted) - - if limit: - stmt = stmt.limit(limit) - - result = db_session.execute(stmt) - chat_sessions = result.scalars().all() - - return list(chat_sessions) - - -def delete_search_doc_message_relationship( - message_id: int, db_session: Session -) -> None: - db_session.query(ChatMessage__SearchDoc).filter( - ChatMessage__SearchDoc.chat_message_id == message_id - ).delete(synchronize_session=False) - - db_session.commit() - - -def delete_tool_call_for_message_id(message_id: int, db_session: Session) -> None: - stmt = delete(ToolCall).where(ToolCall.message_id == message_id) - db_session.execute(stmt) - db_session.commit() - - -def delete_orphaned_search_docs(db_session: Session) -> None: - orphaned_docs = ( - db_session.query(SearchDoc) - .outerjoin(ChatMessage__SearchDoc) - .filter(ChatMessage__SearchDoc.chat_message_id.is_(None)) - .all() - ) - for doc in orphaned_docs: - db_session.delete(doc) - db_session.commit() - - -def delete_messages_and_files_from_chat_session( - chat_session_id: int, db_session: Session -) -> None: - # Select messages older than cutoff_time with files - messages_with_files = db_session.execute( - select(ChatMessage.id, ChatMessage.files).where( - ChatMessage.chat_session_id == chat_session_id, - ) - ).fetchall() - - for id, files in messages_with_files: - delete_tool_call_for_message_id(message_id=id, db_session=db_session) - delete_search_doc_message_relationship(message_id=id, db_session=db_session) - for file_info in files or {}: - lobj_name = file_info.get("id") - if lobj_name: - logger.info(f"Deleting file with name: {lobj_name}") - delete_lobj_by_name(lobj_name, db_session) - - db_session.execute( - delete(ChatMessage).where(ChatMessage.chat_session_id == chat_session_id) - ) - db_session.commit() - - delete_orphaned_search_docs(db_session) - - -def create_chat_session( - db_session: Session, - description: str, - user_id: UUID | 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, - persona_id=persona_id, - description=description, - llm_override=llm_override, - prompt_override=prompt_override, - one_shot=one_shot, - danswerbot_flow=danswerbot_flow, - slack_thread_id=slack_thread_id, - ) - - db_session.add(chat_session) - db_session.commit() - - return chat_session - - -def update_chat_session( - db_session: Session, - user_id: UUID | None, - chat_session_id: int, - description: str | None = None, - sharing_status: ChatSessionSharedStatus | None = None, -) -> ChatSession: - chat_session = get_chat_session_by_id( - chat_session_id=chat_session_id, user_id=user_id, db_session=db_session - ) - - if chat_session.deleted: - raise ValueError("Trying to rename a deleted chat session") - - if description is not None: - chat_session.description = description - if sharing_status is not None: - chat_session.shared_status = sharing_status - - db_session.commit() - - return chat_session - - -def delete_chat_session( - user_id: UUID | None, - chat_session_id: int, - db_session: Session, - hard_delete: bool = HARD_DELETE_CHATS, -) -> None: - if hard_delete: - delete_messages_and_files_from_chat_session(chat_session_id, db_session) - db_session.execute(delete(ChatSession).where(ChatSession.id == chat_session_id)) - else: - chat_session = get_chat_session_by_id( - chat_session_id=chat_session_id, user_id=user_id, db_session=db_session - ) - chat_session.deleted = True - - db_session.commit() - - -def delete_chat_sessions_older_than(days_old: int, db_session: Session) -> None: - cutoff_time = datetime.utcnow() - timedelta(days=days_old) - old_sessions = db_session.execute( - select(ChatSession.user_id, ChatSession.id).where( - ChatSession.time_created < cutoff_time - ) - ).fetchall() - - for user_id, session_id in old_sessions: - delete_chat_session(user_id, session_id, db_session, hard_delete=True) - - -def get_chat_message( - chat_message_id: int, - user_id: UUID | None, - db_session: Session, -) -> ChatMessage: - stmt = select(ChatMessage).where(ChatMessage.id == chat_message_id) - - result = db_session.execute(stmt) - chat_message = result.scalar_one_or_none() - - if not chat_message: - raise ValueError("Invalid Chat Message specified") - - chat_user = chat_message.chat_session.user - expected_user_id = chat_user.id if chat_user is not None else None - - if expected_user_id != user_id: - logger.error( - f"User {user_id} tried to fetch a chat message that does not belong to them" - ) - raise ValueError("Chat message does not belong to user") - - 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_search_docs_for_chat_message( - chat_message_id: int, db_session: Session -) -> list[SearchDoc]: - stmt = ( - select(SearchDoc) - .join( - ChatMessage__SearchDoc, ChatMessage__SearchDoc.search_doc_id == SearchDoc.id - ) - .where(ChatMessage__SearchDoc.chat_message_id == chat_message_id) - ) - - return list(db_session.scalars(stmt).all()) - - -def get_chat_messages_by_session( - chat_session_id: int, - user_id: UUID | None, - db_session: Session, - skip_permission_check: bool = False, - prefetch_tool_calls: bool = False, -) -> list[ChatMessage]: - if not skip_permission_check: - 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 == chat_session_id) - .order_by(nullsfirst(ChatMessage.parent_message)) - ) - - if prefetch_tool_calls: - stmt = stmt.options(joinedload(ChatMessage.tool_calls)) - result = db_session.scalars(stmt).unique().all() - else: - result = db_session.scalars(stmt).all() - - return list(result) - - -def get_or_create_root_message( - chat_session_id: int, - db_session: Session, -) -> ChatMessage: - try: - root_message: ChatMessage | None = ( - db_session.query(ChatMessage) - .filter( - ChatMessage.chat_session_id == chat_session_id, - ChatMessage.parent_message.is_(None), - ) - .one_or_none() - ) - except MultipleResultsFound: - raise Exception( - "Multiple root messages found for chat session. Data inconsistency detected." - ) - - if root_message is not None: - return root_message - else: - new_root_message = ChatMessage( - chat_session_id=chat_session_id, - prompt_id=None, - parent_message=None, - latest_child_message=None, - message="", - token_count=0, - message_type=MessageType.SYSTEM, - ) - db_session.add(new_root_message) - db_session.commit() - return new_root_message - - -def reserve_message_id( - db_session: Session, - chat_session_id: int, - parent_message: int, - message_type: MessageType, -) -> int: - # Create an empty chat message - empty_message = ChatMessage( - chat_session_id=chat_session_id, - parent_message=parent_message, - latest_child_message=None, - message="", - token_count=0, - message_type=message_type, - ) - - # Add the empty message to the session - db_session.add(empty_message) - - # Flush the session to get an ID for the new chat message - db_session.flush() - - # Get the ID of the newly created message - new_id = empty_message.id - - return new_id - - -def create_new_chat_message( - chat_session_id: int, - parent_message: ChatMessage, - message: str, - prompt_id: int | None, - token_count: int, - message_type: MessageType, - db_session: Session, - files: list[FileDescriptor] | None = None, - rephrased_query: str | None = None, - error: str | None = None, - reference_docs: list[DBSearchDoc] | None = None, - alternate_assistant_id: int | None = None, - # Maps the citation number [n] to the DB SearchDoc - citations: dict[int, int] | None = None, - tool_calls: list[ToolCall] | None = None, - commit: bool = True, - reserved_message_id: int | None = None, - overridden_model: str | None = None, -) -> ChatMessage: - if reserved_message_id is not None: - # Edit existing message - existing_message = db_session.query(ChatMessage).get(reserved_message_id) - if existing_message is None: - raise ValueError(f"No message found with id {reserved_message_id}") - - existing_message.chat_session_id = chat_session_id - existing_message.parent_message = parent_message.id - existing_message.message = message - existing_message.rephrased_query = rephrased_query - existing_message.prompt_id = prompt_id - existing_message.token_count = token_count - existing_message.message_type = message_type - existing_message.citations = citations - existing_message.files = files - existing_message.tool_calls = tool_calls if tool_calls else [] - existing_message.error = error - existing_message.alternate_assistant_id = alternate_assistant_id - existing_message.overridden_model = overridden_model - - new_chat_message = existing_message - else: - # Create new message - new_chat_message = ChatMessage( - chat_session_id=chat_session_id, - parent_message=parent_message.id, - latest_child_message=None, - message=message, - rephrased_query=rephrased_query, - prompt_id=prompt_id, - token_count=token_count, - message_type=message_type, - citations=citations, - files=files, - tool_calls=tool_calls if tool_calls else [], - error=error, - alternate_assistant_id=alternate_assistant_id, - overridden_model=overridden_model, - ) - db_session.add(new_chat_message) - - # SQL Alchemy will propagate this to update the reference_docs' foreign keys - if reference_docs: - new_chat_message.search_docs = reference_docs - - # Flush the session to get an ID for the new chat message - db_session.flush() - - parent_message.latest_child_message = new_chat_message.id - if commit: - db_session.commit() - - return new_chat_message - - -def set_as_latest_chat_message( - chat_message: ChatMessage, - user_id: UUID | None, - db_session: Session, -) -> None: - parent_message_id = chat_message.parent_message - - if parent_message_id is None: - raise RuntimeError( - f"Trying to set a latest message without parent, message id: {chat_message.id}" - ) - - parent_message = get_chat_message( - chat_message_id=parent_message_id, user_id=user_id, db_session=db_session - ) - - parent_message.latest_child_message = chat_message.id - - db_session.commit() - - -def attach_files_to_chat_message( - chat_message: ChatMessage, - files: list[FileDescriptor], - db_session: Session, - commit: bool = True, -) -> None: - chat_message.files = files - if commit: - db_session.commit() - - -def get_prompt_by_id( - prompt_id: int, - user: User | None, - db_session: Session, - include_deleted: bool = False, -) -> Prompt: - stmt = select(Prompt).where(Prompt.id == prompt_id) - - # if user is not specified OR they are an admin, they should - # have access to all prompts, so this where clause is not needed - if user and user.role != UserRole.ADMIN: - stmt = stmt.where(or_(Prompt.user_id == user.id, Prompt.user_id.is_(None))) - - if not include_deleted: - stmt = stmt.where(Prompt.deleted.is_(False)) - - result = db_session.execute(stmt) - prompt = result.scalar_one_or_none() - - if prompt is None: - raise ValueError( - f"Prompt with ID {prompt_id} does not exist or does not belong to user" - ) - - return prompt - - -def get_doc_query_identifiers_from_model( - search_doc_ids: list[int], - chat_session: ChatSession, - user_id: UUID | None, - db_session: Session, -) -> list[tuple[str, int]]: - """Given a list of search_doc_ids""" - search_docs = ( - db_session.query(SearchDoc).filter(SearchDoc.id.in_(search_doc_ids)).all() - ) - - if user_id != chat_session.user_id: - logger.error( - f"Docs referenced are from a chat session not belonging to user {user_id}" - ) - raise ValueError("Docs references do not belong to user") - - try: - if any( - [ - doc.chat_messages[0].chat_session_id != chat_session.id - for doc in search_docs - ] - ): - raise ValueError("Invalid reference doc, not from this chat session.") - except IndexError: - # This happens when the doc has no chat_messages associated with it. - # which happens as an edge case where the chat message failed to save - # This usually happens when the LLM fails either immediately or partially through. - raise RuntimeError("Chat session failed, please start a new session.") - - doc_query_identifiers = [(doc.document_id, doc.chunk_ind) for doc in search_docs] - - return doc_query_identifiers - - -def update_search_docs_table_with_relevance( - db_session: Session, - reference_db_search_docs: list[SearchDoc], - relevance_summary: DocumentRelevance, -) -> None: - for search_doc in reference_db_search_docs: - relevance_data = relevance_summary.relevance_summaries.get( - search_doc.document_id - ) - if relevance_data is not None: - db_session.execute( - update(SearchDoc) - .where(SearchDoc.id == search_doc.id) - .values( - is_relevant=relevance_data.relevant, - relevance_explanation=relevance_data.content, - ) - ) - db_session.commit() - - -def create_db_search_doc( - server_search_doc: ServerSearchDoc, - db_session: Session, -) -> SearchDoc: - db_search_doc = SearchDoc( - document_id=server_search_doc.document_id, - chunk_ind=server_search_doc.chunk_ind, - semantic_id=server_search_doc.semantic_identifier, - link=server_search_doc.link, - blurb=server_search_doc.blurb, - source_type=server_search_doc.source_type, - boost=server_search_doc.boost, - hidden=server_search_doc.hidden, - doc_metadata=server_search_doc.metadata, - is_relevant=server_search_doc.is_relevant, - relevance_explanation=server_search_doc.relevance_explanation, - # For docs further down that aren't reranked, we can't use the retrieval score - score=server_search_doc.score or 0.0, - match_highlights=server_search_doc.match_highlights, - updated_at=server_search_doc.updated_at, - primary_owners=server_search_doc.primary_owners, - secondary_owners=server_search_doc.secondary_owners, - is_internet=server_search_doc.is_internet, - ) - - db_session.add(db_search_doc) - db_session.commit() - return db_search_doc - - -def get_db_search_doc_by_id(doc_id: int, db_session: Session) -> DBSearchDoc | None: - """There are no safety checks here like user permission etc., use with caution""" - search_doc = db_session.query(SearchDoc).filter(SearchDoc.id == doc_id).first() - return search_doc - - -def translate_db_search_doc_to_server_search_doc( - db_search_doc: SearchDoc, - remove_doc_content: bool = False, -) -> SavedSearchDoc: - return SavedSearchDoc( - db_doc_id=db_search_doc.id, - document_id=db_search_doc.document_id, - chunk_ind=db_search_doc.chunk_ind, - semantic_identifier=db_search_doc.semantic_id, - link=db_search_doc.link, - blurb=db_search_doc.blurb if not remove_doc_content else "", - source_type=db_search_doc.source_type, - boost=db_search_doc.boost, - hidden=db_search_doc.hidden, - metadata=db_search_doc.doc_metadata if not remove_doc_content else {}, - score=db_search_doc.score, - match_highlights=( - db_search_doc.match_highlights if not remove_doc_content else [] - ), - relevance_explanation=db_search_doc.relevance_explanation, - is_relevant=db_search_doc.is_relevant, - updated_at=db_search_doc.updated_at if not remove_doc_content else None, - primary_owners=db_search_doc.primary_owners if not remove_doc_content else [], - secondary_owners=( - db_search_doc.secondary_owners if not remove_doc_content else [] - ), - is_internet=db_search_doc.is_internet, - ) - - -def get_retrieval_docs_from_chat_message( - chat_message: ChatMessage, remove_doc_content: bool = False -) -> RetrievalDocs: - top_documents = [ - translate_db_search_doc_to_server_search_doc( - db_doc, remove_doc_content=remove_doc_content - ) - for db_doc in chat_message.search_docs - ] - top_documents = sorted(top_documents, key=lambda doc: doc.score, reverse=True) # type: ignore - return RetrievalDocs(top_documents=top_documents) - - -def translate_db_message_to_chat_message_detail( - chat_message: ChatMessage, - remove_doc_content: bool = False, -) -> ChatMessageDetail: - chat_msg_detail = ChatMessageDetail( - chat_session_id=chat_message.chat_session_id, - message_id=chat_message.id, - parent_message=chat_message.parent_message, - latest_child_message=chat_message.latest_child_message, - message=chat_message.message, - rephrased_query=chat_message.rephrased_query, - context_docs=get_retrieval_docs_from_chat_message( - chat_message, remove_doc_content=remove_doc_content - ), - message_type=chat_message.message_type, - time_sent=chat_message.time_sent, - citations=chat_message.citations, - files=chat_message.files or [], - tool_calls=[ - ToolCallFinalResult( - tool_name=tool_call.tool_name, - tool_args=tool_call.tool_arguments, - tool_result=tool_call.tool_result, - ) - for tool_call in chat_message.tool_calls - ], - alternate_assistant_id=chat_message.alternate_assistant_id, - overridden_model=chat_message.overridden_model, - ) - - return chat_msg_detail diff --git a/backend/danswer/db/connector.py b/backend/danswer/db/connector.py deleted file mode 100644 index 89e6977103e..00000000000 --- a/backend/danswer/db/connector.py +++ /dev/null @@ -1,270 +0,0 @@ -from typing import cast - -from sqlalchemy import and_ -from sqlalchemy import exists -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy.orm import aliased -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import DEFAULT_PRUNING_FREQ -from danswer.configs.constants import DocumentSource -from danswer.connectors.models import InputType -from danswer.db.models import Connector -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import IndexAttempt -from danswer.server.documents.models import ConnectorBase -from danswer.server.documents.models import ObjectCreationIdResponse -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def check_connectors_exist(db_session: Session) -> bool: - # Connector 0 is created on server startup as a default for ingestion - # it will always exist and we don't need to count it for this - stmt = select(exists(Connector).where(Connector.id > 0)) - result = db_session.execute(stmt) - return result.scalar() or False - - -def fetch_connectors( - db_session: Session, - sources: list[DocumentSource] | None = None, - input_types: list[InputType] | None = None, -) -> list[Connector]: - stmt = select(Connector) - if sources is not None: - stmt = stmt.where(Connector.source.in_(sources)) - if input_types is not None: - stmt = stmt.where(Connector.input_type.in_(input_types)) - results = db_session.scalars(stmt) - return list(results.all()) - - -def connector_by_name_source_exists( - connector_name: str, source: DocumentSource, db_session: Session -) -> bool: - stmt = select(Connector).where( - Connector.name == connector_name, Connector.source == source - ) - result = db_session.execute(stmt) - connector = result.scalar_one_or_none() - return connector is not None - - -def fetch_connector_by_id(connector_id: int, db_session: Session) -> Connector | None: - stmt = select(Connector).where(Connector.id == connector_id) - result = db_session.execute(stmt) - connector = result.scalar_one_or_none() - return connector - - -def fetch_ingestion_connector_by_name( - connector_name: str, db_session: Session -) -> Connector | None: - stmt = ( - select(Connector) - .where(Connector.name == connector_name) - .where(Connector.source == DocumentSource.INGESTION_API) - ) - result = db_session.execute(stmt) - connector = result.scalar_one_or_none() - return connector - - -def create_connector( - db_session: Session, - connector_data: ConnectorBase, -) -> ObjectCreationIdResponse: - if connector_by_name_source_exists( - connector_data.name, connector_data.source, db_session - ): - raise ValueError( - "Connector by this name already exists, duplicate naming not allowed." - ) - - connector = Connector( - name=connector_data.name, - source=connector_data.source, - input_type=connector_data.input_type, - connector_specific_config=connector_data.connector_specific_config, - refresh_freq=connector_data.refresh_freq, - indexing_start=connector_data.indexing_start, - prune_freq=connector_data.prune_freq, - ) - db_session.add(connector) - db_session.commit() - - return ObjectCreationIdResponse(id=connector.id) - - -def update_connector( - connector_id: int, - connector_data: ConnectorBase, - db_session: Session, -) -> Connector | None: - connector = fetch_connector_by_id(connector_id, db_session) - if connector is None: - return None - - if connector_data.name != connector.name and connector_by_name_source_exists( - connector_data.name, connector_data.source, db_session - ): - raise ValueError( - "Connector by this name already exists, duplicate naming not allowed." - ) - - connector.name = connector_data.name - connector.source = connector_data.source - connector.input_type = connector_data.input_type - connector.connector_specific_config = connector_data.connector_specific_config - connector.refresh_freq = connector_data.refresh_freq - connector.prune_freq = ( - connector_data.prune_freq - if connector_data.prune_freq is not None - else DEFAULT_PRUNING_FREQ - ) - - db_session.commit() - return connector - - -def delete_connector( - db_session: Session, - connector_id: int, -) -> StatusResponse[int]: - """Only used in special cases (e.g. a connector is in a bad state and we need to delete it). - Be VERY careful using this, as it could lead to a bad state if not used correctly. - """ - connector = fetch_connector_by_id(connector_id, db_session) - if connector is None: - return StatusResponse( - success=True, message="Connector was already deleted", data=connector_id - ) - - db_session.delete(connector) - return StatusResponse( - success=True, message="Connector deleted successfully", data=connector_id - ) - - -def get_connector_credential_ids( - connector_id: int, - db_session: Session, -) -> list[int]: - connector = fetch_connector_by_id(connector_id, db_session) - if connector is None: - raise ValueError(f"Connector by id {connector_id} does not exist") - - return [association.credential.id for association in connector.credentials] - - -def fetch_latest_index_attempt_by_connector( - db_session: Session, - source: DocumentSource | None = None, -) -> list[IndexAttempt]: - latest_index_attempts: list[IndexAttempt] = [] - - if source: - connectors = fetch_connectors(db_session, sources=[source]) - else: - connectors = fetch_connectors(db_session) - - if not connectors: - return [] - - for connector in connectors: - latest_index_attempt = ( - db_session.query(IndexAttempt) - .join(ConnectorCredentialPair) - .filter(ConnectorCredentialPair.connector_id == connector.id) - .order_by(IndexAttempt.time_updated.desc()) - .first() - ) - - if latest_index_attempt is not None: - latest_index_attempts.append(latest_index_attempt) - - return latest_index_attempts - - -def fetch_latest_index_attempts_by_status( - db_session: Session, -) -> list[IndexAttempt]: - subquery = ( - db_session.query( - IndexAttempt.connector_credential_pair_id, - IndexAttempt.status, - func.max(IndexAttempt.time_updated).label("time_updated"), - ) - .group_by(IndexAttempt.connector_credential_pair_id) - .group_by(IndexAttempt.status) - .subquery() - ) - - alias = aliased(IndexAttempt, subquery) - - query = db_session.query(IndexAttempt).join( - alias, - and_( - IndexAttempt.connector_credential_pair_id - == alias.connector_credential_pair_id, - IndexAttempt.status == alias.status, - IndexAttempt.time_updated == alias.time_updated, - ), - ) - - return cast(list[IndexAttempt], query.all()) - - -def fetch_unique_document_sources(db_session: Session) -> list[DocumentSource]: - distinct_sources = db_session.query(Connector.source).distinct().all() - - sources = [ - source[0] - for source in distinct_sources - if source[0] != DocumentSource.INGESTION_API - ] - - return sources - - -def create_initial_default_connector(db_session: Session) -> None: - default_connector_id = 0 - default_connector = fetch_connector_by_id(default_connector_id, db_session) - if default_connector is not None: - if ( - default_connector.source != DocumentSource.INGESTION_API - or default_connector.input_type != InputType.LOAD_STATE - or default_connector.refresh_freq is not None - or default_connector.name != "Ingestion API" - or default_connector.connector_specific_config != {} - or default_connector.prune_freq is not None - ): - logger.warning( - "Default connector does not have expected values. Updating to proper state." - ) - # Ensure default connector has correct valuesg - default_connector.source = DocumentSource.INGESTION_API - default_connector.input_type = InputType.LOAD_STATE - default_connector.refresh_freq = None - default_connector.name = "Ingestion API" - default_connector.connector_specific_config = {} - default_connector.prune_freq = None - db_session.commit() - return - - # Create a new default connector if it doesn't exist - connector = Connector( - id=default_connector_id, - name="Ingestion API", - source=DocumentSource.INGESTION_API, - input_type=InputType.LOAD_STATE, - connector_specific_config={}, - refresh_freq=None, - prune_freq=None, - ) - db_session.add(connector) - db_session.commit() diff --git a/backend/danswer/db/connector_credential_pair.py b/backend/danswer/db/connector_credential_pair.py deleted file mode 100644 index a6848232caf..00000000000 --- a/backend/danswer/db/connector_credential_pair.py +++ /dev/null @@ -1,474 +0,0 @@ -from datetime import datetime - -from fastapi import HTTPException -from sqlalchemy import delete -from sqlalchemy import desc -from sqlalchemy import exists -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy.orm import aliased -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.connector import fetch_connector_by_id -from danswer.db.credentials import fetch_credential_by_id -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import IndexAttempt -from danswer.db.models import IndexingStatus -from danswer.db.models import IndexModelStatus -from danswer.db.models import SearchSettings -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup__ConnectorCredentialPair -from danswer.db.models import UserRole -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _add_user_filters( - stmt: Select, user: User | None, get_editable: bool = True -) -> Select: - # If user is None, assume the user is an admin or auth is disabled - if user is None or user.role == UserRole.ADMIN: - return stmt - - UG__CCpair = aliased(UserGroup__ConnectorCredentialPair) - User__UG = aliased(User__UserGroup) - - """ - Here we select cc_pairs by relation: - User -> User__UserGroup -> UserGroup__ConnectorCredentialPair -> - ConnectorCredentialPair - """ - stmt = stmt.outerjoin(UG__CCpair).outerjoin( - User__UG, - User__UG.user_group_id == UG__CCpair.user_group_id, - ) - - """ - Filter cc_pairs by: - - if the user is in the user_group that owns the cc_pair - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out cc_pairs that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all cc_pairs in the groups the user is a curator - for (as well as public cc_pairs) - """ - where_clause = User__UG.user_id == user.id - if user.role == UserRole.CURATOR and get_editable: - where_clause &= User__UG.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id) - if user.role == UserRole.CURATOR: - user_groups = user_groups.where( - User__UserGroup.is_curator == True # noqa: E712 - ) - where_clause &= ( - ~exists() - .where(UG__CCpair.cc_pair_id == ConnectorCredentialPair.id) - .where(~UG__CCpair.user_group_id.in_(user_groups)) - .correlate(ConnectorCredentialPair) - ) - else: - where_clause |= ConnectorCredentialPair.is_public == True # noqa: E712 - - return stmt.where(where_clause) - - -def get_connector_credential_pairs( - db_session: Session, - include_disabled: bool = True, - user: User | None = None, - get_editable: bool = True, - ids: list[int] | None = None, -) -> list[ConnectorCredentialPair]: - stmt = select(ConnectorCredentialPair).distinct() - stmt = _add_user_filters(stmt, user, get_editable) - if not include_disabled: - stmt = stmt.where( - ConnectorCredentialPair.status == ConnectorCredentialPairStatus.ACTIVE - ) # noqa - if ids: - stmt = stmt.where(ConnectorCredentialPair.id.in_(ids)) - results = db_session.scalars(stmt) - return list(results.all()) - - -def get_cc_pair_groups_for_ids( - db_session: Session, - cc_pair_ids: list[int], - user: User | None = None, - get_editable: bool = True, -) -> list[UserGroup__ConnectorCredentialPair]: - stmt = select(UserGroup__ConnectorCredentialPair).distinct() - stmt = stmt.outerjoin( - ConnectorCredentialPair, - UserGroup__ConnectorCredentialPair.cc_pair_id == ConnectorCredentialPair.id, - ) - stmt = _add_user_filters(stmt, user, get_editable) - stmt = stmt.where(UserGroup__ConnectorCredentialPair.cc_pair_id.in_(cc_pair_ids)) - return list(db_session.scalars(stmt).all()) - - -def get_connector_credential_pair( - connector_id: int, - credential_id: int, - db_session: Session, - user: User | None = None, - get_editable: bool = True, -) -> ConnectorCredentialPair | None: - stmt = select(ConnectorCredentialPair) - stmt = _add_user_filters(stmt, user, get_editable) - stmt = stmt.where(ConnectorCredentialPair.connector_id == connector_id) - stmt = stmt.where(ConnectorCredentialPair.credential_id == credential_id) - result = db_session.execute(stmt) - return result.scalar_one_or_none() - - -def get_connector_credential_source_from_id( - cc_pair_id: int, - db_session: Session, - user: User | None = None, - get_editable: bool = True, -) -> DocumentSource | None: - stmt = select(ConnectorCredentialPair) - stmt = _add_user_filters(stmt, user, get_editable) - stmt = stmt.where(ConnectorCredentialPair.id == cc_pair_id) - result = db_session.execute(stmt) - cc_pair = result.scalar_one_or_none() - return cc_pair.connector.source if cc_pair else None - - -def get_connector_credential_pair_from_id( - cc_pair_id: int, - db_session: Session, - user: User | None = None, - get_editable: bool = True, -) -> ConnectorCredentialPair | None: - stmt = select(ConnectorCredentialPair).distinct() - stmt = _add_user_filters(stmt, user, get_editable) - stmt = stmt.where(ConnectorCredentialPair.id == cc_pair_id) - result = db_session.execute(stmt) - return result.scalar_one_or_none() - - -def get_last_successful_attempt_time( - connector_id: int, - credential_id: int, - search_settings: SearchSettings, - db_session: Session, -) -> float: - """Gets the timestamp of the last successful index run stored in - the CC Pair row in the database""" - if search_settings.status == IndexModelStatus.PRESENT: - connector_credential_pair = get_connector_credential_pair( - connector_id, credential_id, db_session - ) - if ( - connector_credential_pair is None - or connector_credential_pair.last_successful_index_time is None - ): - return 0.0 - - return connector_credential_pair.last_successful_index_time.timestamp() - - # For Secondary Index we don't keep track of the latest success, so have to calculate it live - attempt = ( - db_session.query(IndexAttempt) - .join( - ConnectorCredentialPair, - IndexAttempt.connector_credential_pair_id == ConnectorCredentialPair.id, - ) - .filter( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - IndexAttempt.search_settings_id == search_settings.id, - IndexAttempt.status == IndexingStatus.SUCCESS, - ) - .order_by(IndexAttempt.time_started.desc()) - .first() - ) - if not attempt or not attempt.time_started: - connector = fetch_connector_by_id(connector_id, db_session) - if connector and connector.indexing_start: - return connector.indexing_start.timestamp() - return 0.0 - - return attempt.time_started.timestamp() - - -"""Updates""" - - -def _update_connector_credential_pair( - db_session: Session, - cc_pair: ConnectorCredentialPair, - status: ConnectorCredentialPairStatus | None = None, - net_docs: int | None = None, - run_dt: datetime | None = None, -) -> None: - # simply don't update last_successful_index_time if run_dt is not specified - # at worst, this would result in re-indexing documents that were already indexed - if run_dt is not None: - cc_pair.last_successful_index_time = run_dt - if net_docs is not None: - cc_pair.total_docs_indexed += net_docs - if status is not None: - cc_pair.status = status - db_session.commit() - - -def update_connector_credential_pair_from_id( - db_session: Session, - cc_pair_id: int, - status: ConnectorCredentialPairStatus | None = None, - net_docs: int | None = None, - run_dt: datetime | None = None, -) -> None: - cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session) - if not cc_pair: - logger.warning( - f"Attempted to update pair for Connector Credential Pair '{cc_pair_id}'" - f" but it does not exist" - ) - return - - _update_connector_credential_pair( - db_session=db_session, - cc_pair=cc_pair, - status=status, - net_docs=net_docs, - run_dt=run_dt, - ) - - -def update_connector_credential_pair( - db_session: Session, - connector_id: int, - credential_id: int, - status: ConnectorCredentialPairStatus | None = None, - net_docs: int | None = None, - run_dt: datetime | None = None, -) -> None: - cc_pair = get_connector_credential_pair(connector_id, credential_id, db_session) - if not cc_pair: - logger.warning( - f"Attempted to update pair for connector id {connector_id} " - f"and credential id {credential_id}" - ) - return - - _update_connector_credential_pair( - db_session=db_session, - cc_pair=cc_pair, - status=status, - net_docs=net_docs, - run_dt=run_dt, - ) - - -def delete_connector_credential_pair__no_commit( - db_session: Session, - connector_id: int, - credential_id: int, -) -> None: - stmt = delete(ConnectorCredentialPair).where( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - ) - db_session.execute(stmt) - - -def associate_default_cc_pair(db_session: Session) -> None: - existing_association = ( - db_session.query(ConnectorCredentialPair) - .filter( - ConnectorCredentialPair.connector_id == 0, - ConnectorCredentialPair.credential_id == 0, - ) - .one_or_none() - ) - if existing_association is not None: - return - - association = ConnectorCredentialPair( - connector_id=0, - credential_id=0, - name="DefaultCCPair", - status=ConnectorCredentialPairStatus.ACTIVE, - is_public=True, - ) - db_session.add(association) - db_session.commit() - - -def _relate_groups_to_cc_pair__no_commit( - db_session: Session, - cc_pair_id: int, - user_group_ids: list[int], -) -> None: - for group_id in user_group_ids: - db_session.add( - UserGroup__ConnectorCredentialPair( - user_group_id=group_id, cc_pair_id=cc_pair_id - ) - ) - - -def add_credential_to_connector( - db_session: Session, - user: User | None, - connector_id: int, - credential_id: int, - cc_pair_name: str | None, - is_public: bool, - groups: list[int] | None, -) -> StatusResponse: - connector = fetch_connector_by_id(connector_id, db_session) - credential = fetch_credential_by_id(credential_id, user, db_session) - - if connector is None: - raise HTTPException(status_code=404, detail="Connector does not exist") - - if credential is None: - raise HTTPException( - status_code=401, - detail="Credential does not exist or does not belong to user", - ) - - existing_association = ( - db_session.query(ConnectorCredentialPair) - .filter( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - ) - .one_or_none() - ) - if existing_association is not None: - return StatusResponse( - success=False, - message=f"Connector already has Credential {credential_id}", - data=connector_id, - ) - - association = ConnectorCredentialPair( - connector_id=connector_id, - credential_id=credential_id, - name=cc_pair_name, - status=ConnectorCredentialPairStatus.ACTIVE, - is_public=is_public, - ) - db_session.add(association) - db_session.flush() # make sure the association has an id - - if groups: - _relate_groups_to_cc_pair__no_commit( - db_session=db_session, - cc_pair_id=association.id, - user_group_ids=groups, - ) - - db_session.commit() - - return StatusResponse( - success=False, - message=f"Connector already has Credential {credential_id}", - data=association.id, - ) - - -def remove_credential_from_connector( - connector_id: int, - credential_id: int, - user: User | None, - db_session: Session, -) -> StatusResponse[int]: - connector = fetch_connector_by_id(connector_id, db_session) - credential = fetch_credential_by_id(credential_id, user, db_session) - - if connector is None: - raise HTTPException(status_code=404, detail="Connector does not exist") - - if credential is None: - raise HTTPException( - status_code=404, - detail="Credential does not exist or does not belong to user", - ) - - association = get_connector_credential_pair( - connector_id=connector_id, - credential_id=credential_id, - db_session=db_session, - user=user, - get_editable=True, - ) - - if association is not None: - db_session.delete(association) - db_session.commit() - return StatusResponse( - success=True, - message=f"Credential {credential_id} removed from Connector", - data=connector_id, - ) - - return StatusResponse( - success=False, - message=f"Connector already does not have Credential {credential_id}", - data=connector_id, - ) - - -def fetch_connector_credential_pairs( - db_session: Session, -) -> list[ConnectorCredentialPair]: - return db_session.query(ConnectorCredentialPair).all() - - -def resync_cc_pair( - cc_pair: ConnectorCredentialPair, - db_session: Session, -) -> None: - def find_latest_index_attempt( - connector_id: int, - credential_id: int, - only_include_success: bool, - db_session: Session, - ) -> IndexAttempt | None: - query = ( - db_session.query(IndexAttempt) - .join( - ConnectorCredentialPair, - IndexAttempt.connector_credential_pair_id == ConnectorCredentialPair.id, - ) - .join(SearchSettings, IndexAttempt.search_settings_id == SearchSettings.id) - .filter( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - SearchSettings.status == IndexModelStatus.PRESENT, - ) - ) - - if only_include_success: - query = query.filter(IndexAttempt.status == IndexingStatus.SUCCESS) - - latest_index_attempt = query.order_by(desc(IndexAttempt.time_started)).first() - - return latest_index_attempt - - last_success = find_latest_index_attempt( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - only_include_success=True, - db_session=db_session, - ) - - cc_pair.last_successful_index_time = ( - last_success.time_started if last_success else None - ) - - db_session.commit() diff --git a/backend/danswer/db/constants.py b/backend/danswer/db/constants.py deleted file mode 100644 index 935fcf70155..00000000000 --- a/backend/danswer/db/constants.py +++ /dev/null @@ -1 +0,0 @@ -SLACK_BOT_PERSONA_PREFIX = "__slack_bot_persona__" diff --git a/backend/danswer/db/credentials.py b/backend/danswer/db/credentials.py deleted file mode 100644 index abab904cc48..00000000000 --- a/backend/danswer/db/credentials.py +++ /dev/null @@ -1,430 +0,0 @@ -from typing import Any - -from sqlalchemy import exists -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.orm import Session -from sqlalchemy.sql.expression import and_ -from sqlalchemy.sql.expression import or_ - -from danswer.auth.schemas import UserRole -from danswer.configs.constants import DocumentSource -from danswer.connectors.gmail.constants import ( - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.connectors.google_drive.constants import ( - DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY, -) -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Credential -from danswer.db.models import Credential__UserGroup -from danswer.db.models import DocumentByConnectorCredentialPair -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.server.documents.models import CredentialBase -from danswer.server.documents.models import CredentialDataUpdateRequest -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - -# The credentials for these sources are not real so -# permissions are not enforced for them -CREDENTIAL_PERMISSIONS_TO_IGNORE = { - DocumentSource.FILE, - DocumentSource.WEB, - DocumentSource.NOT_APPLICABLE, - DocumentSource.GOOGLE_SITES, - DocumentSource.WIKIPEDIA, - DocumentSource.MEDIAWIKI, -} - - -def _add_user_filters( - stmt: Select, - user: User | None, - assume_admin: bool = False, # Used with API key - get_editable: bool = True, -) -> Select: - """Attaches filters to the statement to ensure that the user can only - access the appropriate credentials""" - if not user: - if assume_admin: - # apply admin filters minus the user_id check - stmt = stmt.where( - or_( - Credential.user_id.is_(None), - Credential.admin_public == True, # noqa: E712 - Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE), - ) - ) - return stmt - - if user.role == UserRole.ADMIN: - # Admins can access all credentials that are public or owned by them - # or are not associated with any user - return stmt.where( - or_( - Credential.user_id == user.id, - Credential.user_id.is_(None), - Credential.admin_public == True, # noqa: E712 - Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE), - ) - ) - if user.role == UserRole.BASIC: - # Basic users can only access credentials that are owned by them - return stmt.where(Credential.user_id == user.id) - - """ - THIS PART IS FOR CURATORS AND GLOBAL CURATORS - Here we select cc_pairs by relation: - User -> User__UserGroup -> Credential__UserGroup -> Credential - """ - stmt = stmt.outerjoin(Credential__UserGroup).outerjoin( - User__UserGroup, - User__UserGroup.user_group_id == Credential__UserGroup.user_group_id, - ) - """ - Filter Credentials by: - - if the user is in the user_group that owns the Credential - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out Credentials that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all Credentials in the groups the user is a curator - for (as well as public Credentials) - - if we are not editing, we return all Credentials directly connected to the user - """ - where_clause = User__UserGroup.user_id == user.id - if user.role == UserRole.CURATOR: - where_clause &= User__UserGroup.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UserGroup.user_group_id).where( - User__UserGroup.user_id == user.id - ) - if user.role == UserRole.CURATOR: - user_groups = user_groups.where( - User__UserGroup.is_curator == True # noqa: E712 - ) - where_clause &= ( - ~exists() - .where(Credential__UserGroup.credential_id == Credential.id) - .where(~Credential__UserGroup.user_group_id.in_(user_groups)) - .correlate(Credential) - ) - else: - where_clause |= Credential.curator_public == True # noqa: E712 - where_clause |= Credential.user_id == user.id # noqa: E712 - - where_clause |= Credential.source.in_(CREDENTIAL_PERMISSIONS_TO_IGNORE) - - return stmt.where(where_clause) - - -def _relate_credential_to_user_groups__no_commit( - db_session: Session, - credential_id: int, - user_group_ids: list[int], -) -> None: - credential_user_groups = [] - for group_id in user_group_ids: - credential_user_groups.append( - Credential__UserGroup( - credential_id=credential_id, - user_group_id=group_id, - ) - ) - db_session.add_all(credential_user_groups) - - -def fetch_credentials( - db_session: Session, - user: User | None = None, - get_editable: bool = True, -) -> list[Credential]: - stmt = select(Credential) - stmt = _add_user_filters(stmt, user, get_editable=get_editable) - results = db_session.scalars(stmt) - return list(results.all()) - - -def fetch_credential_by_id( - credential_id: int, - user: User | None, - db_session: Session, - assume_admin: bool = False, -) -> Credential | None: - stmt = select(Credential).distinct() - stmt = stmt.where(Credential.id == credential_id) - stmt = _add_user_filters(stmt, user, assume_admin=assume_admin) - result = db_session.execute(stmt) - credential = result.scalar_one_or_none() - return credential - - -def fetch_credentials_by_source( - db_session: Session, - user: User | None, - document_source: DocumentSource | None = None, - get_editable: bool = True, -) -> list[Credential]: - base_query = select(Credential).where(Credential.source == document_source) - base_query = _add_user_filters(base_query, user, get_editable=get_editable) - credentials = db_session.execute(base_query).scalars().all() - return list(credentials) - - -def swap_credentials_connector( - new_credential_id: int, connector_id: int, user: User | None, db_session: Session -) -> ConnectorCredentialPair: - # Check if the user has permission to use the new credential - new_credential = fetch_credential_by_id(new_credential_id, user, db_session) - if not new_credential: - raise ValueError( - f"No Credential found with id {new_credential_id} or user doesn't have permission to use it" - ) - - # Existing pair - existing_pair = db_session.execute( - select(ConnectorCredentialPair).where( - ConnectorCredentialPair.connector_id == connector_id - ) - ).scalar_one_or_none() - - if not existing_pair: - raise ValueError( - f"No ConnectorCredentialPair found for connector_id {connector_id}" - ) - - # Check if the new credential is compatible with the connector - if new_credential.source != existing_pair.connector.source: - raise ValueError( - f"New credential source {new_credential.source} does not match connector source {existing_pair.connector.source}" - ) - - db_session.execute( - update(DocumentByConnectorCredentialPair) - .where( - and_( - DocumentByConnectorCredentialPair.connector_id == connector_id, - DocumentByConnectorCredentialPair.credential_id - == existing_pair.credential_id, - ) - ) - .values(credential_id=new_credential_id) - ) - - # Update the existing pair with the new credential - existing_pair.credential_id = new_credential_id - existing_pair.credential = new_credential - - # Commit the changes - db_session.commit() - - # Refresh the object to ensure all relationships are up-to-date - db_session.refresh(existing_pair) - return existing_pair - - -def create_credential( - credential_data: CredentialBase, - user: User | None, - db_session: Session, -) -> Credential: - credential = Credential( - credential_json=credential_data.credential_json, - user_id=user.id if user else None, - admin_public=credential_data.admin_public, - source=credential_data.source, - name=credential_data.name, - curator_public=credential_data.curator_public, - ) - db_session.add(credential) - db_session.flush() # This ensures the credential gets an ID - - _relate_credential_to_user_groups__no_commit( - db_session=db_session, - credential_id=credential.id, - user_group_ids=credential_data.groups, - ) - - db_session.commit() - - return credential - - -def _cleanup_credential__user_group_relationships__no_commit( - db_session: Session, credential_id: int -) -> None: - """NOTE: does not commit the transaction.""" - db_session.query(Credential__UserGroup).filter( - Credential__UserGroup.credential_id == credential_id - ).delete(synchronize_session=False) - - -def alter_credential( - credential_id: int, - credential_data: CredentialDataUpdateRequest, - user: User, - db_session: Session, -) -> Credential | None: - # TODO: add user group relationship update - credential = fetch_credential_by_id(credential_id, user, db_session) - - if credential is None: - return None - - credential.name = credential_data.name - - # Update only the keys present in credential_data.credential_json - for key, value in credential_data.credential_json.items(): - credential.credential_json[key] = value - - credential.user_id = user.id if user is not None else None - db_session.commit() - return credential - - -def update_credential( - credential_id: int, - credential_data: CredentialBase, - user: User, - db_session: Session, -) -> Credential | None: - credential = fetch_credential_by_id(credential_id, user, db_session) - if credential is None: - return None - - credential.credential_json = credential_data.credential_json - credential.user_id = user.id if user is not None else None - - db_session.commit() - return credential - - -def update_credential_json( - credential_id: int, - credential_json: dict[str, Any], - user: User, - db_session: Session, -) -> Credential | None: - credential = fetch_credential_by_id(credential_id, user, db_session) - if credential is None: - return None - credential.credential_json = credential_json - - db_session.commit() - return credential - - -def backend_update_credential_json( - credential: Credential, - credential_json: dict[str, Any], - db_session: Session, -) -> None: - """This should not be used in any flows involving the frontend or users""" - credential.credential_json = credential_json - db_session.commit() - - -def delete_credential( - credential_id: int, - user: User | None, - db_session: Session, - force: bool = False, -) -> None: - credential = fetch_credential_by_id(credential_id, user, db_session) - if credential is None: - raise ValueError( - f"Credential by provided id {credential_id} does not exist or does not belong to user" - ) - - associated_connectors = ( - db_session.query(ConnectorCredentialPair) - .filter(ConnectorCredentialPair.credential_id == credential_id) - .all() - ) - - associated_doc_cc_pairs = ( - db_session.query(DocumentByConnectorCredentialPair) - .filter(DocumentByConnectorCredentialPair.credential_id == credential_id) - .all() - ) - - if associated_connectors or associated_doc_cc_pairs: - if force: - logger.warning( - f"Force deleting credential {credential_id} and its associated records" - ) - - # Delete DocumentByConnectorCredentialPair records first - for doc_cc_pair in associated_doc_cc_pairs: - db_session.delete(doc_cc_pair) - - # Then delete ConnectorCredentialPair records - for connector in associated_connectors: - db_session.delete(connector) - - # Commit these deletions before deleting the credential - db_session.flush() - else: - raise ValueError( - f"Cannot delete credential as it is still associated with " - f"{len(associated_connectors)} connector(s) and {len(associated_doc_cc_pairs)} document(s). " - ) - - if force: - logger.warning(f"Force deleting credential {credential_id}") - else: - logger.notice(f"Deleting credential {credential_id}") - - _cleanup_credential__user_group_relationships__no_commit(db_session, credential_id) - db_session.delete(credential) - db_session.commit() - - -def create_initial_public_credential(db_session: Session) -> None: - public_cred_id = 0 - error_msg = ( - "DB is not in a valid initial state." - "There must exist an empty public credential for data connectors that do not require additional Auth." - ) - first_credential = fetch_credential_by_id(public_cred_id, None, db_session) - - if first_credential is not None: - if first_credential.credential_json != {} or first_credential.user is not None: - raise ValueError(error_msg) - return - - credential = Credential( - id=public_cred_id, - credential_json={}, - user_id=None, - ) - db_session.add(credential) - db_session.commit() - - -def delete_gmail_service_account_credentials( - user: User | None, db_session: Session -) -> None: - credentials = fetch_credentials(db_session=db_session, user=user) - for credential in credentials: - if credential.credential_json.get( - GMAIL_DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY - ): - db_session.delete(credential) - - db_session.commit() - - -def delete_google_drive_service_account_credentials( - user: User | None, db_session: Session -) -> None: - credentials = fetch_credentials(db_session=db_session, user=user) - for credential in credentials: - if credential.credential_json.get(DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY): - db_session.delete(credential) - - db_session.commit() diff --git a/backend/danswer/db/deletion_attempt.py b/backend/danswer/db/deletion_attempt.py deleted file mode 100644 index 0312047250b..00000000000 --- a/backend/danswer/db/deletion_attempt.py +++ /dev/null @@ -1,52 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.db.index_attempt import get_last_attempt -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import IndexingStatus -from danswer.db.search_settings import get_current_search_settings - - -def check_deletion_attempt_is_allowed( - connector_credential_pair: ConnectorCredentialPair, - db_session: Session, - allow_scheduled: bool = False, -) -> str | None: - """ - To be deletable: - (1) connector should be paused - (2) there should be no in-progress/planned index attempts - - Returns an error message if the deletion attempt is not allowed, otherwise None. - """ - base_error_msg = ( - f"Connector with ID '{connector_credential_pair.connector_id}' and credential ID " - f"'{connector_credential_pair.credential_id}' is not deletable." - ) - - if connector_credential_pair.status.is_active(): - return base_error_msg + " Connector must be paused." - - connector_id = connector_credential_pair.connector_id - credential_id = connector_credential_pair.credential_id - search_settings = get_current_search_settings(db_session) - - last_indexing = get_last_attempt( - connector_id=connector_id, - credential_id=credential_id, - search_settings_id=search_settings.id, - db_session=db_session, - ) - - if not last_indexing: - return None - - if last_indexing.status == IndexingStatus.IN_PROGRESS or ( - last_indexing.status == IndexingStatus.NOT_STARTED and not allow_scheduled - ): - return ( - base_error_msg - + " There is an ongoing / planned indexing attempt. " - + "The indexing attempt must be completed or cancelled before deletion." - ) - - return None diff --git a/backend/danswer/db/document.py b/backend/danswer/db/document.py deleted file mode 100644 index 77ea4e3dd9d..00000000000 --- a/backend/danswer/db/document.py +++ /dev/null @@ -1,381 +0,0 @@ -import contextlib -import time -from collections.abc import Generator -from collections.abc import Sequence -from datetime import datetime -from uuid import UUID - -from sqlalchemy import and_ -from sqlalchemy import delete -from sqlalchemy import exists -from sqlalchemy import func -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy.dialects.postgresql import insert -from sqlalchemy.engine.util import TransactionalContext -from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session - -from danswer.configs.constants import DEFAULT_BOOST -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.feedback import delete_document_feedback_for_documents__no_commit -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Credential -from danswer.db.models import Document as DbDocument -from danswer.db.models import DocumentByConnectorCredentialPair -from danswer.db.tag import delete_document_tags_for_documents__no_commit -from danswer.db.utils import model_to_dict -from danswer.document_index.interfaces import DocumentMetadata -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def check_docs_exist(db_session: Session) -> bool: - stmt = select(exists(DbDocument)) - result = db_session.execute(stmt) - return result.scalar() or False - - -def get_documents_for_connector_credential_pair( - db_session: Session, connector_id: int, credential_id: int, limit: int | None = None -) -> Sequence[DbDocument]: - initial_doc_ids_stmt = select(DocumentByConnectorCredentialPair.id).where( - and_( - DocumentByConnectorCredentialPair.connector_id == connector_id, - DocumentByConnectorCredentialPair.credential_id == credential_id, - ) - ) - stmt = select(DbDocument).where(DbDocument.id.in_(initial_doc_ids_stmt)).distinct() - if limit: - stmt = stmt.limit(limit) - return db_session.scalars(stmt).all() - - -def get_documents_by_ids( - document_ids: list[str], - db_session: Session, -) -> list[DbDocument]: - stmt = select(DbDocument).where(DbDocument.id.in_(document_ids)) - documents = db_session.execute(stmt).scalars().all() - return list(documents) - - -def get_document_connector_cnts( - db_session: Session, - document_ids: list[str], -) -> Sequence[tuple[str, int]]: - stmt = ( - select( - DocumentByConnectorCredentialPair.id, - func.count(), - ) - .where(DocumentByConnectorCredentialPair.id.in_(document_ids)) - .group_by(DocumentByConnectorCredentialPair.id) - ) - return db_session.execute(stmt).all() # type: ignore - - -def get_document_cnts_for_cc_pairs( - db_session: Session, cc_pair_identifiers: list[ConnectorCredentialPairIdentifier] -) -> Sequence[tuple[int, int, int]]: - stmt = ( - select( - DocumentByConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id, - func.count(), - ) - .where( - or_( - *[ - and_( - DocumentByConnectorCredentialPair.connector_id - == cc_pair_identifier.connector_id, - DocumentByConnectorCredentialPair.credential_id - == cc_pair_identifier.credential_id, - ) - for cc_pair_identifier in cc_pair_identifiers - ] - ) - ) - .group_by( - DocumentByConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id, - ) - ) - - return db_session.execute(stmt).all() # type: ignore - - -def get_acccess_info_for_documents( - db_session: Session, - document_ids: list[str], -) -> Sequence[tuple[str, list[UUID | None], bool]]: - """Gets back all relevant access info for the given documents. This includes - the user_ids for cc pairs that the document is associated with + whether any - of the associated cc pairs are intending to make the document globally public. - """ - stmt = ( - select( - DocumentByConnectorCredentialPair.id, - func.array_agg(Credential.user_id).label("user_ids"), - func.bool_or(ConnectorCredentialPair.is_public).label("public_doc"), - ) - .where(DocumentByConnectorCredentialPair.id.in_(document_ids)) - .join( - Credential, - DocumentByConnectorCredentialPair.credential_id == Credential.id, - ) - .join( - ConnectorCredentialPair, - and_( - DocumentByConnectorCredentialPair.connector_id - == ConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id - == ConnectorCredentialPair.credential_id, - ), - ) - # don't include CC pairs that are being deleted - # NOTE: CC pairs can never go from DELETING to any other state -> it's safe to ignore them - .where(ConnectorCredentialPair.status != ConnectorCredentialPairStatus.DELETING) - .group_by(DocumentByConnectorCredentialPair.id) - ) - return db_session.execute(stmt).all() # type: ignore - - -def upsert_documents( - db_session: Session, - document_metadata_batch: list[DocumentMetadata], - initial_boost: int = DEFAULT_BOOST, -) -> None: - """NOTE: this function is Postgres specific. Not all DBs support the ON CONFLICT clause. - Also note, this function should not be used for updating documents, only creating and - ensuring that it exists. It IGNORES the doc_updated_at field""" - seen_documents: dict[str, DocumentMetadata] = {} - for document_metadata in document_metadata_batch: - doc_id = document_metadata.document_id - if doc_id not in seen_documents: - seen_documents[doc_id] = document_metadata - - if not seen_documents: - logger.info("No documents to upsert. Skipping.") - return - - insert_stmt = insert(DbDocument).values( - [ - model_to_dict( - DbDocument( - id=doc.document_id, - from_ingestion_api=doc.from_ingestion_api, - boost=initial_boost, - hidden=False, - semantic_id=doc.semantic_identifier, - link=doc.first_link, - doc_updated_at=None, # this is intentional - primary_owners=doc.primary_owners, - secondary_owners=doc.secondary_owners, - ) - ) - for doc in seen_documents.values() - ] - ) - # for now, there are no columns to update. If more metadata is added, then this - # needs to change to an `on_conflict_do_update` - on_conflict_stmt = insert_stmt.on_conflict_do_nothing() - db_session.execute(on_conflict_stmt) - db_session.commit() - - -def upsert_document_by_connector_credential_pair( - db_session: Session, document_metadata_batch: list[DocumentMetadata] -) -> None: - """NOTE: this function is Postgres specific. Not all DBs support the ON CONFLICT clause.""" - if not document_metadata_batch: - logger.info("`document_metadata_batch` is empty. Skipping.") - return - - insert_stmt = insert(DocumentByConnectorCredentialPair).values( - [ - model_to_dict( - DocumentByConnectorCredentialPair( - id=document_metadata.document_id, - connector_id=document_metadata.connector_id, - credential_id=document_metadata.credential_id, - ) - ) - for document_metadata in document_metadata_batch - ] - ) - # for now, there are no columns to update. If more metadata is added, then this - # needs to change to an `on_conflict_do_update` - on_conflict_stmt = insert_stmt.on_conflict_do_nothing() - db_session.execute(on_conflict_stmt) - db_session.commit() - - -def update_docs_updated_at( - ids_to_new_updated_at: dict[str, datetime], - db_session: Session, -) -> None: - doc_ids = list(ids_to_new_updated_at.keys()) - documents_to_update = ( - db_session.query(DbDocument).filter(DbDocument.id.in_(doc_ids)).all() - ) - - for document in documents_to_update: - document.doc_updated_at = ids_to_new_updated_at[document.id] - - db_session.commit() - - -def upsert_documents_complete( - db_session: Session, - document_metadata_batch: list[DocumentMetadata], -) -> None: - upsert_documents(db_session, document_metadata_batch) - upsert_document_by_connector_credential_pair(db_session, document_metadata_batch) - logger.info( - f"Upserted {len(document_metadata_batch)} document store entries into DB" - ) - - -def delete_document_by_connector_credential_pair__no_commit( - db_session: Session, - document_ids: list[str], - connector_credential_pair_identifier: ConnectorCredentialPairIdentifier - | None = None, -) -> None: - stmt = delete(DocumentByConnectorCredentialPair).where( - DocumentByConnectorCredentialPair.id.in_(document_ids) - ) - if connector_credential_pair_identifier: - stmt = stmt.where( - and_( - DocumentByConnectorCredentialPair.connector_id - == connector_credential_pair_identifier.connector_id, - DocumentByConnectorCredentialPair.credential_id - == connector_credential_pair_identifier.credential_id, - ) - ) - db_session.execute(stmt) - - -def delete_documents__no_commit(db_session: Session, document_ids: list[str]) -> None: - db_session.execute(delete(DbDocument).where(DbDocument.id.in_(document_ids))) - - -def delete_documents_complete__no_commit( - db_session: Session, document_ids: list[str] -) -> None: - logger.info(f"Deleting {len(document_ids)} documents from the DB") - delete_document_by_connector_credential_pair__no_commit(db_session, document_ids) - delete_document_feedback_for_documents__no_commit( - document_ids=document_ids, db_session=db_session - ) - delete_document_tags_for_documents__no_commit( - document_ids=document_ids, db_session=db_session - ) - delete_documents__no_commit(db_session, document_ids) - - -def acquire_document_locks(db_session: Session, document_ids: list[str]) -> bool: - """Acquire locks for the specified documents. Ideally this shouldn't be - called with large list of document_ids (an exception could be made if the - length of holding the lock is very short). - - Will simply raise an exception if any of the documents are already locked. - This prevents deadlocks (assuming that the caller passes in all required - document IDs in a single call). - """ - stmt = ( - select(DbDocument.id) - .where(DbDocument.id.in_(document_ids)) - .with_for_update(nowait=True) - ) - # will raise exception if any of the documents are already locked - documents = db_session.scalars(stmt).all() - - # make sure we found every document - if len(documents) != len(set(document_ids)): - logger.warning("Didn't find row for all specified document IDs. Aborting.") - return False - - return True - - -_NUM_LOCK_ATTEMPTS = 10 -_LOCK_RETRY_DELAY = 10 - - -@contextlib.contextmanager -def prepare_to_modify_documents( - db_session: Session, document_ids: list[str], retry_delay: int = _LOCK_RETRY_DELAY -) -> Generator[TransactionalContext, None, None]: - """Try and acquire locks for the documents to prevent other jobs from - modifying them at the same time (e.g. avoid race conditions). This should be - called ahead of any modification to Vespa. Locks should be released by the - caller as soon as updates are complete by finishing the transaction. - - NOTE: only one commit is allowed within the context manager returned by this function. - Multiple commits will result in a sqlalchemy.exc.InvalidRequestError. - NOTE: this function will commit any existing transaction. - """ - - db_session.commit() # ensure that we're not in a transaction - - lock_acquired = False - for _ in range(_NUM_LOCK_ATTEMPTS): - try: - with db_session.begin() as transaction: - lock_acquired = acquire_document_locks( - db_session=db_session, document_ids=document_ids - ) - if lock_acquired: - yield transaction - break - except OperationalError as e: - logger.warning( - f"Failed to acquire locks for documents, retrying. Error: {e}" - ) - - time.sleep(retry_delay) - - if not lock_acquired: - raise RuntimeError( - f"Failed to acquire locks after {_NUM_LOCK_ATTEMPTS} attempts " - f"for documents: {document_ids}" - ) - - -def get_ingestion_documents( - db_session: Session, -) -> list[DbDocument]: - # TODO add the option to filter by DocumentSource - stmt = select(DbDocument).where(DbDocument.from_ingestion_api.is_(True)) - documents = db_session.execute(stmt).scalars().all() - return list(documents) - - -def get_documents_by_cc_pair( - cc_pair_id: int, - db_session: Session, -) -> list[DbDocument]: - return ( - db_session.query(DbDocument) - .join( - DocumentByConnectorCredentialPair, - DbDocument.id == DocumentByConnectorCredentialPair.id, - ) - .join( - ConnectorCredentialPair, - and_( - DocumentByConnectorCredentialPair.connector_id - == ConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id - == ConnectorCredentialPair.credential_id, - ), - ) - .filter(ConnectorCredentialPair.id == cc_pair_id) - .all() - ) diff --git a/backend/danswer/db/document_set.py b/backend/danswer/db/document_set.py deleted file mode 100644 index 2de61a491f9..00000000000 --- a/backend/danswer/db/document_set.py +++ /dev/null @@ -1,617 +0,0 @@ -from collections.abc import Sequence -from typing import cast -from uuid import UUID - -from sqlalchemy import and_ -from sqlalchemy import delete -from sqlalchemy import exists -from sqlalchemy import func -from sqlalchemy import or_ -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy.orm import aliased -from sqlalchemy.orm import Session - -from danswer.db.connector_credential_pair import get_cc_pair_groups_for_ids -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Document -from danswer.db.models import DocumentByConnectorCredentialPair -from danswer.db.models import DocumentSet as DocumentSetDBModel -from danswer.db.models import DocumentSet__ConnectorCredentialPair -from danswer.db.models import DocumentSet__UserGroup -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserRole -from danswer.server.features.document_set.models import DocumentSetCreationRequest -from danswer.server.features.document_set.models import DocumentSetUpdateRequest -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import fetch_versioned_implementation - -logger = setup_logger() - - -def _add_user_filters( - stmt: Select, user: User | None, get_editable: bool = True -) -> Select: - # If user is None, assume the user is an admin or auth is disabled - if user is None or user.role == UserRole.ADMIN: - return stmt - - DocumentSet__UG = aliased(DocumentSet__UserGroup) - User__UG = aliased(User__UserGroup) - """ - Here we select cc_pairs by relation: - User -> User__UserGroup -> DocumentSet__UserGroup -> DocumentSet - """ - stmt = stmt.outerjoin(DocumentSet__UG).outerjoin( - User__UserGroup, - User__UserGroup.user_group_id == DocumentSet__UG.user_group_id, - ) - """ - Filter DocumentSets by: - - if the user is in the user_group that owns the DocumentSet - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out DocumentSets that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all DocumentSets in the groups the user is a curator - for (as well as public DocumentSets) - """ - where_clause = User__UserGroup.user_id == user.id - if user.role == UserRole.CURATOR and get_editable: - where_clause &= User__UserGroup.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id) - if user.role == UserRole.CURATOR: - user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712 - where_clause &= ( - ~exists() - .where(DocumentSet__UG.document_set_id == DocumentSetDBModel.id) - .where(~DocumentSet__UG.user_group_id.in_(user_groups)) - .correlate(DocumentSetDBModel) - ) - else: - where_clause |= DocumentSetDBModel.is_public == True # noqa: E712 - - return stmt.where(where_clause) - - -def _delete_document_set_cc_pairs__no_commit( - db_session: Session, document_set_id: int, is_current: bool | None = None -) -> None: - """NOTE: does not commit transaction, this must be done by the caller""" - stmt = delete(DocumentSet__ConnectorCredentialPair).where( - DocumentSet__ConnectorCredentialPair.document_set_id == document_set_id - ) - if is_current is not None: - stmt = stmt.where(DocumentSet__ConnectorCredentialPair.is_current == is_current) - db_session.execute(stmt) - - -def _mark_document_set_cc_pairs_as_outdated__no_commit( - db_session: Session, document_set_id: int -) -> None: - """NOTE: does not commit transaction, this must be done by the caller""" - stmt = select(DocumentSet__ConnectorCredentialPair).where( - DocumentSet__ConnectorCredentialPair.document_set_id == document_set_id - ) - for row in db_session.scalars(stmt): - row.is_current = False - - -def delete_document_set_privacy__no_commit( - document_set_id: int, db_session: Session -) -> None: - """No private document sets in Danswer MIT""" - - -def get_document_set_by_id( - db_session: Session, - document_set_id: int, - user: User | None = None, - get_editable: bool = True, -) -> DocumentSetDBModel | None: - stmt = select(DocumentSetDBModel).distinct() - stmt = stmt.where(DocumentSetDBModel.id == document_set_id) - stmt = _add_user_filters(stmt=stmt, user=user, get_editable=get_editable) - return db_session.scalar(stmt) - - -def get_document_set_by_name( - db_session: Session, document_set_name: str -) -> DocumentSetDBModel | None: - return db_session.scalar( - select(DocumentSetDBModel).where(DocumentSetDBModel.name == document_set_name) - ) - - -def get_document_sets_by_ids( - db_session: Session, document_set_ids: list[int] -) -> Sequence[DocumentSetDBModel]: - if not document_set_ids: - return [] - return db_session.scalars( - select(DocumentSetDBModel).where(DocumentSetDBModel.id.in_(document_set_ids)) - ).all() - - -def make_doc_set_private( - document_set_id: int, - user_ids: list[UUID] | None, - group_ids: list[int] | None, - db_session: Session, -) -> None: - # May cause error if someone switches down to MIT from EE - if user_ids or group_ids: - raise NotImplementedError("Danswer MIT does not support private Document Sets") - - -def _check_if_cc_pairs_are_owned_by_groups( - db_session: Session, - cc_pair_ids: list[int], - group_ids: list[int], -) -> None: - """ - This function checks if the CC pairs are owned by the specified groups or public. - If not, it raises a ValueError. - """ - group_cc_pair_relationships = get_cc_pair_groups_for_ids( - db_session=db_session, - cc_pair_ids=cc_pair_ids, - ) - - group_cc_pair_relationships_set = { - (relationship.cc_pair_id, relationship.user_group_id) - for relationship in group_cc_pair_relationships - } - - missing_cc_pair_ids = [] - for cc_pair_id in cc_pair_ids: - for group_id in group_ids: - if (cc_pair_id, group_id) not in group_cc_pair_relationships_set: - missing_cc_pair_ids.append(cc_pair_id) - break - - if missing_cc_pair_ids: - cc_pairs = get_connector_credential_pairs( - db_session=db_session, - ids=missing_cc_pair_ids, - ) - for cc_pair in cc_pairs: - if not cc_pair.is_public: - raise ValueError( - f"Connector Credential Pair with ID: '{cc_pair.id}'" - " is not owned by the specified groups" - ) - - -def insert_document_set( - document_set_creation_request: DocumentSetCreationRequest, - user_id: UUID | None, - db_session: Session, -) -> tuple[DocumentSetDBModel, list[DocumentSet__ConnectorCredentialPair]]: - if not document_set_creation_request.cc_pair_ids: - # It's cc-pairs in actuality but the UI displays this error - raise ValueError("Cannot create a document set with no Connectors") - - if not document_set_creation_request.is_public: - _check_if_cc_pairs_are_owned_by_groups( - db_session=db_session, - cc_pair_ids=document_set_creation_request.cc_pair_ids, - group_ids=document_set_creation_request.groups or [], - ) - - try: - new_document_set_row = DocumentSetDBModel( - name=document_set_creation_request.name, - description=document_set_creation_request.description, - user_id=user_id, - is_public=document_set_creation_request.is_public, - ) - db_session.add(new_document_set_row) - db_session.flush() # ensure the new document set gets assigned an ID - - ds_cc_pairs = [ - DocumentSet__ConnectorCredentialPair( - document_set_id=new_document_set_row.id, - connector_credential_pair_id=cc_pair_id, - is_current=True, - ) - for cc_pair_id in document_set_creation_request.cc_pair_ids - ] - db_session.add_all(ds_cc_pairs) - - versioned_private_doc_set_fn = fetch_versioned_implementation( - "danswer.db.document_set", "make_doc_set_private" - ) - - # Private Document Sets - versioned_private_doc_set_fn( - document_set_id=new_document_set_row.id, - user_ids=document_set_creation_request.users, - group_ids=document_set_creation_request.groups, - db_session=db_session, - ) - - db_session.commit() - except Exception as e: - db_session.rollback() - logger.error(f"Error creating document set: {e}") - - return new_document_set_row, ds_cc_pairs - - -def update_document_set( - db_session: Session, - document_set_update_request: DocumentSetUpdateRequest, - user: User | None = None, -) -> tuple[DocumentSetDBModel, list[DocumentSet__ConnectorCredentialPair]]: - if not document_set_update_request.cc_pair_ids: - # It's cc-pairs in actuality but the UI displays this error - raise ValueError("Cannot create a document set with no Connectors") - - if not document_set_update_request.is_public: - _check_if_cc_pairs_are_owned_by_groups( - db_session=db_session, - cc_pair_ids=document_set_update_request.cc_pair_ids, - group_ids=document_set_update_request.groups, - ) - - try: - # update the description - document_set_row = get_document_set_by_id( - db_session=db_session, - document_set_id=document_set_update_request.id, - user=user, - get_editable=True, - ) - if document_set_row is None: - raise ValueError( - f"No document set with ID '{document_set_update_request.id}'" - ) - if not document_set_row.is_up_to_date: - raise ValueError( - "Cannot update document set while it is syncing. Please wait " - "for it to finish syncing, and then try again." - ) - - document_set_row.description = document_set_update_request.description - document_set_row.is_up_to_date = False - document_set_row.is_public = document_set_update_request.is_public - - versioned_private_doc_set_fn = fetch_versioned_implementation( - "danswer.db.document_set", "make_doc_set_private" - ) - - # Private Document Sets - versioned_private_doc_set_fn( - document_set_id=document_set_row.id, - user_ids=document_set_update_request.users, - group_ids=document_set_update_request.groups, - db_session=db_session, - ) - - # update the attached CC pairs - # first, mark all existing CC pairs as not current - _mark_document_set_cc_pairs_as_outdated__no_commit( - db_session=db_session, document_set_id=document_set_row.id - ) - # add in rows for the new CC pairs - ds_cc_pairs = [ - DocumentSet__ConnectorCredentialPair( - document_set_id=document_set_update_request.id, - connector_credential_pair_id=cc_pair_id, - is_current=True, - ) - for cc_pair_id in document_set_update_request.cc_pair_ids - ] - db_session.add_all(ds_cc_pairs) - db_session.commit() - except: - db_session.rollback() - raise - - return document_set_row, ds_cc_pairs - - -def mark_document_set_as_synced(document_set_id: int, db_session: Session) -> None: - stmt = select(DocumentSetDBModel).where(DocumentSetDBModel.id == document_set_id) - document_set = db_session.scalar(stmt) - if document_set is None: - raise ValueError(f"No document set with ID: {document_set_id}") - - # mark as up to date - document_set.is_up_to_date = True - # delete outdated relationship table rows - _delete_document_set_cc_pairs__no_commit( - db_session=db_session, document_set_id=document_set_id, is_current=False - ) - db_session.commit() - - -def delete_document_set( - document_set_row: DocumentSetDBModel, db_session: Session -) -> None: - # delete all relationships to CC pairs - _delete_document_set_cc_pairs__no_commit( - db_session=db_session, document_set_id=document_set_row.id - ) - db_session.delete(document_set_row) - db_session.commit() - - -def mark_document_set_as_to_be_deleted( - db_session: Session, - document_set_id: int, - user: User | None = None, -) -> None: - """Cleans up all document_set -> cc_pair relationships and marks the document set - as needing an update. The actual document set row will be deleted by the background - job which syncs these changes to Vespa.""" - - try: - document_set_row = get_document_set_by_id( - db_session=db_session, - document_set_id=document_set_id, - user=user, - get_editable=True, - ) - if document_set_row is None: - error_msg = f"Document set with ID: '{document_set_id}' does not exist " - if user is not None: - error_msg += f"or is not editable by user with email: '{user.email}'" - raise ValueError(error_msg) - if not document_set_row.is_up_to_date: - raise ValueError( - "Cannot delete document set while it is syncing. Please wait " - "for it to finish syncing, and then try again." - ) - - # delete all relationships to CC pairs - _delete_document_set_cc_pairs__no_commit( - db_session=db_session, document_set_id=document_set_id - ) - - # delete all private document set information - versioned_delete_private_fn = fetch_versioned_implementation( - "danswer.db.document_set", "delete_document_set_privacy__no_commit" - ) - versioned_delete_private_fn( - document_set_id=document_set_id, db_session=db_session - ) - - # mark the row as needing a sync, it will be deleted there since there - # are no more relationships to cc pairs - document_set_row.is_up_to_date = False - db_session.commit() - except: - db_session.rollback() - raise - - -def delete_document_set_cc_pair_relationship__no_commit( - connector_id: int, credential_id: int, db_session: Session -) -> None: - """Deletes all rows from DocumentSet__ConnectorCredentialPair where the - connector_credential_pair_id matches the given cc_pair_id.""" - delete_stmt = delete(DocumentSet__ConnectorCredentialPair).where( - and_( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - DocumentSet__ConnectorCredentialPair.connector_credential_pair_id - == ConnectorCredentialPair.id, - ) - ) - db_session.execute(delete_stmt) - - -def fetch_document_sets( - user_id: UUID | None, db_session: Session, include_outdated: bool = False -) -> list[tuple[DocumentSetDBModel, list[ConnectorCredentialPair]]]: - """Return is a list where each element contains a tuple of: - 1. The document set itself - 2. All CC pairs associated with the document set""" - stmt = ( - select(DocumentSetDBModel, ConnectorCredentialPair) - .join( - DocumentSet__ConnectorCredentialPair, - DocumentSetDBModel.id - == DocumentSet__ConnectorCredentialPair.document_set_id, - isouter=True, # outer join is needed to also fetch document sets with no cc pairs - ) - .join( - ConnectorCredentialPair, - ConnectorCredentialPair.id - == DocumentSet__ConnectorCredentialPair.connector_credential_pair_id, - isouter=True, # outer join is needed to also fetch document sets with no cc pairs - ) - ) - if not include_outdated: - stmt = stmt.where( - or_( - DocumentSet__ConnectorCredentialPair.is_current == True, # noqa: E712 - # `None` handles case where no CC Pairs exist for a Document Set - DocumentSet__ConnectorCredentialPair.is_current.is_(None), - ) - ) - - results = cast( - list[tuple[DocumentSetDBModel, ConnectorCredentialPair | None]], - db_session.execute(stmt).all(), - ) - - aggregated_results: dict[ - int, tuple[DocumentSetDBModel, list[ConnectorCredentialPair]] - ] = {} - for document_set, cc_pair in results: - if document_set.id not in aggregated_results: - aggregated_results[document_set.id] = ( - document_set, - [cc_pair] if cc_pair else [], - ) - else: - if cc_pair: - aggregated_results[document_set.id][1].append(cc_pair) - - return [ - (document_set, cc_pairs) - for document_set, cc_pairs in aggregated_results.values() - ] - - -def fetch_all_document_sets_for_user( - db_session: Session, - user: User | None = None, - get_editable: bool = True, -) -> Sequence[DocumentSetDBModel]: - stmt = select(DocumentSetDBModel).distinct() - stmt = _add_user_filters(stmt, user, get_editable=get_editable) - return db_session.scalars(stmt).all() - - -def fetch_documents_for_document_set_paginated( - document_set_id: int, - db_session: Session, - current_only: bool = True, - last_document_id: str | None = None, - limit: int = 100, -) -> tuple[Sequence[Document], str | None]: - stmt = ( - select(Document) - .join( - DocumentByConnectorCredentialPair, - DocumentByConnectorCredentialPair.id == Document.id, - ) - .join( - ConnectorCredentialPair, - and_( - ConnectorCredentialPair.connector_id - == DocumentByConnectorCredentialPair.connector_id, - ConnectorCredentialPair.credential_id - == DocumentByConnectorCredentialPair.credential_id, - ), - ) - .join( - DocumentSet__ConnectorCredentialPair, - DocumentSet__ConnectorCredentialPair.connector_credential_pair_id - == ConnectorCredentialPair.id, - ) - .join( - DocumentSetDBModel, - DocumentSetDBModel.id - == DocumentSet__ConnectorCredentialPair.document_set_id, - ) - .where(DocumentSetDBModel.id == document_set_id) - .order_by(Document.id) - .limit(limit) - ) - if last_document_id is not None: - stmt = stmt.where(Document.id > last_document_id) - if current_only: - stmt = stmt.where( - DocumentSet__ConnectorCredentialPair.is_current == True # noqa: E712 - ) - stmt = stmt.distinct() - - documents = db_session.scalars(stmt).all() - return documents, documents[-1].id if documents else None - - -def fetch_document_sets_for_documents( - document_ids: list[str], - db_session: Session, -) -> Sequence[tuple[str, list[str]]]: - """Gives back a list of (document_id, list[document_set_names]) tuples""" - stmt = ( - select(Document.id, func.array_agg(DocumentSetDBModel.name)) - .join( - DocumentSet__ConnectorCredentialPair, - DocumentSetDBModel.id - == DocumentSet__ConnectorCredentialPair.document_set_id, - ) - .join( - ConnectorCredentialPair, - ConnectorCredentialPair.id - == DocumentSet__ConnectorCredentialPair.connector_credential_pair_id, - ) - .join( - DocumentByConnectorCredentialPair, - and_( - DocumentByConnectorCredentialPair.connector_id - == ConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id - == ConnectorCredentialPair.credential_id, - ), - ) - .join( - Document, - Document.id == DocumentByConnectorCredentialPair.id, - ) - .where(Document.id.in_(document_ids)) - # don't include CC pairs that are being deleted - # NOTE: CC pairs can never go from DELETING to any other state -> it's safe to ignore them - # as we can assume their document sets are no longer relevant - .where(ConnectorCredentialPair.status != ConnectorCredentialPairStatus.DELETING) - .where(DocumentSet__ConnectorCredentialPair.is_current == True) # noqa: E712 - .group_by(Document.id) - ) - return db_session.execute(stmt).all() # type: ignore - - -def get_or_create_document_set_by_name( - db_session: Session, - document_set_name: str, - document_set_description: str = "Default Persona created Document-Set, " - "please update description", -) -> DocumentSetDBModel: - """This is used by the default personas which need to attach to document sets - on server startup""" - doc_set = get_document_set_by_name(db_session, document_set_name) - if doc_set is not None: - return doc_set - - new_doc_set = DocumentSetDBModel( - name=document_set_name, - description=document_set_description, - user_id=None, - is_up_to_date=True, - ) - - db_session.add(new_doc_set) - db_session.commit() - - return new_doc_set - - -def check_document_sets_are_public( - db_session: Session, - document_set_ids: list[int], -) -> bool: - """Checks if any of the CC-Pairs are Non Public (meaning that some documents in this document - set is not Public""" - connector_credential_pair_ids = ( - db_session.query( - DocumentSet__ConnectorCredentialPair.connector_credential_pair_id - ) - .filter( - DocumentSet__ConnectorCredentialPair.document_set_id.in_(document_set_ids) - ) - .subquery() - ) - - not_public_exists = ( - db_session.query(ConnectorCredentialPair.id) - .filter( - ConnectorCredentialPair.id.in_( - connector_credential_pair_ids # type:ignore - ), - ConnectorCredentialPair.is_public.is_(False), - ) - .limit(1) - .first() - is not None - ) - - return not not_public_exists diff --git a/backend/danswer/db/engine.py b/backend/danswer/db/engine.py deleted file mode 100644 index 94b5d0123cc..00000000000 --- a/backend/danswer/db/engine.py +++ /dev/null @@ -1,213 +0,0 @@ -import contextlib -import time -from collections.abc import AsyncGenerator -from collections.abc import Generator -from datetime import datetime -from typing import ContextManager - -from sqlalchemy import event -from sqlalchemy import text -from sqlalchemy.engine import create_engine -from sqlalchemy.engine import Engine -from sqlalchemy.ext.asyncio import AsyncEngine -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.ext.asyncio import create_async_engine -from sqlalchemy.orm import Session -from sqlalchemy.orm import sessionmaker - -from danswer.configs.app_configs import LOG_POSTGRES_CONN_COUNTS -from danswer.configs.app_configs import LOG_POSTGRES_LATENCY -from danswer.configs.app_configs import POSTGRES_DB -from danswer.configs.app_configs import POSTGRES_HOST -from danswer.configs.app_configs import POSTGRES_PASSWORD -from danswer.configs.app_configs import POSTGRES_POOL_PRE_PING -from danswer.configs.app_configs import POSTGRES_POOL_RECYCLE -from danswer.configs.app_configs import POSTGRES_PORT -from danswer.configs.app_configs import POSTGRES_USER -from danswer.configs.constants import POSTGRES_UNKNOWN_APP_NAME -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -SYNC_DB_API = "psycopg2" -ASYNC_DB_API = "asyncpg" - -POSTGRES_APP_NAME = ( - POSTGRES_UNKNOWN_APP_NAME # helps to diagnose open connections in postgres -) - -# global so we don't create more than one engine per process -# outside of being best practice, this is needed so we can properly pool -# connections and not create a new pool on every request -_SYNC_ENGINE: Engine | None = None -_ASYNC_ENGINE: AsyncEngine | None = None - -SessionFactory: sessionmaker[Session] | None = None - - -if LOG_POSTGRES_LATENCY: - # Function to log before query execution - @event.listens_for(Engine, "before_cursor_execute") - def before_cursor_execute( # type: ignore - conn, cursor, statement, parameters, context, executemany - ): - conn.info["query_start_time"] = time.time() - - # Function to log after query execution - @event.listens_for(Engine, "after_cursor_execute") - def after_cursor_execute( # type: ignore - conn, cursor, statement, parameters, context, executemany - ): - total_time = time.time() - conn.info["query_start_time"] - # don't spam TOO hard - if total_time > 0.1: - logger.debug( - f"Query Complete: {statement}\n\nTotal Time: {total_time:.4f} seconds" - ) - - -if LOG_POSTGRES_CONN_COUNTS: - # Global counter for connection checkouts and checkins - checkout_count = 0 - checkin_count = 0 - - @event.listens_for(Engine, "checkout") - def log_checkout(dbapi_connection, connection_record, connection_proxy): # type: ignore - global checkout_count - checkout_count += 1 - - active_connections = connection_proxy._pool.checkedout() - idle_connections = connection_proxy._pool.checkedin() - pool_size = connection_proxy._pool.size() - logger.debug( - "Connection Checkout\n" - f"Active Connections: {active_connections};\n" - f"Idle: {idle_connections};\n" - f"Pool Size: {pool_size};\n" - f"Total connection checkouts: {checkout_count}" - ) - - @event.listens_for(Engine, "checkin") - def log_checkin(dbapi_connection, connection_record): # type: ignore - global checkin_count - checkin_count += 1 - logger.debug(f"Total connection checkins: {checkin_count}") - - -"""END DEBUGGING LOGGING""" - - -def get_db_current_time(db_session: Session) -> datetime: - """Get the current time from Postgres representing the start of the transaction - Within the same transaction this value will not update - This datetime object returned should be timezone aware, default Postgres timezone is UTC - """ - result = db_session.execute(text("SELECT NOW()")).scalar() - if result is None: - raise ValueError("Database did not return a time") - return result - - -def build_connection_string( - *, - db_api: str = ASYNC_DB_API, - user: str = POSTGRES_USER, - password: str = POSTGRES_PASSWORD, - host: str = POSTGRES_HOST, - port: str = POSTGRES_PORT, - db: str = POSTGRES_DB, - app_name: str | None = None, -) -> str: - if app_name: - return f"postgresql+{db_api}://{user}:{password}@{host}:{port}/{db}?application_name={app_name}" - - return f"postgresql+{db_api}://{user}:{password}@{host}:{port}/{db}" - - -def init_sqlalchemy_engine(app_name: str) -> None: - global POSTGRES_APP_NAME - POSTGRES_APP_NAME = app_name - - -def get_sqlalchemy_engine() -> Engine: - global _SYNC_ENGINE - if _SYNC_ENGINE is None: - connection_string = build_connection_string( - db_api=SYNC_DB_API, app_name=POSTGRES_APP_NAME + "_sync" - ) - _SYNC_ENGINE = create_engine( - connection_string, - pool_size=40, - max_overflow=10, - pool_pre_ping=POSTGRES_POOL_PRE_PING, - pool_recycle=POSTGRES_POOL_RECYCLE, - ) - return _SYNC_ENGINE - - -def get_sqlalchemy_async_engine() -> AsyncEngine: - global _ASYNC_ENGINE - if _ASYNC_ENGINE is None: - # underlying asyncpg cannot accept application_name directly in the connection string - # https://github.com/MagicStack/asyncpg/issues/798 - connection_string = build_connection_string() - _ASYNC_ENGINE = create_async_engine( - connection_string, - connect_args={ - "server_settings": {"application_name": POSTGRES_APP_NAME + "_async"} - }, - pool_size=40, - max_overflow=10, - pool_pre_ping=POSTGRES_POOL_PRE_PING, - pool_recycle=POSTGRES_POOL_RECYCLE, - ) - return _ASYNC_ENGINE - - -def get_session_context_manager() -> ContextManager[Session]: - return contextlib.contextmanager(get_session)() - - -def get_session() -> Generator[Session, None, None]: - # The line below was added to monitor the latency caused by Postgres connections - # during API calls. - # with tracer.trace("db.get_session"): - with Session(get_sqlalchemy_engine(), expire_on_commit=False) as session: - yield session - - -async def get_async_session() -> AsyncGenerator[AsyncSession, None]: - async with AsyncSession( - get_sqlalchemy_async_engine(), expire_on_commit=False - ) as async_session: - yield async_session - - -async def warm_up_connections( - sync_connections_to_warm_up: int = 20, async_connections_to_warm_up: int = 20 -) -> None: - sync_postgres_engine = get_sqlalchemy_engine() - connections = [ - sync_postgres_engine.connect() for _ in range(sync_connections_to_warm_up) - ] - for conn in connections: - conn.execute(text("SELECT 1")) - for conn in connections: - conn.close() - - async_postgres_engine = get_sqlalchemy_async_engine() - async_connections = [ - await async_postgres_engine.connect() - for _ in range(async_connections_to_warm_up) - ] - for async_conn in async_connections: - await async_conn.execute(text("SELECT 1")) - for async_conn in async_connections: - await async_conn.close() - - -def get_session_factory() -> sessionmaker[Session]: - global SessionFactory - if SessionFactory is None: - SessionFactory = sessionmaker(bind=get_sqlalchemy_engine()) - return SessionFactory diff --git a/backend/danswer/db/enums.py b/backend/danswer/db/enums.py deleted file mode 100644 index eac048e10ab..00000000000 --- a/backend/danswer/db/enums.py +++ /dev/null @@ -1,53 +0,0 @@ -from enum import Enum as PyEnum - - -class IndexingStatus(str, PyEnum): - NOT_STARTED = "not_started" - IN_PROGRESS = "in_progress" - SUCCESS = "success" - FAILED = "failed" - COMPLETED_WITH_ERRORS = "completed_with_errors" - - def is_terminal(self) -> bool: - terminal_states = { - IndexingStatus.SUCCESS, - IndexingStatus.COMPLETED_WITH_ERRORS, - IndexingStatus.FAILED, - } - return self in terminal_states - - -# these may differ in the future, which is why we're okay with this duplication -class DeletionStatus(str, PyEnum): - NOT_STARTED = "not_started" - IN_PROGRESS = "in_progress" - SUCCESS = "success" - FAILED = "failed" - - -# Consistent with Celery task statuses -class TaskStatus(str, PyEnum): - PENDING = "PENDING" - STARTED = "STARTED" - SUCCESS = "SUCCESS" - FAILURE = "FAILURE" - - -class IndexModelStatus(str, PyEnum): - PAST = "PAST" - PRESENT = "PRESENT" - FUTURE = "FUTURE" - - -class ChatSessionSharedStatus(str, PyEnum): - PUBLIC = "public" - PRIVATE = "private" - - -class ConnectorCredentialPairStatus(str, PyEnum): - ACTIVE = "ACTIVE" - PAUSED = "PAUSED" - DELETING = "DELETING" - - def is_active(self) -> bool: - return self == ConnectorCredentialPairStatus.ACTIVE diff --git a/backend/danswer/db/feedback.py b/backend/danswer/db/feedback.py deleted file mode 100644 index 79557f209dc..00000000000 --- a/backend/danswer/db/feedback.py +++ /dev/null @@ -1,268 +0,0 @@ -from uuid import UUID - -from fastapi import HTTPException -from sqlalchemy import and_ -from sqlalchemy import asc -from sqlalchemy import delete -from sqlalchemy import desc -from sqlalchemy import exists -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy.orm import aliased -from sqlalchemy.orm import Session - -from danswer.configs.constants import MessageType -from danswer.configs.constants import SearchFeedbackType -from danswer.db.chat import get_chat_message -from danswer.db.models import ChatMessageFeedback -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Document as DbDocument -from danswer.db.models import DocumentByConnectorCredentialPair -from danswer.db.models import DocumentRetrievalFeedback -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup__ConnectorCredentialPair -from danswer.db.models import UserRole -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import UpdateRequest -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _fetch_db_doc_by_id(doc_id: str, db_session: Session) -> DbDocument: - stmt = select(DbDocument).where(DbDocument.id == doc_id) - result = db_session.execute(stmt) - doc = result.scalar_one_or_none() - - if not doc: - raise ValueError("Invalid Document ID Provided") - - return doc - - -def _add_user_filters( - stmt: Select, user: User | None, get_editable: bool = True -) -> Select: - # If user is None, assume the user is an admin or auth is disabled - if user is None or user.role == UserRole.ADMIN: - return stmt - - DocByCC = aliased(DocumentByConnectorCredentialPair) - CCPair = aliased(ConnectorCredentialPair) - UG__CCpair = aliased(UserGroup__ConnectorCredentialPair) - User__UG = aliased(User__UserGroup) - - """ - Here we select documents by relation: - User -> User__UserGroup -> UserGroup__ConnectorCredentialPair -> - ConnectorCredentialPair -> DocumentByConnectorCredentialPair -> Document - """ - stmt = ( - stmt.outerjoin(DocByCC, DocByCC.id == DbDocument.id) - .outerjoin( - CCPair, - and_( - CCPair.connector_id == DocByCC.connector_id, - CCPair.credential_id == DocByCC.credential_id, - ), - ) - .outerjoin(UG__CCpair, UG__CCpair.cc_pair_id == CCPair.id) - .outerjoin(User__UG, User__UG.user_group_id == UG__CCpair.user_group_id) - ) - - """ - Filter Documents by: - - if the user is in the user_group that owns the object - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out objects that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all objects in the groups the user is a curator - for (as well as public objects as well) - """ - where_clause = User__UG.user_id == user.id - if user.role == UserRole.CURATOR and get_editable: - where_clause &= User__UG.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id) - where_clause &= ( - ~exists() - .where(UG__CCpair.cc_pair_id == CCPair.id) - .where(~UG__CCpair.user_group_id.in_(user_groups)) - .correlate(CCPair) - ) - else: - where_clause |= CCPair.is_public == True # noqa: E712 - - return stmt.where(where_clause) - - -def fetch_docs_ranked_by_boost( - db_session: Session, - user: User | None = None, - ascending: bool = False, - limit: int = 100, -) -> list[DbDocument]: - order_func = asc if ascending else desc - stmt = select(DbDocument) - - stmt = _add_user_filters(stmt=stmt, user=user, get_editable=False) - - stmt = stmt.order_by( - order_func(DbDocument.boost), order_func(DbDocument.semantic_id) - ) - stmt = stmt.limit(limit) - result = db_session.execute(stmt) - doc_list = result.scalars().all() - - return list(doc_list) - - -def update_document_boost( - db_session: Session, - document_id: str, - boost: int, - document_index: DocumentIndex, - user: User | None = None, -) -> None: - stmt = select(DbDocument).where(DbDocument.id == document_id) - stmt = _add_user_filters(stmt, user, get_editable=True) - result = db_session.execute(stmt).scalar_one_or_none() - if result is None: - raise HTTPException( - status_code=400, detail="Document is not editable by this user" - ) - - result.boost = boost - - update = UpdateRequest( - document_ids=[document_id], - boost=boost, - ) - - document_index.update(update_requests=[update]) - - db_session.commit() - - -def update_document_hidden( - db_session: Session, - document_id: str, - hidden: bool, - document_index: DocumentIndex, - user: User | None = None, -) -> None: - stmt = select(DbDocument).where(DbDocument.id == document_id) - stmt = _add_user_filters(stmt, user, get_editable=True) - result = db_session.execute(stmt).scalar_one_or_none() - if result is None: - raise HTTPException( - status_code=400, detail="Document is not editable by this user" - ) - - result.hidden = hidden - - update = UpdateRequest( - document_ids=[document_id], - hidden=hidden, - ) - - document_index.update(update_requests=[update]) - - db_session.commit() - - -def create_doc_retrieval_feedback( - message_id: int, - document_id: str, - document_rank: int, - document_index: DocumentIndex, - db_session: Session, - clicked: bool = False, - feedback: SearchFeedbackType | None = None, -) -> None: - """Creates a new Document feedback row and updates the boost value in Postgres and Vespa""" - db_doc = _fetch_db_doc_by_id(document_id, db_session) - - retrieval_feedback = DocumentRetrievalFeedback( - chat_message_id=message_id, - document_id=document_id, - document_rank=document_rank, - clicked=clicked, - feedback=feedback, - ) - - if feedback is not None: - if feedback == SearchFeedbackType.ENDORSE: - db_doc.boost += 1 - elif feedback == SearchFeedbackType.REJECT: - db_doc.boost -= 1 - elif feedback == SearchFeedbackType.HIDE: - db_doc.hidden = True - elif feedback == SearchFeedbackType.UNHIDE: - db_doc.hidden = False - else: - raise ValueError("Unhandled document feedback type") - - if feedback in [ - SearchFeedbackType.ENDORSE, - SearchFeedbackType.REJECT, - SearchFeedbackType.HIDE, - ]: - update = UpdateRequest( - document_ids=[document_id], boost=db_doc.boost, hidden=db_doc.hidden - ) - # Updates are generally batched for efficiency, this case only 1 doc/value is updated - document_index.update(update_requests=[update]) - - db_session.add(retrieval_feedback) - db_session.commit() - - -def delete_document_feedback_for_documents__no_commit( - document_ids: list[str], db_session: Session -) -> None: - """NOTE: does not commit transaction so that this can be used as part of a - larger transaction block.""" - stmt = delete(DocumentRetrievalFeedback).where( - DocumentRetrievalFeedback.document_id.in_(document_ids) - ) - db_session.execute(stmt) - - -def create_chat_message_feedback( - is_positive: bool | None, - feedback_text: str | None, - chat_message_id: int, - user_id: UUID | None, - db_session: Session, - # Slack user requested help from human - required_followup: bool | None = None, - predefined_feedback: str | None = None, # Added predefined_feedback parameter -) -> None: - if ( - is_positive is None - and feedback_text is None - and required_followup is None - and predefined_feedback is None - ): - raise ValueError("No feedback provided") - - chat_message = get_chat_message( - chat_message_id=chat_message_id, user_id=user_id, db_session=db_session - ) - - if chat_message.message_type != MessageType.ASSISTANT: - raise ValueError("Can only provide feedback on LLM Outputs") - - message_feedback = ChatMessageFeedback( - chat_message_id=chat_message_id, - is_positive=is_positive, - feedback_text=feedback_text, - required_followup=required_followup, - predefined_feedback=predefined_feedback, - ) - - db_session.add(message_feedback) - db_session.commit() diff --git a/backend/danswer/db/folder.py b/backend/danswer/db/folder.py deleted file mode 100644 index 77e543a8dc8..00000000000 --- a/backend/danswer/db/folder.py +++ /dev/null @@ -1,132 +0,0 @@ -from uuid import UUID - -from sqlalchemy.orm import Session - -from danswer.db.chat import delete_chat_session -from danswer.db.models import ChatFolder -from danswer.db.models import ChatSession -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_user_folders( - user_id: UUID | None, - db_session: Session, -) -> list[ChatFolder]: - return db_session.query(ChatFolder).filter(ChatFolder.user_id == user_id).all() - - -def update_folder_display_priority( - user_id: UUID | None, - display_priority_map: dict[int, int], - db_session: Session, -) -> None: - folders = get_user_folders(user_id=user_id, db_session=db_session) - folder_ids = {folder.id for folder in folders} - if folder_ids != set(display_priority_map.keys()): - raise ValueError("Invalid Folder IDs provided") - - for folder in folders: - folder.display_priority = display_priority_map[folder.id] - - db_session.commit() - - -def get_folder_by_id( - user_id: UUID | None, - folder_id: int, - db_session: Session, -) -> ChatFolder: - folder = ( - db_session.query(ChatFolder).filter(ChatFolder.id == folder_id).one_or_none() - ) - if not folder: - raise ValueError("Folder by specified id does not exist") - - if folder.user_id != user_id: - raise PermissionError(f"Folder does not belong to user: {user_id}") - - return folder - - -def create_folder( - user_id: UUID | None, folder_name: str | None, db_session: Session -) -> int: - new_folder = ChatFolder( - user_id=user_id, - name=folder_name, - ) - db_session.add(new_folder) - db_session.commit() - - return new_folder.id - - -def rename_folder( - user_id: UUID | None, folder_id: int, folder_name: str | None, db_session: Session -) -> None: - folder = get_folder_by_id( - user_id=user_id, folder_id=folder_id, db_session=db_session - ) - - folder.name = folder_name - db_session.commit() - - -def add_chat_to_folder( - user_id: UUID | None, folder_id: int, chat_session: ChatSession, db_session: Session -) -> None: - folder = get_folder_by_id( - user_id=user_id, folder_id=folder_id, db_session=db_session - ) - - chat_session.folder_id = folder.id - - db_session.commit() - - -def remove_chat_from_folder( - user_id: UUID | None, folder_id: int, chat_session: ChatSession, db_session: Session -) -> None: - folder = get_folder_by_id( - user_id=user_id, folder_id=folder_id, db_session=db_session - ) - - if chat_session.folder_id != folder.id: - raise ValueError("The chat session is not in the specified folder.") - - if folder.user_id != user_id: - raise ValueError( - f"Tried to remove a chat session from a folder that does not below to " - f"this user, user id: {user_id}" - ) - - chat_session.folder_id = None - if chat_session in folder.chat_sessions: - folder.chat_sessions.remove(chat_session) - - db_session.commit() - - -def delete_folder( - user_id: UUID | None, - folder_id: int, - including_chats: bool, - db_session: Session, -) -> None: - folder = get_folder_by_id( - user_id=user_id, folder_id=folder_id, db_session=db_session - ) - - # Assuming there will not be a massive number of chats in any given folder - if including_chats: - for chat_session in folder.chat_sessions: - delete_chat_session( - user_id=user_id, - chat_session_id=chat_session.id, - db_session=db_session, - ) - - db_session.delete(folder) - db_session.commit() diff --git a/backend/danswer/db/index_attempt.py b/backend/danswer/db/index_attempt.py deleted file mode 100644 index 0932d500bbd..00000000000 --- a/backend/danswer/db/index_attempt.py +++ /dev/null @@ -1,434 +0,0 @@ -from collections.abc import Sequence - -from sqlalchemy import and_ -from sqlalchemy import delete -from sqlalchemy import desc -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import Session - -from danswer.connectors.models import Document -from danswer.connectors.models import DocumentErrorSummary -from danswer.db.models import IndexAttempt -from danswer.db.models import IndexAttemptError -from danswer.db.models import IndexingStatus -from danswer.db.models import IndexModelStatus -from danswer.db.models import SearchSettings -from danswer.server.documents.models import ConnectorCredentialPair -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.utils.logger import setup_logger -from danswer.utils.telemetry import optional_telemetry -from danswer.utils.telemetry import RecordType - -logger = setup_logger() - - -def get_last_attempt_for_cc_pair( - cc_pair_id: int, - search_settings_id: int, - db_session: Session, -) -> IndexAttempt | None: - return ( - db_session.query(IndexAttempt) - .filter( - IndexAttempt.connector_credential_pair_id == cc_pair_id, - IndexAttempt.search_settings_id == search_settings_id, - ) - .order_by(IndexAttempt.time_updated.desc()) - .first() - ) - - -def get_index_attempt( - db_session: Session, index_attempt_id: int -) -> IndexAttempt | None: - stmt = select(IndexAttempt).where(IndexAttempt.id == index_attempt_id) - return db_session.scalars(stmt).first() - - -def create_index_attempt( - connector_credential_pair_id: int, - search_settings_id: int, - db_session: Session, - from_beginning: bool = False, -) -> int: - new_attempt = IndexAttempt( - connector_credential_pair_id=connector_credential_pair_id, - search_settings_id=search_settings_id, - from_beginning=from_beginning, - status=IndexingStatus.NOT_STARTED, - ) - db_session.add(new_attempt) - db_session.commit() - - return new_attempt.id - - -def get_inprogress_index_attempts( - connector_id: int | None, - db_session: Session, -) -> list[IndexAttempt]: - stmt = select(IndexAttempt) - if connector_id is not None: - stmt = stmt.where( - IndexAttempt.connector_credential_pair.has(connector_id=connector_id) - ) - stmt = stmt.where(IndexAttempt.status == IndexingStatus.IN_PROGRESS) - - incomplete_attempts = db_session.scalars(stmt) - return list(incomplete_attempts.all()) - - -def get_not_started_index_attempts(db_session: Session) -> list[IndexAttempt]: - """This eagerly loads the connector and credential so that the db_session can be expired - before running long-living indexing jobs, which causes increasing memory usage. - - Results are ordered by time_created (oldest to newest).""" - stmt = select(IndexAttempt) - stmt = stmt.where(IndexAttempt.status == IndexingStatus.NOT_STARTED) - stmt = stmt.order_by(IndexAttempt.time_created) - stmt = stmt.options( - joinedload(IndexAttempt.connector_credential_pair).joinedload( - ConnectorCredentialPair.connector - ), - joinedload(IndexAttempt.connector_credential_pair).joinedload( - ConnectorCredentialPair.credential - ), - ) - new_attempts = db_session.scalars(stmt) - return list(new_attempts.all()) - - -def mark_attempt_in_progress( - index_attempt: IndexAttempt, - db_session: Session, -) -> None: - index_attempt.status = IndexingStatus.IN_PROGRESS - index_attempt.time_started = index_attempt.time_started or func.now() # type: ignore - db_session.commit() - - -def mark_attempt_succeeded( - index_attempt: IndexAttempt, - db_session: Session, -) -> None: - index_attempt.status = IndexingStatus.SUCCESS - db_session.add(index_attempt) - db_session.commit() - - -def mark_attempt_partially_succeeded( - index_attempt: IndexAttempt, - db_session: Session, -) -> None: - index_attempt.status = IndexingStatus.COMPLETED_WITH_ERRORS - db_session.add(index_attempt) - db_session.commit() - - -def mark_attempt_failed( - index_attempt: IndexAttempt, - db_session: Session, - failure_reason: str = "Unknown", - full_exception_trace: str | None = None, -) -> None: - index_attempt.status = IndexingStatus.FAILED - index_attempt.error_msg = failure_reason - index_attempt.full_exception_trace = full_exception_trace - db_session.add(index_attempt) - db_session.commit() - - source = index_attempt.connector_credential_pair.connector.source - optional_telemetry(record_type=RecordType.FAILURE, data={"connector": source}) - - -def update_docs_indexed( - db_session: Session, - index_attempt: IndexAttempt, - total_docs_indexed: int, - new_docs_indexed: int, - docs_removed_from_index: int, -) -> None: - index_attempt.total_docs_indexed = total_docs_indexed - index_attempt.new_docs_indexed = new_docs_indexed - index_attempt.docs_removed_from_index = docs_removed_from_index - - db_session.add(index_attempt) - db_session.commit() - - -def get_last_attempt( - connector_id: int, - credential_id: int, - search_settings_id: int | None, - db_session: Session, -) -> IndexAttempt | None: - stmt = ( - select(IndexAttempt) - .join(ConnectorCredentialPair) - .where( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - IndexAttempt.search_settings_id == search_settings_id, - ) - ) - - # Note, the below is using time_created instead of time_updated - stmt = stmt.order_by(desc(IndexAttempt.time_created)) - - return db_session.execute(stmt).scalars().first() - - -def get_latest_index_attempts( - secondary_index: bool, - db_session: Session, -) -> Sequence[IndexAttempt]: - ids_stmt = select( - IndexAttempt.connector_credential_pair_id, - func.max(IndexAttempt.id).label("max_id"), - ).join(SearchSettings, IndexAttempt.search_settings_id == SearchSettings.id) - - if secondary_index: - ids_stmt = ids_stmt.where(SearchSettings.status == IndexModelStatus.FUTURE) - else: - ids_stmt = ids_stmt.where(SearchSettings.status == IndexModelStatus.PRESENT) - - ids_stmt = ids_stmt.group_by(IndexAttempt.connector_credential_pair_id) - ids_subquery = ids_stmt.subquery() - - stmt = ( - select(IndexAttempt) - .join( - ids_subquery, - IndexAttempt.connector_credential_pair_id - == ids_subquery.c.connector_credential_pair_id, - ) - .where(IndexAttempt.id == ids_subquery.c.max_id) - ) - - return db_session.execute(stmt).scalars().all() - - -def get_index_attempts_for_connector( - db_session: Session, - connector_id: int, - only_current: bool = True, - disinclude_finished: bool = False, -) -> Sequence[IndexAttempt]: - stmt = ( - select(IndexAttempt) - .join(ConnectorCredentialPair) - .where(ConnectorCredentialPair.connector_id == connector_id) - ) - if disinclude_finished: - stmt = stmt.where( - IndexAttempt.status.in_( - [IndexingStatus.NOT_STARTED, IndexingStatus.IN_PROGRESS] - ) - ) - if only_current: - stmt = stmt.join(SearchSettings).where( - SearchSettings.status == IndexModelStatus.PRESENT - ) - - stmt = stmt.order_by(IndexAttempt.time_created.desc()) - return db_session.execute(stmt).scalars().all() - - -def get_latest_finished_index_attempt_for_cc_pair( - connector_credential_pair_id: int, - secondary_index: bool, - db_session: Session, -) -> IndexAttempt | None: - stmt = select(IndexAttempt).distinct() - stmt = stmt.where( - IndexAttempt.connector_credential_pair_id == connector_credential_pair_id, - IndexAttempt.status.not_in( - [IndexingStatus.NOT_STARTED, IndexingStatus.IN_PROGRESS] - ), - ) - if secondary_index: - stmt = stmt.join(SearchSettings).where( - SearchSettings.status == IndexModelStatus.FUTURE - ) - else: - stmt = stmt.join(SearchSettings).where( - SearchSettings.status == IndexModelStatus.PRESENT - ) - stmt = stmt.order_by(desc(IndexAttempt.time_created)) - stmt = stmt.limit(1) - return db_session.execute(stmt).scalar_one_or_none() - - -def get_index_attempts_for_cc_pair( - db_session: Session, - cc_pair_identifier: ConnectorCredentialPairIdentifier, - only_current: bool = True, - disinclude_finished: bool = False, -) -> Sequence[IndexAttempt]: - stmt = ( - select(IndexAttempt) - .join(ConnectorCredentialPair) - .where( - and_( - ConnectorCredentialPair.connector_id == cc_pair_identifier.connector_id, - ConnectorCredentialPair.credential_id - == cc_pair_identifier.credential_id, - ) - ) - ) - if disinclude_finished: - stmt = stmt.where( - IndexAttempt.status.in_( - [IndexingStatus.NOT_STARTED, IndexingStatus.IN_PROGRESS] - ) - ) - if only_current: - stmt = stmt.join(SearchSettings).where( - SearchSettings.status == IndexModelStatus.PRESENT - ) - - stmt = stmt.order_by(IndexAttempt.time_created.desc()) - return db_session.execute(stmt).scalars().all() - - -def delete_index_attempts( - connector_id: int, - credential_id: int, - db_session: Session, -) -> None: - stmt = delete(IndexAttempt).where( - IndexAttempt.connector_credential_pair_id == ConnectorCredentialPair.id, - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - ) - - db_session.execute(stmt) - - -def expire_index_attempts( - search_settings_id: int, - db_session: Session, -) -> None: - delete_query = ( - delete(IndexAttempt) - .where(IndexAttempt.search_settings_id == search_settings_id) - .where(IndexAttempt.status == IndexingStatus.NOT_STARTED) - ) - db_session.execute(delete_query) - - update_query = ( - update(IndexAttempt) - .where(IndexAttempt.search_settings_id == search_settings_id) - .where(IndexAttempt.status != IndexingStatus.SUCCESS) - .values( - status=IndexingStatus.FAILED, - error_msg="Canceled due to embedding model swap", - ) - ) - db_session.execute(update_query) - - db_session.commit() - - -def cancel_indexing_attempts_for_ccpair( - cc_pair_id: int, - db_session: Session, - include_secondary_index: bool = False, -) -> None: - stmt = ( - delete(IndexAttempt) - .where(IndexAttempt.connector_credential_pair_id == cc_pair_id) - .where(IndexAttempt.status == IndexingStatus.NOT_STARTED) - ) - - if not include_secondary_index: - subquery = select(SearchSettings.id).where( - SearchSettings.status != IndexModelStatus.FUTURE - ) - stmt = stmt.where(IndexAttempt.search_settings_id.in_(subquery)) - - db_session.execute(stmt) - - db_session.commit() - - -def cancel_indexing_attempts_past_model( - db_session: Session, -) -> None: - """Stops all indexing attempts that are in progress or not started for - any embedding model that not present/future""" - db_session.execute( - update(IndexAttempt) - .where( - IndexAttempt.status.in_( - [IndexingStatus.IN_PROGRESS, IndexingStatus.NOT_STARTED] - ), - IndexAttempt.search_settings_id == SearchSettings.id, - SearchSettings.status == IndexModelStatus.PAST, - ) - .values(status=IndexingStatus.FAILED) - ) - - db_session.commit() - - -def count_unique_cc_pairs_with_successful_index_attempts( - search_settings_id: int | None, - db_session: Session, -) -> int: - """Collect all of the Index Attempts that are successful and for the specified embedding model - Then do distinct by connector_id and credential_id which is equivalent to the cc-pair. Finally, - do a count to get the total number of unique cc-pairs with successful attempts""" - unique_pairs_count = ( - db_session.query(IndexAttempt.connector_credential_pair_id) - .join(ConnectorCredentialPair) - .filter( - IndexAttempt.search_settings_id == search_settings_id, - IndexAttempt.status == IndexingStatus.SUCCESS, - ) - .distinct() - .count() - ) - - return unique_pairs_count - - -def create_index_attempt_error( - index_attempt_id: int | None, - batch: int | None, - docs: list[Document], - exception_msg: str, - exception_traceback: str, - db_session: Session, -) -> int: - doc_summaries = [] - for doc in docs: - doc_summary = DocumentErrorSummary.from_document(doc) - doc_summaries.append(doc_summary.to_dict()) - - new_error = IndexAttemptError( - index_attempt_id=index_attempt_id, - batch=batch, - doc_summaries=doc_summaries, - error_msg=exception_msg, - traceback=exception_traceback, - ) - db_session.add(new_error) - db_session.commit() - - return new_error.id - - -def get_index_attempt_errors( - index_attempt_id: int, - db_session: Session, -) -> list[IndexAttemptError]: - stmt = select(IndexAttemptError).where( - IndexAttemptError.index_attempt_id == index_attempt_id - ) - - errors = db_session.scalars(stmt) - return list(errors.all()) diff --git a/backend/danswer/db/input_prompt.py b/backend/danswer/db/input_prompt.py deleted file mode 100644 index efa54d986a1..00000000000 --- a/backend/danswer/db/input_prompt.py +++ /dev/null @@ -1,202 +0,0 @@ -from uuid import UUID - -from fastapi import HTTPException -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import InputPrompt -from danswer.db.models import User -from danswer.server.features.input_prompt.models import InputPromptSnapshot -from danswer.server.manage.models import UserInfo -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def insert_input_prompt_if_not_exists( - user: User | None, - input_prompt_id: int | None, - prompt: str, - content: str, - active: bool, - is_public: bool, - db_session: Session, - commit: bool = True, -) -> InputPrompt: - if input_prompt_id is not None: - input_prompt = ( - db_session.query(InputPrompt).filter_by(id=input_prompt_id).first() - ) - else: - query = db_session.query(InputPrompt).filter(InputPrompt.prompt == prompt) - if user: - query = query.filter(InputPrompt.user_id == user.id) - else: - query = query.filter(InputPrompt.user_id.is_(None)) - input_prompt = query.first() - - if input_prompt is None: - input_prompt = InputPrompt( - id=input_prompt_id, - prompt=prompt, - content=content, - active=active, - is_public=is_public or user is None, - user_id=user.id if user else None, - ) - db_session.add(input_prompt) - - if commit: - db_session.commit() - - return input_prompt - - -def insert_input_prompt( - prompt: str, - content: str, - is_public: bool, - user: User | None, - db_session: Session, -) -> InputPrompt: - input_prompt = InputPrompt( - prompt=prompt, - content=content, - active=True, - is_public=is_public or user is None, - user_id=user.id if user is not None else None, - ) - db_session.add(input_prompt) - db_session.commit() - - return input_prompt - - -def update_input_prompt( - user: User | None, - input_prompt_id: int, - prompt: str, - content: str, - active: bool, - db_session: Session, -) -> InputPrompt: - input_prompt = db_session.scalar( - select(InputPrompt).where(InputPrompt.id == input_prompt_id) - ) - if input_prompt is None: - raise ValueError(f"No input prompt with id {input_prompt_id}") - - if not validate_user_prompt_authorization(user, input_prompt): - raise HTTPException(status_code=401, detail="You don't own this prompt") - - input_prompt.prompt = prompt - input_prompt.content = content - input_prompt.active = active - - db_session.commit() - return input_prompt - - -def validate_user_prompt_authorization( - user: User | None, input_prompt: InputPrompt -) -> bool: - prompt = InputPromptSnapshot.from_model(input_prompt=input_prompt) - - if prompt.user_id is not None: - if user is None: - return False - - user_details = UserInfo.from_model(user) - if str(user_details.id) != str(prompt.user_id): - return False - return True - - -def remove_public_input_prompt(input_prompt_id: int, db_session: Session) -> None: - input_prompt = db_session.scalar( - select(InputPrompt).where(InputPrompt.id == input_prompt_id) - ) - - if input_prompt is None: - raise ValueError(f"No input prompt with id {input_prompt_id}") - - if not input_prompt.is_public: - raise HTTPException(status_code=400, detail="This prompt is not public") - - db_session.delete(input_prompt) - db_session.commit() - - -def remove_input_prompt( - user: User | None, input_prompt_id: int, db_session: Session -) -> None: - input_prompt = db_session.scalar( - select(InputPrompt).where(InputPrompt.id == input_prompt_id) - ) - if input_prompt is None: - raise ValueError(f"No input prompt with id {input_prompt_id}") - - if input_prompt.is_public: - raise HTTPException( - status_code=400, detail="Cannot delete public prompts with this method" - ) - - if not validate_user_prompt_authorization(user, input_prompt): - raise HTTPException(status_code=401, detail="You do not own this prompt") - - db_session.delete(input_prompt) - db_session.commit() - - -def fetch_input_prompt_by_id( - id: int, user_id: UUID | None, db_session: Session -) -> InputPrompt: - query = select(InputPrompt).where(InputPrompt.id == id) - - if user_id: - query = query.where( - (InputPrompt.user_id == user_id) | (InputPrompt.user_id is None) - ) - else: - # If no user_id is provided, only fetch prompts without a user_id (aka public) - query = query.where(InputPrompt.user_id == None) # noqa - - result = db_session.scalar(query) - - if result is None: - raise HTTPException(422, "No input prompt found") - - return result - - -def fetch_public_input_prompts( - db_session: Session, -) -> list[InputPrompt]: - query = select(InputPrompt).where(InputPrompt.is_public) - return list(db_session.scalars(query).all()) - - -def fetch_input_prompts_by_user( - db_session: Session, - user_id: UUID | None, - active: bool | None = None, - include_public: bool = False, -) -> list[InputPrompt]: - query = select(InputPrompt) - - if user_id is not None: - if include_public: - query = query.where( - (InputPrompt.user_id == user_id) | InputPrompt.is_public - ) - else: - query = query.where(InputPrompt.user_id == user_id) - - elif include_public: - query = query.where(InputPrompt.is_public) - - if active is not None: - query = query.where(InputPrompt.active == active) - - return list(db_session.scalars(query).all()) diff --git a/backend/danswer/db/llm.py b/backend/danswer/db/llm.py deleted file mode 100644 index 152cb130573..00000000000 --- a/backend/danswer/db/llm.py +++ /dev/null @@ -1,199 +0,0 @@ -from sqlalchemy import delete -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import CloudEmbeddingProvider as CloudEmbeddingProviderModel -from danswer.db.models import LLMProvider as LLMProviderModel -from danswer.db.models import LLMProvider__UserGroup -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.server.manage.embedding.models import CloudEmbeddingProvider -from danswer.server.manage.embedding.models import CloudEmbeddingProviderCreationRequest -from danswer.server.manage.llm.models import FullLLMProvider -from danswer.server.manage.llm.models import LLMProviderUpsertRequest -from shared_configs.enums import EmbeddingProvider - - -def update_group_llm_provider_relationships__no_commit( - llm_provider_id: int, - group_ids: list[int] | None, - db_session: Session, -) -> None: - # Delete existing relationships - db_session.query(LLMProvider__UserGroup).filter( - LLMProvider__UserGroup.llm_provider_id == llm_provider_id - ).delete(synchronize_session="fetch") - - # Add new relationships from given group_ids - if group_ids: - new_relationships = [ - LLMProvider__UserGroup( - llm_provider_id=llm_provider_id, - user_group_id=group_id, - ) - for group_id in group_ids - ] - db_session.add_all(new_relationships) - - -def upsert_cloud_embedding_provider( - db_session: Session, provider: CloudEmbeddingProviderCreationRequest -) -> CloudEmbeddingProvider: - existing_provider = ( - db_session.query(CloudEmbeddingProviderModel) - .filter_by(provider_type=provider.provider_type) - .first() - ) - if existing_provider: - for key, value in provider.model_dump().items(): - setattr(existing_provider, key, value) - else: - new_provider = CloudEmbeddingProviderModel(**provider.model_dump()) - db_session.add(new_provider) - existing_provider = new_provider - db_session.commit() - db_session.refresh(existing_provider) - return CloudEmbeddingProvider.from_request(existing_provider) - - -def upsert_llm_provider( - db_session: Session, llm_provider: LLMProviderUpsertRequest -) -> FullLLMProvider: - existing_llm_provider = db_session.scalar( - select(LLMProviderModel).where(LLMProviderModel.name == llm_provider.name) - ) - - if not existing_llm_provider: - existing_llm_provider = LLMProviderModel(name=llm_provider.name) - db_session.add(existing_llm_provider) - - existing_llm_provider.provider = llm_provider.provider - existing_llm_provider.api_key = llm_provider.api_key - existing_llm_provider.api_base = llm_provider.api_base - existing_llm_provider.api_version = llm_provider.api_version - existing_llm_provider.custom_config = llm_provider.custom_config - existing_llm_provider.default_model_name = llm_provider.default_model_name - existing_llm_provider.fast_default_model_name = llm_provider.fast_default_model_name - existing_llm_provider.model_names = llm_provider.model_names - existing_llm_provider.is_public = llm_provider.is_public - existing_llm_provider.display_model_names = llm_provider.display_model_names - - if not existing_llm_provider.id: - # If its not already in the db, we need to generate an ID by flushing - db_session.flush() - - # Make sure the relationship table stays up to date - update_group_llm_provider_relationships__no_commit( - llm_provider_id=existing_llm_provider.id, - group_ids=llm_provider.groups, - db_session=db_session, - ) - - db_session.commit() - - return FullLLMProvider.from_model(existing_llm_provider) - - -def fetch_existing_embedding_providers( - db_session: Session, -) -> list[CloudEmbeddingProviderModel]: - return list(db_session.scalars(select(CloudEmbeddingProviderModel)).all()) - - -def fetch_existing_llm_providers( - db_session: Session, - user: User | None = None, -) -> list[LLMProviderModel]: - if not user: - return list(db_session.scalars(select(LLMProviderModel)).all()) - stmt = select(LLMProviderModel).distinct() - user_groups_select = select(User__UserGroup.user_group_id).where( - User__UserGroup.user_id == user.id - ) - access_conditions = or_( - LLMProviderModel.is_public, - LLMProviderModel.id.in_( # User is part of a group that has access - select(LLMProvider__UserGroup.llm_provider_id).where( - LLMProvider__UserGroup.user_group_id.in_(user_groups_select) # type: ignore - ) - ), - ) - stmt = stmt.where(access_conditions) - - return list(db_session.scalars(stmt).all()) - - -def fetch_embedding_provider( - db_session: Session, provider_type: EmbeddingProvider -) -> CloudEmbeddingProviderModel | None: - return db_session.scalar( - select(CloudEmbeddingProviderModel).where( - CloudEmbeddingProviderModel.provider_type == provider_type - ) - ) - - -def fetch_default_provider(db_session: Session) -> FullLLMProvider | None: - provider_model = db_session.scalar( - select(LLMProviderModel).where( - LLMProviderModel.is_default_provider == True # noqa: E712 - ) - ) - if not provider_model: - return None - return FullLLMProvider.from_model(provider_model) - - -def fetch_provider(db_session: Session, provider_name: str) -> FullLLMProvider | None: - provider_model = db_session.scalar( - select(LLMProviderModel).where(LLMProviderModel.name == provider_name) - ) - if not provider_model: - return None - return FullLLMProvider.from_model(provider_model) - - -def remove_embedding_provider( - db_session: Session, provider_type: EmbeddingProvider -) -> None: - db_session.execute( - delete(CloudEmbeddingProviderModel).where( - CloudEmbeddingProviderModel.provider_type == provider_type - ) - ) - - -def remove_llm_provider(db_session: Session, provider_id: int) -> None: - # Remove LLMProvider's dependent relationships - db_session.execute( - delete(LLMProvider__UserGroup).where( - LLMProvider__UserGroup.llm_provider_id == provider_id - ) - ) - # Remove LLMProvider - db_session.execute( - delete(LLMProviderModel).where(LLMProviderModel.id == provider_id) - ) - db_session.commit() - - -def update_default_provider(db_session: Session, provider_id: int) -> None: - new_default = db_session.scalar( - select(LLMProviderModel).where(LLMProviderModel.id == provider_id) - ) - if not new_default: - raise ValueError(f"LLM Provider with id {provider_id} does not exist") - - existing_default = db_session.scalar( - select(LLMProviderModel).where( - LLMProviderModel.is_default_provider == True # noqa: E712 - ) - ) - if existing_default: - existing_default.is_default_provider = None - # required to ensure that the below does not cause a unique constraint violation - db_session.flush() - - new_default.is_default_provider = True - db_session.commit() diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py deleted file mode 100644 index 3cdec323961..00000000000 --- a/backend/danswer/db/models.py +++ /dev/null @@ -1,1735 +0,0 @@ -import datetime -import json -from enum import Enum as PyEnum -from typing import Any -from typing import Literal -from typing import NotRequired -from typing import Optional -from typing_extensions import TypedDict # noreorder -from uuid import UUID - -from fastapi_users_db_sqlalchemy import SQLAlchemyBaseOAuthAccountTableUUID -from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID -from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyBaseAccessTokenTableUUID -from fastapi_users_db_sqlalchemy.generics import TIMESTAMPAware -from sqlalchemy import Boolean -from sqlalchemy import DateTime -from sqlalchemy import Enum -from sqlalchemy import Float -from sqlalchemy import ForeignKey -from sqlalchemy import func -from sqlalchemy import Index -from sqlalchemy import Integer -from sqlalchemy import Sequence -from sqlalchemy import String -from sqlalchemy import Text -from sqlalchemy import UniqueConstraint -from sqlalchemy.dialects import postgresql -from sqlalchemy.engine.interfaces import Dialect -from sqlalchemy.orm import DeclarativeBase -from sqlalchemy.orm import Mapped -from sqlalchemy.orm import mapped_column -from sqlalchemy.orm import relationship -from sqlalchemy.types import LargeBinary -from sqlalchemy.types import TypeDecorator - -from danswer.auth.schemas import UserRole -from danswer.configs.chat_configs import NUM_POSTPROCESSED_RESULTS -from danswer.configs.constants import DEFAULT_BOOST -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import FileOrigin -from danswer.configs.constants import MessageType -from danswer.configs.constants import NotificationType -from danswer.configs.constants import SearchFeedbackType -from danswer.configs.constants import TokenRateLimitScope -from danswer.connectors.models import InputType -from danswer.db.enums import ChatSessionSharedStatus -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.enums import IndexingStatus -from danswer.db.enums import IndexModelStatus -from danswer.db.enums import TaskStatus -from danswer.db.pydantic_type import PydanticType -from danswer.dynamic_configs.interface import JSON_ro -from danswer.file_store.models import FileDescriptor -from danswer.llm.override_models import LLMOverride -from danswer.llm.override_models import PromptOverride -from danswer.search.enums import RecencyBiasSetting -from danswer.utils.encryption import decrypt_bytes_to_string -from danswer.utils.encryption import encrypt_string_to_bytes -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import RerankerProvider - - -class Base(DeclarativeBase): - pass - - -class EncryptedString(TypeDecorator): - impl = LargeBinary - - def process_bind_param(self, value: str | None, dialect: Dialect) -> bytes | None: - if value is not None: - return encrypt_string_to_bytes(value) - return value - - def process_result_value(self, value: bytes | None, dialect: Dialect) -> str | None: - if value is not None: - return decrypt_bytes_to_string(value) - return value - - -class EncryptedJson(TypeDecorator): - impl = LargeBinary - - def process_bind_param(self, value: dict | None, dialect: Dialect) -> bytes | None: - if value is not None: - json_str = json.dumps(value) - return encrypt_string_to_bytes(json_str) - return value - - def process_result_value( - self, value: bytes | None, dialect: Dialect - ) -> dict | None: - if value is not None: - json_str = decrypt_bytes_to_string(value) - return json.loads(json_str) - return value - - -""" -Auth/Authz (users, permissions, access) Tables -""" - - -class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): - # even an almost empty token from keycloak will not fit the default 1024 bytes - access_token: Mapped[str] = mapped_column(Text, nullable=False) # type: ignore - - -class User(SQLAlchemyBaseUserTableUUID, Base): - oauth_accounts: Mapped[list[OAuthAccount]] = relationship( - "OAuthAccount", lazy="joined" - ) - role: Mapped[UserRole] = mapped_column( - Enum(UserRole, native_enum=False, default=UserRole.BASIC) - ) - - """ - Preferences probably should be in a separate table at some point, but for now - putting here for simpicity - """ - - # if specified, controls the assistants that are shown to the user + their order - # if not specified, all assistants are shown - chosen_assistants: Mapped[list[int]] = mapped_column( - postgresql.JSONB(), nullable=True - ) - - oidc_expiry: Mapped[datetime.datetime] = mapped_column( - TIMESTAMPAware(timezone=True), nullable=True - ) - - default_model: Mapped[str] = mapped_column(Text, nullable=True) - # organized in typical structured fashion - # formatted as `displayName__provider__modelName` - - # relationships - credentials: Mapped[list["Credential"]] = relationship( - "Credential", back_populates="user", lazy="joined" - ) - chat_sessions: Mapped[list["ChatSession"]] = relationship( - "ChatSession", back_populates="user" - ) - chat_folders: Mapped[list["ChatFolder"]] = relationship( - "ChatFolder", back_populates="user" - ) - - prompts: Mapped[list["Prompt"]] = relationship("Prompt", back_populates="user") - input_prompts: Mapped[list["InputPrompt"]] = relationship( - "InputPrompt", back_populates="user" - ) - - # Personas owned by this user - personas: Mapped[list["Persona"]] = relationship("Persona", back_populates="user") - # Custom tools created by this user - custom_tools: Mapped[list["Tool"]] = relationship("Tool", back_populates="user") - # Notifications for the UI - notifications: Mapped[list["Notification"]] = relationship( - "Notification", back_populates="user" - ) - - -class InputPrompt(Base): - __tablename__ = "inputprompt" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - prompt: Mapped[str] = mapped_column(String) - content: Mapped[str] = mapped_column(String) - active: Mapped[bool] = mapped_column(Boolean) - user: Mapped[User | None] = relationship("User", back_populates="input_prompts") - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - - -class InputPrompt__User(Base): - __tablename__ = "inputprompt__user" - - input_prompt_id: Mapped[int] = mapped_column( - ForeignKey("inputprompt.id"), primary_key=True - ) - user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("inputprompt.id"), primary_key=True - ) - - -class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base): - pass - - -class ApiKey(Base): - __tablename__ = "api_key" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str | None] = mapped_column(String, nullable=True) - hashed_api_key: Mapped[str] = mapped_column(String, unique=True) - api_key_display: Mapped[str] = mapped_column(String, unique=True) - # the ID of the "user" who represents the access credentials for the API key - user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id"), nullable=False) - # the ID of the user who owns the key - owner_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - # Add this relationship to access the User object via user_id - user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) - - -class Notification(Base): - __tablename__ = "notification" - - id: Mapped[int] = mapped_column(primary_key=True) - notif_type: Mapped[NotificationType] = mapped_column( - Enum(NotificationType, native_enum=False) - ) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - dismissed: Mapped[bool] = mapped_column(Boolean, default=False) - last_shown: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True)) - first_shown: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True)) - - user: Mapped[User] = relationship("User", back_populates="notifications") - - -""" -Association Tables -NOTE: must be at the top since they are referenced by other tables -""" - - -class Persona__DocumentSet(Base): - __tablename__ = "persona__document_set" - - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) - document_set_id: Mapped[int] = mapped_column( - ForeignKey("document_set.id"), primary_key=True - ) - - -class Persona__Prompt(Base): - __tablename__ = "persona__prompt" - - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) - prompt_id: Mapped[int] = mapped_column(ForeignKey("prompt.id"), primary_key=True) - - -class Persona__User(Base): - __tablename__ = "persona__user" - - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) - user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("user.id"), primary_key=True, nullable=True - ) - - -class DocumentSet__User(Base): - __tablename__ = "document_set__user" - - document_set_id: Mapped[int] = mapped_column( - ForeignKey("document_set.id"), primary_key=True - ) - user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("user.id"), primary_key=True, nullable=True - ) - - -class DocumentSet__ConnectorCredentialPair(Base): - __tablename__ = "document_set__connector_credential_pair" - - document_set_id: Mapped[int] = mapped_column( - ForeignKey("document_set.id"), primary_key=True - ) - connector_credential_pair_id: Mapped[int] = mapped_column( - ForeignKey("connector_credential_pair.id"), primary_key=True - ) - # if `True`, then is part of the current state of the document set - # if `False`, then is a part of the prior state of the document set - # rows with `is_current=False` should be deleted when the document - # set is updated and should not exist for a given document set if - # `DocumentSet.is_up_to_date == True` - is_current: Mapped[bool] = mapped_column( - Boolean, - nullable=False, - default=True, - primary_key=True, - ) - - document_set: Mapped["DocumentSet"] = relationship("DocumentSet") - - -class ChatMessage__SearchDoc(Base): - __tablename__ = "chat_message__search_doc" - - chat_message_id: Mapped[int] = mapped_column( - ForeignKey("chat_message.id"), primary_key=True - ) - search_doc_id: Mapped[int] = mapped_column( - ForeignKey("search_doc.id"), primary_key=True - ) - - -class Document__Tag(Base): - __tablename__ = "document__tag" - - document_id: Mapped[str] = mapped_column( - ForeignKey("document.id"), primary_key=True - ) - tag_id: Mapped[int] = mapped_column(ForeignKey("tag.id"), primary_key=True) - - -class Persona__Tool(Base): - __tablename__ = "persona__tool" - - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) - 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 -""" - - -class ConnectorCredentialPair(Base): - """Connectors and Credentials can have a many-to-many relationship - I.e. A Confluence Connector may have multiple admin users who can run it with their own credentials - I.e. An admin user may use the same credential to index multiple Confluence Spaces - """ - - __tablename__ = "connector_credential_pair" - # NOTE: this `id` column has to use `Sequence` instead of `autoincrement=True` - # due to some SQLAlchemy quirks + this not being a primary key column - id: Mapped[int] = mapped_column( - Integer, - Sequence("connector_credential_pair_id_seq"), - unique=True, - nullable=False, - ) - name: Mapped[str] = mapped_column(String, nullable=False) - status: Mapped[ConnectorCredentialPairStatus] = mapped_column( - Enum(ConnectorCredentialPairStatus, native_enum=False), nullable=False - ) - connector_id: Mapped[int] = mapped_column( - ForeignKey("connector.id"), primary_key=True - ) - credential_id: Mapped[int] = mapped_column( - ForeignKey("credential.id"), primary_key=True - ) - # controls whether the documents indexed by this CC pair are visible to all - # or if they are only visible to those with that are given explicit access - # (e.g. via owning the credential or being a part of a group that is given access) - is_public: Mapped[bool] = mapped_column( - Boolean, - default=True, - nullable=False, - ) - # Time finished, not used for calculating backend jobs which uses time started (created) - last_successful_index_time: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), default=None - ) - total_docs_indexed: Mapped[int] = mapped_column(Integer, default=0) - - connector: Mapped["Connector"] = relationship( - "Connector", back_populates="credentials" - ) - credential: Mapped["Credential"] = relationship( - "Credential", back_populates="connectors" - ) - document_sets: Mapped[list["DocumentSet"]] = relationship( - "DocumentSet", - secondary=DocumentSet__ConnectorCredentialPair.__table__, - primaryjoin=( - (DocumentSet__ConnectorCredentialPair.connector_credential_pair_id == id) - & (DocumentSet__ConnectorCredentialPair.is_current.is_(True)) - ), - back_populates="connector_credential_pairs", - overlaps="document_set", - ) - index_attempts: Mapped[list["IndexAttempt"]] = relationship( - "IndexAttempt", back_populates="connector_credential_pair" - ) - - -class Document(Base): - __tablename__ = "document" - - # this should correspond to the ID of the document - # (as is passed around in Danswer) - id: Mapped[str] = mapped_column(String, primary_key=True) - from_ingestion_api: Mapped[bool] = mapped_column( - Boolean, default=False, nullable=True - ) - # 0 for neutral, positive for mostly endorse, negative for mostly reject - boost: Mapped[int] = mapped_column(Integer, default=DEFAULT_BOOST) - hidden: Mapped[bool] = mapped_column(Boolean, default=False) - semantic_id: Mapped[str] = mapped_column(String) - # First Section's link - link: Mapped[str | None] = mapped_column(String, nullable=True) - # The updated time is also used as a measure of the last successful state of the doc - # pulled from the source (to help skip reindexing already updated docs in case of - # connector retries) - doc_updated_at: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - # The following are not attached to User because the account/email may not be known - # within Danswer - # Something like the document creator - primary_owners: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - secondary_owners: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - # TODO if more sensitive data is added here for display, make sure to add user/group permission - - retrieval_feedbacks: Mapped[list["DocumentRetrievalFeedback"]] = relationship( - "DocumentRetrievalFeedback", back_populates="document" - ) - tags = relationship( - "Tag", - secondary="document__tag", - back_populates="documents", - ) - - -class Tag(Base): - __tablename__ = "tag" - - id: Mapped[int] = mapped_column(primary_key=True) - tag_key: Mapped[str] = mapped_column(String) - tag_value: Mapped[str] = mapped_column(String) - source: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - - documents = relationship( - "Document", - secondary="document__tag", - back_populates="tags", - ) - - __table_args__ = ( - UniqueConstraint( - "tag_key", "tag_value", "source", name="_tag_key_value_source_uc" - ), - ) - - -class Connector(Base): - __tablename__ = "connector" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String) - source: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - input_type = mapped_column(Enum(InputType, native_enum=False)) - connector_specific_config: Mapped[dict[str, Any]] = mapped_column( - postgresql.JSONB() - ) - indexing_start: Mapped[datetime.datetime | None] = mapped_column( - DateTime, nullable=True - ) - refresh_freq: Mapped[int | None] = mapped_column(Integer, nullable=True) - prune_freq: Mapped[int | None] = mapped_column(Integer, nullable=True) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - time_updated: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - credentials: Mapped[list["ConnectorCredentialPair"]] = relationship( - "ConnectorCredentialPair", - back_populates="connector", - cascade="all, delete-orphan", - ) - documents_by_connector: Mapped[ - list["DocumentByConnectorCredentialPair"] - ] = relationship("DocumentByConnectorCredentialPair", back_populates="connector") - - -class Credential(Base): - __tablename__ = "credential" - - name: Mapped[str] = mapped_column(String, nullable=True) - - source: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - - id: Mapped[int] = mapped_column(primary_key=True) - credential_json: Mapped[dict[str, Any]] = mapped_column(EncryptedJson()) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - # if `true`, then all Admins will have access to the credential - admin_public: Mapped[bool] = mapped_column(Boolean, default=True) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - time_updated: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - curator_public: Mapped[bool] = mapped_column(Boolean, default=False) - - connectors: Mapped[list["ConnectorCredentialPair"]] = relationship( - "ConnectorCredentialPair", - back_populates="credential", - cascade="all, delete-orphan", - ) - documents_by_credential: Mapped[ - list["DocumentByConnectorCredentialPair"] - ] = relationship("DocumentByConnectorCredentialPair", back_populates="credential") - - user: Mapped[User | None] = relationship("User", back_populates="credentials") - - -class SearchSettings(Base): - __tablename__ = "search_settings" - - id: Mapped[int] = mapped_column(primary_key=True) - model_name: Mapped[str] = mapped_column(String) - model_dim: Mapped[int] = mapped_column(Integer) - normalize: Mapped[bool] = mapped_column(Boolean) - query_prefix: Mapped[str | None] = mapped_column(String, nullable=True) - passage_prefix: Mapped[str | None] = mapped_column(String, nullable=True) - status: Mapped[IndexModelStatus] = mapped_column( - Enum(IndexModelStatus, native_enum=False) - ) - index_name: Mapped[str] = mapped_column(String) - provider_type: Mapped[EmbeddingProvider | None] = mapped_column( - ForeignKey("embedding_provider.provider_type"), nullable=True - ) - - # Mini and Large Chunks (large chunk also checks for model max context) - multipass_indexing: Mapped[bool] = mapped_column(Boolean, default=True) - - multilingual_expansion: Mapped[list[str]] = mapped_column( - postgresql.ARRAY(String), default=[] - ) - - # Reranking settings - disable_rerank_for_streaming: Mapped[bool] = mapped_column(Boolean, default=False) - rerank_model_name: Mapped[str | None] = mapped_column(String, nullable=True) - rerank_provider_type: Mapped[RerankerProvider | None] = mapped_column( - Enum(RerankerProvider, native_enum=False), nullable=True - ) - rerank_api_key: Mapped[str | None] = mapped_column(String, nullable=True) - num_rerank: Mapped[int] = mapped_column(Integer, default=NUM_POSTPROCESSED_RESULTS) - - cloud_provider: Mapped["CloudEmbeddingProvider"] = relationship( - "CloudEmbeddingProvider", - back_populates="search_settings", - foreign_keys=[provider_type], - ) - - index_attempts: Mapped[list["IndexAttempt"]] = relationship( - "IndexAttempt", back_populates="search_settings" - ) - - __table_args__ = ( - Index( - "ix_embedding_model_present_unique", - "status", - unique=True, - postgresql_where=(status == IndexModelStatus.PRESENT), - ), - Index( - "ix_embedding_model_future_unique", - "status", - unique=True, - postgresql_where=(status == IndexModelStatus.FUTURE), - ), - ) - - def __repr__(self) -> str: - return f"" - - @property - def api_key(self) -> str | None: - return self.cloud_provider.api_key if self.cloud_provider is not None else None - - -class IndexAttempt(Base): - """ - Represents an attempt to index a group of 1 or more documents from a - source. For example, a single pull from Google Drive, a single event from - slack event API, or a single website crawl. - """ - - __tablename__ = "index_attempt" - - id: Mapped[int] = mapped_column(primary_key=True) - - connector_credential_pair_id: Mapped[int] = mapped_column( - ForeignKey("connector_credential_pair.id"), - nullable=False, - ) - - # Some index attempts that run from beginning will still have this as False - # This is only for attempts that are explicitly marked as from the start via - # the run once API - from_beginning: Mapped[bool] = mapped_column(Boolean) - status: Mapped[IndexingStatus] = mapped_column( - Enum(IndexingStatus, native_enum=False) - ) - # The two below may be slightly out of sync if user switches Embedding Model - new_docs_indexed: Mapped[int | None] = mapped_column(Integer, default=0) - total_docs_indexed: Mapped[int | None] = mapped_column(Integer, default=0) - docs_removed_from_index: Mapped[int | None] = mapped_column(Integer, default=0) - # only filled if status = "failed" - error_msg: Mapped[str | None] = mapped_column(Text, default=None) - # only filled if status = "failed" AND an unhandled exception caused the failure - full_exception_trace: Mapped[str | None] = mapped_column(Text, default=None) - # Nullable because in the past, we didn't allow swapping out embedding models live - search_settings_id: Mapped[int] = mapped_column( - ForeignKey("search_settings.id"), - nullable=False, - ) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - ) - # when the actual indexing run began - # NOTE: will use the api_server clock rather than DB server clock - time_started: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), default=None - ) - time_updated: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) - - connector_credential_pair: Mapped[ConnectorCredentialPair] = relationship( - "ConnectorCredentialPair", back_populates="index_attempts" - ) - - search_settings: Mapped[SearchSettings] = relationship( - "SearchSettings", back_populates="index_attempts" - ) - - error_rows = relationship("IndexAttemptError", back_populates="index_attempt") - - __table_args__ = ( - Index( - "ix_index_attempt_latest_for_connector_credential_pair", - "connector_credential_pair_id", - "time_created", - ), - ) - - def __repr__(self) -> str: - return ( - f"" - f"time_created={self.time_created!r}, " - f"time_updated={self.time_updated!r}, " - ) - - def is_finished(self) -> bool: - return self.status.is_terminal() - - -class IndexAttemptError(Base): - """ - Represents an error that was encountered during an IndexAttempt. - """ - - __tablename__ = "index_attempt_errors" - - id: Mapped[int] = mapped_column(primary_key=True) - - index_attempt_id: Mapped[int] = mapped_column( - ForeignKey("index_attempt.id"), - nullable=True, - ) - - # The index of the batch where the error occurred (if looping thru batches) - # Just informational. - batch: Mapped[int | None] = mapped_column(Integer, default=None) - doc_summaries: Mapped[list[Any]] = mapped_column(postgresql.JSONB()) - error_msg: Mapped[str | None] = mapped_column(Text, default=None) - traceback: Mapped[str | None] = mapped_column(Text, default=None) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - ) - - # This is the reverse side of the relationship - index_attempt = relationship("IndexAttempt", back_populates="error_rows") - - __table_args__ = ( - Index( - "index_attempt_id", - "time_created", - ), - ) - - def __repr__(self) -> str: - return ( - f"" - f"time_created={self.time_created!r}, " - ) - - -class DocumentByConnectorCredentialPair(Base): - """Represents an indexing of a document by a specific connector / credential pair""" - - __tablename__ = "document_by_connector_credential_pair" - - id: Mapped[str] = mapped_column(ForeignKey("document.id"), primary_key=True) - # TODO: transition this to use the ConnectorCredentialPair id directly - connector_id: Mapped[int] = mapped_column( - ForeignKey("connector.id"), primary_key=True - ) - credential_id: Mapped[int] = mapped_column( - ForeignKey("credential.id"), primary_key=True - ) - - connector: Mapped[Connector] = relationship( - "Connector", back_populates="documents_by_connector" - ) - credential: Mapped[Credential] = relationship( - "Credential", back_populates="documents_by_credential" - ) - - -""" -Messages Tables -""" - - -class SearchDoc(Base): - """Different from Document table. This one stores the state of a document from a retrieval. - This allows chat sessions to be replayed with the searched docs - - Notably, this does not include the contents of the Document/Chunk, during inference if a stored - SearchDoc is selected, an inference must be remade to retrieve the contents - """ - - __tablename__ = "search_doc" - - id: Mapped[int] = mapped_column(primary_key=True) - document_id: Mapped[str] = mapped_column(String) - chunk_ind: Mapped[int] = mapped_column(Integer) - semantic_id: Mapped[str] = mapped_column(String) - link: Mapped[str | None] = mapped_column(String, nullable=True) - blurb: Mapped[str] = mapped_column(String) - boost: Mapped[int] = mapped_column(Integer) - source_type: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - hidden: Mapped[bool] = mapped_column(Boolean) - doc_metadata: Mapped[dict[str, str | list[str]]] = mapped_column(postgresql.JSONB()) - score: Mapped[float] = mapped_column(Float) - match_highlights: Mapped[list[str]] = mapped_column(postgresql.ARRAY(String)) - # This is for the document, not this row in the table - updated_at: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) - primary_owners: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - secondary_owners: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - is_internet: Mapped[bool] = mapped_column(Boolean, default=False, nullable=True) - - is_relevant: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - relevance_explanation: Mapped[str | None] = mapped_column(String, nullable=True) - - chat_messages = relationship( - "ChatMessage", - secondary="chat_message__search_doc", - back_populates="search_docs", - ) - - -class ToolCall(Base): - """Represents a single tool call""" - - __tablename__ = "tool_call" - - id: Mapped[int] = mapped_column(primary_key=True) - # not a FK because we want to be able to delete the tool without deleting - # this entry - tool_id: Mapped[int] = mapped_column(Integer()) - tool_name: Mapped[str] = mapped_column(String()) - tool_arguments: Mapped[dict[str, JSON_ro]] = mapped_column(postgresql.JSONB()) - tool_result: Mapped[JSON_ro] = mapped_column(postgresql.JSONB()) - - message_id: Mapped[int] = mapped_column(ForeignKey("chat_message.id")) - - message: Mapped["ChatMessage"] = relationship( - "ChatMessage", back_populates="tool_calls" - ) - - -class ChatSession(Base): - __tablename__ = "chat_session" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id")) - description: Mapped[str] = mapped_column(Text) - # One-shot direct answering, currently the two types of chats are not mixed - one_shot: Mapped[bool] = mapped_column(Boolean, default=False) - danswerbot_flow: Mapped[bool] = mapped_column(Boolean, default=False) - # Only ever set to True if system is set to not hard-delete chats - deleted: Mapped[bool] = mapped_column(Boolean, default=False) - # controls whether or not this conversation is viewable by others - shared_status: Mapped[ChatSessionSharedStatus] = mapped_column( - Enum(ChatSessionSharedStatus, native_enum=False), - default=ChatSessionSharedStatus.PRIVATE, - ) - folder_id: Mapped[int | None] = mapped_column( - ForeignKey("chat_folder.id"), nullable=True - ) - - 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. - # NOTE: currently only used by the chat seeding flow, will be used in the - # future once we allow users to override default values via the Chat UI - # itself - llm_override: Mapped[LLMOverride | None] = mapped_column( - PydanticType(LLMOverride), nullable=True - ) - prompt_override: Mapped[PromptOverride | None] = mapped_column( - PydanticType(PromptOverride), nullable=True - ) - - time_updated: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - user: Mapped[User] = relationship("User", back_populates="chat_sessions") - folder: Mapped["ChatFolder"] = relationship( - "ChatFolder", back_populates="chat_sessions" - ) - messages: Mapped[list["ChatMessage"]] = relationship( - "ChatMessage", back_populates="chat_session" - ) - persona: Mapped["Persona"] = relationship("Persona") - - -class ChatMessage(Base): - """Note, the first message in a chain has no contents, it's a workaround to allow edits - on the first message of a session, an empty root node basically - - Since every user message is followed by a LLM response, chat messages generally come in pairs. - Keeping them as separate messages however for future Agentification extensions - Fields will be largely duplicated in the pair. - """ - - __tablename__ = "chat_message" - - id: Mapped[int] = mapped_column(primary_key=True) - chat_session_id: Mapped[int] = mapped_column(ForeignKey("chat_session.id")) - - alternate_assistant_id = mapped_column( - Integer, ForeignKey("persona.id"), nullable=True - ) - - overridden_model: Mapped[str | None] = mapped_column(String, nullable=True) - parent_message: Mapped[int | None] = mapped_column(Integer, nullable=True) - latest_child_message: Mapped[int | None] = mapped_column(Integer, nullable=True) - message: Mapped[str] = mapped_column(Text) - rephrased_query: Mapped[str] = mapped_column(Text, nullable=True) - # If None, then there is no answer generation, it's the special case of only - # showing the user the retrieved docs - prompt_id: Mapped[int | None] = mapped_column(ForeignKey("prompt.id")) - # If prompt is None, then token_count is 0 as this message won't be passed into - # the LLM's context (not included in the history of messages) - token_count: Mapped[int] = mapped_column(Integer) - message_type: Mapped[MessageType] = mapped_column( - Enum(MessageType, native_enum=False) - ) - # Maps the citation numbers to a SearchDoc id - citations: Mapped[dict[int, int]] = mapped_column(postgresql.JSONB(), nullable=True) - # files associated with this message (e.g. images uploaded by the user that the - # user is asking a question of) - files: Mapped[list[FileDescriptor] | None] = mapped_column( - postgresql.JSONB(), nullable=True - ) - # Only applies for LLM - error: Mapped[str | None] = mapped_column(Text, nullable=True) - time_sent: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - chat_session: Mapped[ChatSession] = relationship("ChatSession") - prompt: Mapped[Optional["Prompt"]] = relationship("Prompt") - - chat_message_feedbacks: Mapped[list["ChatMessageFeedback"]] = relationship( - "ChatMessageFeedback", - back_populates="chat_message", - ) - - document_feedbacks: Mapped[list["DocumentRetrievalFeedback"]] = relationship( - "DocumentRetrievalFeedback", - back_populates="chat_message", - ) - search_docs: Mapped[list["SearchDoc"]] = relationship( - "SearchDoc", - secondary="chat_message__search_doc", - back_populates="chat_messages", - ) - # NOTE: Should always be attached to the `assistant` message. - # represents the tool calls used to generate this message - tool_calls: Mapped[list["ToolCall"]] = relationship( - "ToolCall", - back_populates="message", - ) - standard_answers: Mapped[list["StandardAnswer"]] = relationship( - "StandardAnswer", - secondary=ChatMessage__StandardAnswer.__table__, - back_populates="chat_messages", - ) - - -class ChatFolder(Base): - """For organizing chat sessions""" - - __tablename__ = "chat_folder" - - id: Mapped[int] = mapped_column(primary_key=True) - # Only null if auth is off - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - name: Mapped[str | None] = mapped_column(String, nullable=True) - display_priority: Mapped[int] = mapped_column(Integer, nullable=True, default=0) - - user: Mapped[User] = relationship("User", back_populates="chat_folders") - chat_sessions: Mapped[list["ChatSession"]] = relationship( - "ChatSession", back_populates="folder" - ) - - def __lt__(self, other: Any) -> bool: - if not isinstance(other, ChatFolder): - return NotImplemented - if self.display_priority == other.display_priority: - # Bigger ID (created later) show earlier - return self.id > other.id - return self.display_priority < other.display_priority - - -""" -Feedback, Logging, Metrics Tables -""" - - -class DocumentRetrievalFeedback(Base): - __tablename__ = "document_retrieval_feedback" - - id: Mapped[int] = mapped_column(primary_key=True) - chat_message_id: Mapped[int | None] = mapped_column( - ForeignKey("chat_message.id", ondelete="SET NULL"), nullable=True - ) - document_id: Mapped[str] = mapped_column(ForeignKey("document.id")) - # How high up this document is in the results, 1 for first - document_rank: Mapped[int] = mapped_column(Integer) - clicked: Mapped[bool] = mapped_column(Boolean, default=False) - feedback: Mapped[SearchFeedbackType | None] = mapped_column( - Enum(SearchFeedbackType, native_enum=False), nullable=True - ) - - chat_message: Mapped[ChatMessage] = relationship( - "ChatMessage", - back_populates="document_feedbacks", - foreign_keys=[chat_message_id], - ) - document: Mapped[Document] = relationship( - "Document", back_populates="retrieval_feedbacks" - ) - - -class ChatMessageFeedback(Base): - __tablename__ = "chat_feedback" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - chat_message_id: Mapped[int | None] = mapped_column( - ForeignKey("chat_message.id", ondelete="SET NULL"), nullable=True - ) - is_positive: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - required_followup: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - feedback_text: Mapped[str | None] = mapped_column(Text, nullable=True) - predefined_feedback: Mapped[str | None] = mapped_column(String, nullable=True) - - chat_message: Mapped[ChatMessage] = relationship( - "ChatMessage", - back_populates="chat_message_feedbacks", - foreign_keys=[chat_message_id], - ) - - -class LLMProvider(Base): - __tablename__ = "llm_provider" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String, unique=True) - provider: Mapped[str] = mapped_column(String) - api_key: Mapped[str | None] = mapped_column(EncryptedString(), nullable=True) - api_base: Mapped[str | None] = mapped_column(String, nullable=True) - api_version: Mapped[str | None] = mapped_column(String, nullable=True) - # custom configs that should be passed to the LLM provider at inference time - # (e.g. `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, etc. for bedrock) - custom_config: Mapped[dict[str, str] | None] = mapped_column( - postgresql.JSONB(), nullable=True - ) - default_model_name: Mapped[str] = mapped_column(String) - fast_default_model_name: Mapped[str | None] = mapped_column(String, nullable=True) - - # Models to actually disp;aly to users - # If nulled out, we assume in the application logic we should present all - display_model_names: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - # The LLMs that are available for this provider. Only required if not a default provider. - # If a default provider, then the LLM options are pulled from the `options.py` file. - # If needed, can be pulled out as a separate table in the future. - model_names: Mapped[list[str] | None] = mapped_column( - postgresql.ARRAY(String), nullable=True - ) - - # should only be set for a single provider - is_default_provider: Mapped[bool | None] = mapped_column(Boolean, unique=True) - # EE only - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - groups: Mapped[list["UserGroup"]] = relationship( - "UserGroup", - secondary="llm_provider__user_group", - viewonly=True, - ) - - -class CloudEmbeddingProvider(Base): - __tablename__ = "embedding_provider" - - provider_type: Mapped[EmbeddingProvider] = mapped_column( - Enum(EmbeddingProvider), primary_key=True - ) - api_key: Mapped[str | None] = mapped_column(EncryptedString()) - search_settings: Mapped[list["SearchSettings"]] = relationship( - "SearchSettings", - back_populates="cloud_provider", - foreign_keys="SearchSettings.provider_type", - ) - - def __repr__(self) -> str: - return f"" - - -class DocumentSet(Base): - __tablename__ = "document_set" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String, unique=True) - description: Mapped[str] = mapped_column(String) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - # Whether changes to the document set have been propagated - is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - # If `False`, then the document set is not visible to users who are not explicitly - # given access to it either via the `users` or `groups` relationships - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - - connector_credential_pairs: Mapped[list[ConnectorCredentialPair]] = relationship( - "ConnectorCredentialPair", - secondary=DocumentSet__ConnectorCredentialPair.__table__, - primaryjoin=( - (DocumentSet__ConnectorCredentialPair.document_set_id == id) - & (DocumentSet__ConnectorCredentialPair.is_current.is_(True)) - ), - secondaryjoin=( - DocumentSet__ConnectorCredentialPair.connector_credential_pair_id - == ConnectorCredentialPair.id - ), - back_populates="document_sets", - overlaps="document_set", - ) - personas: Mapped[list["Persona"]] = relationship( - "Persona", - secondary=Persona__DocumentSet.__table__, - back_populates="document_sets", - ) - # Other users with access - users: Mapped[list[User]] = relationship( - "User", - secondary=DocumentSet__User.__table__, - viewonly=True, - ) - # EE only - groups: Mapped[list["UserGroup"]] = relationship( - "UserGroup", - secondary="document_set__user_group", - viewonly=True, - ) - - -class Prompt(Base): - __tablename__ = "prompt" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - name: Mapped[str] = mapped_column(String) - description: Mapped[str] = mapped_column(String) - system_prompt: Mapped[str] = mapped_column(Text) - task_prompt: Mapped[str] = mapped_column(Text) - include_citations: Mapped[bool] = mapped_column(Boolean, default=True) - datetime_aware: Mapped[bool] = mapped_column(Boolean, default=True) - # Default prompts are configured via backend during deployment - # Treated specially (cannot be user edited etc.) - default_prompt: Mapped[bool] = mapped_column(Boolean, default=False) - deleted: Mapped[bool] = mapped_column(Boolean, default=False) - - user: Mapped[User] = relationship("User", back_populates="prompts") - personas: Mapped[list["Persona"]] = relationship( - "Persona", - secondary=Persona__Prompt.__table__, - back_populates="prompts", - ) - - -class Tool(Base): - __tablename__ = "tool" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String, nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=True) - # ID of the tool in the codebase, only applies for in-code tools. - # tools defined via the UI will have this as None - in_code_tool_id: Mapped[str | None] = mapped_column(String, nullable=True) - display_name: Mapped[str] = mapped_column(String, nullable=True) - - # OpenAPI scheme for the tool. Only applies to tools defined via the UI. - openapi_schema: Mapped[dict[str, Any] | None] = mapped_column( - postgresql.JSONB(), nullable=True - ) - - # user who created / owns the tool. Will be None for built-in tools. - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - - user: Mapped[User | None] = relationship("User", back_populates="custom_tools") - # Relationship to Persona through the association table - personas: Mapped[list["Persona"]] = relationship( - "Persona", - secondary=Persona__Tool.__table__, - back_populates="tools", - ) - - -class StarterMessage(TypedDict): - """NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column - in Postgres""" - - name: str - description: str - message: str - - -class Persona(Base): - __tablename__ = "persona" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - name: Mapped[str] = mapped_column(String) - description: Mapped[str] = mapped_column(String) - # Number of chunks to pass to the LLM for generation. - num_chunks: Mapped[float | None] = mapped_column(Float, nullable=True) - chunks_above: Mapped[int] = mapped_column(Integer) - chunks_below: Mapped[int] = mapped_column(Integer) - # Pass every chunk through LLM for evaluation, fairly expensive - # Can be turned off globally by admin, in which case, this setting is ignored - llm_relevance_filter: Mapped[bool] = mapped_column(Boolean) - # Enables using LLM to extract time and source type filters - # Can also be admin disabled globally - llm_filter_extraction: Mapped[bool] = mapped_column(Boolean) - recency_bias: Mapped[RecencyBiasSetting] = mapped_column( - Enum(RecencyBiasSetting, native_enum=False) - ) - # Allows the Persona to specify a different LLM version than is controlled - # globablly via env variables. For flexibility, validity is not currently enforced - # NOTE: only is applied on the actual response generation - is not used for things like - # auto-detected time filters, relevance filters, etc. - llm_model_provider_override: Mapped[str | None] = mapped_column( - String, nullable=True - ) - llm_model_version_override: Mapped[str | None] = mapped_column( - String, nullable=True - ) - starter_messages: Mapped[list[StarterMessage] | None] = mapped_column( - postgresql.JSONB(), nullable=True - ) - # Default personas are configured via backend during deployment - # Treated specially (cannot be user edited etc.) - default_persona: Mapped[bool] = mapped_column(Boolean, default=False) - # controls whether the persona is available to be selected by users - is_visible: Mapped[bool] = mapped_column(Boolean, default=True) - # controls the ordering of personas in the UI - # higher priority personas are displayed first, ties are resolved by the ID, - # where lower value IDs (e.g. created earlier) are displayed first - display_priority: Mapped[int | None] = mapped_column( - Integer, nullable=True, default=None - ) - deleted: Mapped[bool] = mapped_column(Boolean, default=False) - - uploaded_image_id: Mapped[str | None] = mapped_column(String, nullable=True) - icon_color: Mapped[str | None] = mapped_column(String, nullable=True) - icon_shape: Mapped[int | None] = mapped_column(Integer, nullable=True) - - # These are only defaults, users can select from all if desired - prompts: Mapped[list[Prompt]] = relationship( - "Prompt", - secondary=Persona__Prompt.__table__, - back_populates="personas", - ) - # These are only defaults, users can select from all if desired - document_sets: Mapped[list[DocumentSet]] = relationship( - "DocumentSet", - secondary=Persona__DocumentSet.__table__, - back_populates="personas", - ) - tools: Mapped[list[Tool]] = relationship( - "Tool", - secondary=Persona__Tool.__table__, - back_populates="personas", - ) - # Owner - user: Mapped[User | None] = relationship("User", back_populates="personas") - # Other users with access - users: Mapped[list[User]] = relationship( - "User", - secondary=Persona__User.__table__, - viewonly=True, - ) - # EE only - is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - groups: Mapped[list["UserGroup"]] = relationship( - "UserGroup", - secondary="persona__user_group", - viewonly=True, - ) - - # Default personas loaded via yaml cannot have the same name - __table_args__ = ( - Index( - "_default_persona_name_idx", - "name", - unique=True, - postgresql_where=(default_persona == True), # noqa: E712 - ), - ) - - -AllowedAnswerFilters = ( - Literal["well_answered_postfilter"] | Literal["questionmark_prefilter"] -) - - -class ChannelConfig(TypedDict): - """NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column - in Postgres""" - - channel_names: list[str] - respond_tag_only: NotRequired[bool] # defaults to False - respond_to_bots: NotRequired[bool] # defaults to False - respond_member_group_list: NotRequired[list[str]] - answer_filters: NotRequired[list[AllowedAnswerFilters]] - # If None then no follow up - # If empty list, follow up with no tags - 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" - - -class SlackBotConfig(Base): - __tablename__ = "slack_bot_config" - - id: Mapped[int] = mapped_column(primary_key=True) - persona_id: Mapped[int | None] = mapped_column( - ForeignKey("persona.id"), nullable=True - ) - # JSON for flexibility. Contains things like: channel name, team members, etc. - channel_config: Mapped[ChannelConfig] = mapped_column( - postgresql.JSONB(), nullable=False - ) - response_type: Mapped[SlackBotResponseType] = mapped_column( - Enum(SlackBotResponseType, native_enum=False), nullable=False - ) - - enable_auto_filters: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False - ) - - 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): - # Currently refers to Celery Tasks - __tablename__ = "task_queue_jobs" - - id: Mapped[int] = mapped_column(primary_key=True) - # Celery task id - task_id: Mapped[str] = mapped_column(String) - # For any job type, this would be the same - task_name: Mapped[str] = mapped_column(String) - # Note that if the task dies, this won't necessarily be marked FAILED correctly - status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus, native_enum=False)) - start_time: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True) - ) - register_time: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - -class KVStore(Base): - __tablename__ = "key_value_store" - - key: Mapped[str] = mapped_column(String, primary_key=True) - value: Mapped[JSON_ro] = mapped_column(postgresql.JSONB(), nullable=True) - encrypted_value: Mapped[JSON_ro] = mapped_column(EncryptedJson(), nullable=True) - - -class PGFileStore(Base): - __tablename__ = "file_store" - - file_name: Mapped[str] = mapped_column(String, primary_key=True) - display_name: Mapped[str] = mapped_column(String, nullable=True) - file_origin: Mapped[FileOrigin] = mapped_column(Enum(FileOrigin, native_enum=False)) - file_type: Mapped[str] = mapped_column(String, default="text/plain") - file_metadata: Mapped[JSON_ro] = mapped_column(postgresql.JSONB(), nullable=True) - lobj_oid: Mapped[int] = mapped_column(Integer, nullable=False) - - -""" -************************************************************************ -Enterprise Edition Models -************************************************************************ - -These models are only used in Enterprise Edition only features in Danswer. -They are kept here to simplify the codebase and avoid having different assumptions -on the shape of data being passed around between the MIT and EE versions of Danswer. - -In the MIT version of Danswer, assume these tables are always empty. -""" - - -class SamlAccount(Base): - __tablename__ = "saml" - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True) - encrypted_cookie: Mapped[str] = mapped_column(Text, unique=True) - expires_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True)) - updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - user: Mapped[User] = relationship("User") - - -class User__UserGroup(Base): - __tablename__ = "user__user_group" - - is_curator: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("user.id"), primary_key=True, nullable=True - ) - - -class UserGroup__ConnectorCredentialPair(Base): - __tablename__ = "user_group__connector_credential_pair" - - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - cc_pair_id: Mapped[int] = mapped_column( - ForeignKey("connector_credential_pair.id"), primary_key=True - ) - # if `True`, then is part of the current state of the UserGroup - # if `False`, then is a part of the prior state of the UserGroup - # rows with `is_current=False` should be deleted when the UserGroup - # is updated and should not exist for a given UserGroup if - # `UserGroup.is_up_to_date == True` - is_current: Mapped[bool] = mapped_column( - Boolean, - default=True, - primary_key=True, - ) - - cc_pair: Mapped[ConnectorCredentialPair] = relationship( - "ConnectorCredentialPair", - ) - - -class Persona__UserGroup(Base): - __tablename__ = "persona__user_group" - - persona_id: Mapped[int] = mapped_column(ForeignKey("persona.id"), primary_key=True) - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - - -class LLMProvider__UserGroup(Base): - __tablename__ = "llm_provider__user_group" - - llm_provider_id: Mapped[int] = mapped_column( - ForeignKey("llm_provider.id"), primary_key=True - ) - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - - -class DocumentSet__UserGroup(Base): - __tablename__ = "document_set__user_group" - - document_set_id: Mapped[int] = mapped_column( - ForeignKey("document_set.id"), primary_key=True - ) - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - - -class Credential__UserGroup(Base): - __tablename__ = "credential__user_group" - - credential_id: Mapped[int] = mapped_column( - ForeignKey("credential.id"), primary_key=True - ) - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - - -class UserGroup(Base): - __tablename__ = "user_group" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String, unique=True) - # whether or not changes to the UserGroup have been propagated to Vespa - is_up_to_date: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - # tell the sync job to clean up the group - is_up_for_deletion: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False - ) - - users: Mapped[list[User]] = relationship( - "User", - secondary=User__UserGroup.__table__, - ) - user_group_relationships: Mapped[list[User__UserGroup]] = relationship( - "User__UserGroup", - viewonly=True, - ) - cc_pairs: Mapped[list[ConnectorCredentialPair]] = relationship( - "ConnectorCredentialPair", - secondary=UserGroup__ConnectorCredentialPair.__table__, - viewonly=True, - ) - cc_pair_relationships: Mapped[ - list[UserGroup__ConnectorCredentialPair] - ] = relationship( - "UserGroup__ConnectorCredentialPair", - viewonly=True, - ) - personas: Mapped[list[Persona]] = relationship( - "Persona", - secondary=Persona__UserGroup.__table__, - viewonly=True, - ) - document_sets: Mapped[list[DocumentSet]] = relationship( - "DocumentSet", - secondary=DocumentSet__UserGroup.__table__, - viewonly=True, - ) - credentials: Mapped[list[Credential]] = relationship( - "Credential", - secondary=Credential__UserGroup.__table__, - ) - - -"""Tables related to Token Rate Limiting -NOTE: `TokenRateLimit` is partially an MIT feature (global rate limit) -""" - - -class TokenRateLimit(Base): - __tablename__ = "token_rate_limit" - - id: Mapped[int] = mapped_column(primary_key=True) - enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - token_budget: Mapped[int] = mapped_column(Integer, nullable=False) - period_hours: Mapped[int] = mapped_column(Integer, nullable=False) - scope: Mapped[TokenRateLimitScope] = mapped_column( - Enum(TokenRateLimitScope, native_enum=False) - ) - created_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - -class TokenRateLimit__UserGroup(Base): - __tablename__ = "token_rate_limit__user_group" - - rate_limit_id: Mapped[int] = mapped_column( - ForeignKey("token_rate_limit.id"), primary_key=True - ) - user_group_id: Mapped[int] = mapped_column( - ForeignKey("user_group.id"), primary_key=True - ) - - -"""Tables related to Permission Sync""" - - -class PermissionSyncStatus(str, PyEnum): - IN_PROGRESS = "in_progress" - SUCCESS = "success" - FAILED = "failed" - - -class PermissionSyncJobType(str, PyEnum): - USER_LEVEL = "user_level" - GROUP_LEVEL = "group_level" - - -class PermissionSyncRun(Base): - """Represents one run of a permission sync job. For some given cc_pair, it is either sync-ing - the users or it is sync-ing the groups""" - - __tablename__ = "permission_sync_run" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - # Not strictly needed but makes it easy to use without fetching from cc_pair - source_type: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - # Currently all sync jobs are handled as a group permission sync or a user permission sync - update_type: Mapped[PermissionSyncJobType] = mapped_column( - Enum(PermissionSyncJobType) - ) - cc_pair_id: Mapped[int | None] = mapped_column( - ForeignKey("connector_credential_pair.id"), nullable=True - ) - status: Mapped[PermissionSyncStatus] = mapped_column(Enum(PermissionSyncStatus)) - error_msg: Mapped[str | None] = mapped_column(Text, default=None) - updated_at: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) - - cc_pair: Mapped[ConnectorCredentialPair] = relationship("ConnectorCredentialPair") - - -class ExternalPermission(Base): - """Maps user info both internal and external to the name of the external group - This maps the user to all of their external groups so that the external group name can be - attached to the ACL list matching during query time. User level permissions can be handled by - directly adding the Danswer user to the doc ACL list""" - - __tablename__ = "external_permission" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - # Email is needed because we want to keep track of users not in Danswer to simplify process - # when the user joins - user_email: Mapped[str] = mapped_column(String) - source_type: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - external_permission_group: Mapped[str] = mapped_column(String) - user = relationship("User") - - -class EmailToExternalUserCache(Base): - """A way to map users IDs in the external tool to a user in Danswer or at least an email for - when the user joins. Used as a cache for when fetching external groups which have their own - user ids, this can easily be mapped back to users already known in Danswer without needing - to call external APIs to get the user emails. - - This way when groups are updated in the external tool and we need to update the mapping of - internal users to the groups, we can sync the internal users to the external groups they are - part of using this. - - Ie. User Chris is part of groups alpha, beta, and we can update this if Chris is no longer - part of alpha in some external tool. - """ - - __tablename__ = "email_to_external_user_cache" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - external_user_id: Mapped[str] = mapped_column(String) - user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True) - # Email is needed because we want to keep track of users not in Danswer to simplify process - # when the user joins - user_email: Mapped[str] = mapped_column(String) - source_type: Mapped[DocumentSource] = mapped_column( - Enum(DocumentSource, native_enum=False) - ) - - user = relationship("User") - - -class UsageReport(Base): - """This stores metadata about usage reports generated by admin including user who generated - them as well las the period they cover. The actual zip file of the report is stored as a lo - using the PGFileStore - """ - - __tablename__ = "usage_reports" - - id: Mapped[int] = mapped_column(primary_key=True) - report_name: Mapped[str] = mapped_column(ForeignKey("file_store.file_name")) - - # if None, report was auto-generated - requestor_user_id: Mapped[UUID | None] = mapped_column( - ForeignKey("user.id"), nullable=True - ) - time_created: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - period_from: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True) - ) - period_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) - - requestor = relationship("User") - file = relationship("PGFileStore") diff --git a/backend/danswer/db/notification.py b/backend/danswer/db/notification.py deleted file mode 100644 index 61586208c69..00000000000 --- a/backend/danswer/db/notification.py +++ /dev/null @@ -1,76 +0,0 @@ -from sqlalchemy import select -from sqlalchemy.orm import Session -from sqlalchemy.sql import func - -from danswer.configs.constants import NotificationType -from danswer.db.models import Notification -from danswer.db.models import User - - -def create_notification( - user: User | None, - notif_type: NotificationType, - db_session: Session, -) -> Notification: - notification = Notification( - user_id=user.id if user else None, - notif_type=notif_type, - dismissed=False, - last_shown=func.now(), - first_shown=func.now(), - ) - db_session.add(notification) - db_session.commit() - return notification - - -def get_notification_by_id( - notification_id: int, user: User | None, db_session: Session -) -> Notification: - user_id = user.id if user else None - notif = db_session.get(Notification, notification_id) - if not notif: - raise ValueError(f"No notification found with id {notification_id}") - if notif.user_id != user_id: - raise PermissionError( - f"User {user_id} is not authorized to access notification {notification_id}" - ) - return notif - - -def get_notifications( - user: User | None, - db_session: Session, - notif_type: NotificationType | None = None, - include_dismissed: bool = True, -) -> list[Notification]: - query = select(Notification).where( - Notification.user_id == user.id if user else Notification.user_id.is_(None) - ) - if not include_dismissed: - query = query.where(Notification.dismissed.is_(False)) - if notif_type: - query = query.where(Notification.notif_type == notif_type) - return list(db_session.execute(query).scalars().all()) - - -def dismiss_all_notifications( - notif_type: NotificationType, - db_session: Session, -) -> None: - db_session.query(Notification).filter(Notification.notif_type == notif_type).update( - {"dismissed": True} - ) - db_session.commit() - - -def dismiss_notification(notification: Notification, db_session: Session) -> None: - notification.dismissed = True - db_session.commit() - - -def update_notification_last_shown( - notification: Notification, db_session: Session -) -> None: - notification.last_shown = func.now() - db_session.commit() diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py deleted file mode 100644 index bbf45a1d9ad..00000000000 --- a/backend/danswer/db/persona.py +++ /dev/null @@ -1,723 +0,0 @@ -from collections.abc import Sequence -from functools import lru_cache -from uuid import UUID - -from fastapi import HTTPException -from sqlalchemy import delete -from sqlalchemy import exists -from sqlalchemy import func -from sqlalchemy import not_ -from sqlalchemy import or_ -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.orm import aliased -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import Session - -from danswer.auth.schemas import UserRole -from danswer.configs.chat_configs import BING_API_KEY -from danswer.configs.chat_configs import CONTEXT_CHUNKS_ABOVE -from danswer.configs.chat_configs import CONTEXT_CHUNKS_BELOW -from danswer.db.constants import SLACK_BOT_PERSONA_PREFIX -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.models import DocumentSet -from danswer.db.models import Persona -from danswer.db.models import Persona__User -from danswer.db.models import Persona__UserGroup -from danswer.db.models import Prompt -from danswer.db.models import StarterMessage -from danswer.db.models import Tool -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup -from danswer.search.enums import RecencyBiasSetting -from danswer.server.features.persona.models import CreatePersonaRequest -from danswer.server.features.persona.models import PersonaSnapshot -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import fetch_versioned_implementation - -logger = setup_logger() - - -def _add_user_filters( - stmt: Select, user: User | None, get_editable: bool = True -) -> Select: - # If user is None, assume the user is an admin or auth is disabled - if user is None or user.role == UserRole.ADMIN: - return stmt - - Persona__UG = aliased(Persona__UserGroup) - User__UG = aliased(User__UserGroup) - """ - Here we select cc_pairs by relation: - User -> User__UserGroup -> Persona__UserGroup -> Persona - """ - stmt = ( - stmt.outerjoin(Persona__UG) - .outerjoin( - User__UserGroup, - User__UserGroup.user_group_id == Persona__UG.user_group_id, - ) - .outerjoin( - Persona__User, - Persona__User.persona_id == Persona.id, - ) - ) - """ - Filter Personas by: - - if the user is in the user_group that owns the Persona - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out Personas that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all Personas in the groups the user is a curator - for (as well as public Personas) - - if we are not editing, we return all Personas directly connected to the user - """ - where_clause = User__UserGroup.user_id == user.id - if user.role == UserRole.CURATOR and get_editable: - where_clause &= User__UserGroup.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id) - if user.role == UserRole.CURATOR: - user_groups = user_groups.where(User__UG.is_curator == True) # noqa: E712 - where_clause &= ( - ~exists() - .where(Persona__UG.persona_id == Persona.id) - .where(~Persona__UG.user_group_id.in_(user_groups)) - .correlate(Persona) - ) - else: - where_clause |= Persona.is_public == True # noqa: E712 - where_clause &= Persona.is_visible == True # noqa: E712 - where_clause |= Persona__User.user_id == user.id - where_clause |= Persona.user_id == user.id - - return stmt.where(where_clause) - - -def fetch_persona_by_id( - db_session: Session, persona_id: int, user: User | None, get_editable: bool = True -) -> Persona: - stmt = select(Persona).where(Persona.id == persona_id).distinct() - stmt = _add_user_filters(stmt=stmt, user=user, get_editable=get_editable) - persona = db_session.scalars(stmt).one_or_none() - if not persona: - raise HTTPException( - status_code=403, - detail=f"Persona with ID {persona_id} does not exist or user is not authorized to access it", - ) - return persona - - -def _get_persona_by_name( - persona_name: str, user: User | None, db_session: Session -) -> Persona | None: - """Admins can see all, regular users can only fetch their own. - If user is None, assume the user is an admin or auth is disabled.""" - stmt = select(Persona).where(Persona.name == persona_name) - if user and user.role != UserRole.ADMIN: - stmt = stmt.where(Persona.user_id == user.id) - result = db_session.execute(stmt).scalar_one_or_none() - return result - - -def make_persona_private( - persona_id: int, - user_ids: list[UUID] | None, - group_ids: list[int] | None, - db_session: Session, -) -> None: - if user_ids is not None: - db_session.query(Persona__User).filter( - Persona__User.persona_id == persona_id - ).delete(synchronize_session="fetch") - - for user_uuid in user_ids: - db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid)) - - db_session.commit() - - # May cause error if someone switches down to MIT from EE - if group_ids: - raise NotImplementedError("Danswer MIT does not support private Personas") - - -def create_update_persona( - persona_id: int | None, - create_persona_request: CreatePersonaRequest, - user: User | None, - db_session: Session, -) -> PersonaSnapshot: - """Higher level function than upsert_persona, although either is valid to use.""" - # Permission to actually use these is checked later - - try: - persona_data = { - "persona_id": persona_id, - "user": user, - "db_session": db_session, - **create_persona_request.dict(exclude={"users", "groups"}), - } - - persona = upsert_persona(**persona_data) - - versioned_make_persona_private = fetch_versioned_implementation( - "danswer.db.persona", "make_persona_private" - ) - - # Privatize Persona - versioned_make_persona_private( - persona_id=persona.id, - user_ids=create_persona_request.users, - group_ids=create_persona_request.groups, - db_session=db_session, - ) - - except ValueError as e: - logger.exception("Failed to create persona") - raise HTTPException(status_code=400, detail=str(e)) - return PersonaSnapshot.from_model(persona) - - -def update_persona_shared_users( - persona_id: int, - user_ids: list[UUID], - user: User | None, - db_session: Session, -) -> None: - """Simplified version of `create_update_persona` which only touches the - accessibility rather than any of the logic (e.g. prompt, connected data sources, - etc.).""" - persona = fetch_persona_by_id( - db_session=db_session, persona_id=persona_id, user=user, get_editable=True - ) - - if persona.is_public: - raise HTTPException(status_code=400, detail="Cannot share public persona") - - versioned_make_persona_private = fetch_versioned_implementation( - "danswer.db.persona", "make_persona_private" - ) - - # Privatize Persona - versioned_make_persona_private( - persona_id=persona_id, - user_ids=user_ids, - group_ids=None, - db_session=db_session, - ) - - -def get_prompts( - user_id: UUID | None, - db_session: Session, - include_default: bool = True, - include_deleted: bool = False, -) -> Sequence[Prompt]: - stmt = select(Prompt).where( - or_(Prompt.user_id == user_id, Prompt.user_id.is_(None)) - ) - - if not include_default: - stmt = stmt.where(Prompt.default_prompt.is_(False)) - if not include_deleted: - stmt = stmt.where(Prompt.deleted.is_(False)) - - return db_session.scalars(stmt).all() - - -def get_personas( - # if user is `None` assume the user is an admin or auth is disabled - user: User | None, - db_session: Session, - get_editable: bool = True, - include_default: bool = True, - include_slack_bot_personas: bool = False, - include_deleted: bool = False, - joinedload_all: bool = False, -) -> Sequence[Persona]: - stmt = select(Persona).distinct() - stmt = _add_user_filters(stmt=stmt, user=user, get_editable=get_editable) - - if not include_default: - stmt = stmt.where(Persona.default_persona.is_(False)) - if not include_slack_bot_personas: - stmt = stmt.where(not_(Persona.name.startswith(SLACK_BOT_PERSONA_PREFIX))) - if not include_deleted: - stmt = stmt.where(Persona.deleted.is_(False)) - - if joinedload_all: - stmt = stmt.options( - joinedload(Persona.prompts), - joinedload(Persona.tools), - joinedload(Persona.document_sets), - joinedload(Persona.groups), - joinedload(Persona.users), - ) - - return db_session.execute(stmt).unique().scalars().all() - - -def mark_persona_as_deleted( - persona_id: int, - user: User | None, - db_session: Session, -) -> None: - persona = get_persona_by_id(persona_id=persona_id, user=user, db_session=db_session) - persona.deleted = True - db_session.commit() - - -def mark_persona_as_not_deleted( - persona_id: int, - user: User | None, - db_session: Session, -) -> None: - persona = get_persona_by_id( - persona_id=persona_id, user=user, db_session=db_session, include_deleted=True - ) - if persona.deleted: - persona.deleted = False - db_session.commit() - else: - raise ValueError(f"Persona with ID {persona_id} is not deleted.") - - -def mark_delete_persona_by_name( - persona_name: str, db_session: Session, is_default: bool = True -) -> None: - stmt = ( - update(Persona) - .where(Persona.name == persona_name, Persona.default_persona == is_default) - .values(deleted=True) - ) - - db_session.execute(stmt) - db_session.commit() - - -def update_all_personas_display_priority( - display_priority_map: dict[int, int], - db_session: Session, -) -> None: - """Updates the display priority of all lives Personas""" - personas = get_personas(user=None, db_session=db_session) - available_persona_ids = {persona.id for persona in personas} - if available_persona_ids != set(display_priority_map.keys()): - raise ValueError("Invalid persona IDs provided") - - for persona in personas: - persona.display_priority = display_priority_map[persona.id] - - db_session.commit() - - -def upsert_prompt( - user: User | None, - name: str, - description: str, - system_prompt: str, - task_prompt: str, - include_citations: bool, - datetime_aware: bool, - personas: list[Persona] | None, - db_session: Session, - prompt_id: int | None = None, - default_prompt: bool = True, - commit: bool = True, -) -> Prompt: - if prompt_id is not None: - prompt = db_session.query(Prompt).filter_by(id=prompt_id).first() - else: - prompt = get_prompt_by_name(prompt_name=name, user=user, db_session=db_session) - - if prompt: - if not default_prompt and prompt.default_prompt: - raise ValueError("Cannot update default prompt with non-default.") - - prompt.name = name - prompt.description = description - prompt.system_prompt = system_prompt - prompt.task_prompt = task_prompt - prompt.include_citations = include_citations - prompt.datetime_aware = datetime_aware - prompt.default_prompt = default_prompt - - if personas is not None: - prompt.personas.clear() - prompt.personas = personas - - else: - prompt = Prompt( - id=prompt_id, - user_id=user.id if user else None, - name=name, - description=description, - system_prompt=system_prompt, - task_prompt=task_prompt, - include_citations=include_citations, - datetime_aware=datetime_aware, - default_prompt=default_prompt, - personas=personas or [], - ) - db_session.add(prompt) - - if commit: - db_session.commit() - else: - # Flush the session so that the Prompt has an ID - db_session.flush() - - return prompt - - -def upsert_persona( - user: User | None, - name: str, - description: str, - num_chunks: float, - llm_relevance_filter: bool, - llm_filter_extraction: bool, - recency_bias: RecencyBiasSetting, - llm_model_provider_override: str | None, - llm_model_version_override: str | None, - starter_messages: list[StarterMessage] | None, - is_public: bool, - db_session: Session, - prompt_ids: list[int] | None = None, - document_set_ids: list[int] | None = None, - tool_ids: list[int] | None = None, - persona_id: int | None = None, - default_persona: bool = False, - commit: bool = True, - icon_color: str | None = None, - icon_shape: int | None = None, - uploaded_image_id: str | None = None, - display_priority: int | None = None, - is_visible: bool = True, - remove_image: bool | None = None, - chunks_above: int = CONTEXT_CHUNKS_ABOVE, - chunks_below: int = CONTEXT_CHUNKS_BELOW, -) -> Persona: - if persona_id is not None: - persona = db_session.query(Persona).filter_by(id=persona_id).first() - else: - persona = _get_persona_by_name( - persona_name=name, user=user, db_session=db_session - ) - - # Fetch and attach tools by IDs - tools = None - if tool_ids is not None: - tools = db_session.query(Tool).filter(Tool.id.in_(tool_ids)).all() - if not tools and tool_ids: - raise ValueError("Tools not found") - - # Fetch and attach document_sets by IDs - document_sets = None - if document_set_ids is not None: - document_sets = ( - db_session.query(DocumentSet) - .filter(DocumentSet.id.in_(document_set_ids)) - .all() - ) - if not document_sets and document_set_ids: - raise ValueError("document_sets not found") - - # Fetch and attach prompts by IDs - prompts = None - if prompt_ids is not None: - prompts = db_session.query(Prompt).filter(Prompt.id.in_(prompt_ids)).all() - if not prompts and prompt_ids: - raise ValueError("prompts not found") - - # ensure all specified tools are valid - if tools: - validate_persona_tools(tools) - - if persona: - if not default_persona and persona.default_persona: - raise ValueError("Cannot update default persona with non-default.") - - # this checks if the user has permission to edit the persona - persona = fetch_persona_by_id( - db_session=db_session, persona_id=persona.id, user=user, get_editable=True - ) - - persona.name = name - persona.description = description - persona.num_chunks = num_chunks - persona.chunks_above = chunks_above - persona.chunks_below = chunks_below - persona.llm_relevance_filter = llm_relevance_filter - persona.llm_filter_extraction = llm_filter_extraction - persona.recency_bias = recency_bias - persona.default_persona = default_persona - persona.llm_model_provider_override = llm_model_provider_override - persona.llm_model_version_override = llm_model_version_override - persona.starter_messages = starter_messages - persona.deleted = False # Un-delete if previously deleted - persona.is_public = is_public - persona.icon_color = icon_color - persona.icon_shape = icon_shape - if remove_image or uploaded_image_id: - persona.uploaded_image_id = uploaded_image_id - persona.display_priority = display_priority - persona.is_visible = is_visible - - # Do not delete any associations manually added unless - # a new updated list is provided - if document_sets is not None: - persona.document_sets.clear() - persona.document_sets = document_sets or [] - - if prompts is not None: - persona.prompts.clear() - persona.prompts = prompts or [] - - if tools is not None: - persona.tools = tools or [] - - else: - persona = Persona( - id=persona_id, - user_id=user.id if user else None, - is_public=is_public, - name=name, - description=description, - num_chunks=num_chunks, - chunks_above=chunks_above, - chunks_below=chunks_below, - llm_relevance_filter=llm_relevance_filter, - llm_filter_extraction=llm_filter_extraction, - recency_bias=recency_bias, - default_persona=default_persona, - prompts=prompts or [], - document_sets=document_sets or [], - llm_model_provider_override=llm_model_provider_override, - llm_model_version_override=llm_model_version_override, - starter_messages=starter_messages, - tools=tools or [], - icon_shape=icon_shape, - icon_color=icon_color, - uploaded_image_id=uploaded_image_id, - display_priority=display_priority, - is_visible=is_visible, - ) - db_session.add(persona) - - if commit: - db_session.commit() - else: - # flush the session so that the persona has an ID - db_session.flush() - - return persona - - -def mark_prompt_as_deleted( - prompt_id: int, - user: User | None, - db_session: Session, -) -> None: - prompt = get_prompt_by_id(prompt_id=prompt_id, user=user, db_session=db_session) - prompt.deleted = True - db_session.commit() - - -def delete_old_default_personas( - db_session: Session, -) -> None: - """Note, this locks out the Summarize and Paraphrase personas for now - Need a more graceful fix later or those need to never have IDs""" - stmt = ( - update(Persona) - .where(Persona.default_persona, Persona.id > 0) - .values(deleted=True, name=func.concat(Persona.name, "_old")) - ) - - db_session.execute(stmt) - db_session.commit() - - -def update_persona_visibility( - persona_id: int, - is_visible: bool, - db_session: Session, - user: User | None = None, -) -> None: - persona = fetch_persona_by_id( - db_session=db_session, persona_id=persona_id, user=user, get_editable=True - ) - persona.is_visible = is_visible - db_session.commit() - - -def validate_persona_tools(tools: list[Tool]) -> None: - for tool in tools: - if tool.name == "InternetSearchTool" and not BING_API_KEY: - raise ValueError( - "Bing API key not found, please contact your Danswer admin to get it added!" - ) - - -def get_prompts_by_ids(prompt_ids: list[int], db_session: Session) -> Sequence[Prompt]: - """Unsafe, can fetch prompts from all users""" - if not prompt_ids: - return [] - prompts = db_session.scalars(select(Prompt).where(Prompt.id.in_(prompt_ids))).all() - - return prompts - - -def get_prompt_by_id( - prompt_id: int, - user: User | None, - db_session: Session, - include_deleted: bool = False, -) -> Prompt: - stmt = select(Prompt).where(Prompt.id == prompt_id) - - # if user is not specified OR they are an admin, they should - # have access to all prompts, so this where clause is not needed - if user and user.role != UserRole.ADMIN: - stmt = stmt.where(or_(Prompt.user_id == user.id, Prompt.user_id.is_(None))) - - if not include_deleted: - stmt = stmt.where(Prompt.deleted.is_(False)) - - result = db_session.execute(stmt) - prompt = result.scalar_one_or_none() - - if prompt is None: - raise ValueError( - f"Prompt with ID {prompt_id} does not exist or does not belong to user" - ) - - return prompt - - -def _get_default_prompt(db_session: Session) -> Prompt: - stmt = select(Prompt).where(Prompt.id == 0) - result = db_session.execute(stmt) - prompt = result.scalar_one_or_none() - - if prompt is None: - raise RuntimeError("Default Prompt not found") - - return prompt - - -def get_default_prompt(db_session: Session) -> Prompt: - return _get_default_prompt(db_session) - - -@lru_cache() -def get_default_prompt__read_only() -> Prompt: - """Due to the way lru_cache / SQLAlchemy works, this can cause issues - when trying to attach the returned `Prompt` object to a `Persona`. If you are - doing anything other than reading, you should use the `get_default_prompt` - method instead.""" - with Session(get_sqlalchemy_engine()) as db_session: - return _get_default_prompt(db_session) - - -# TODO: since this gets called with every chat message, could it be more efficient to pregenerate -# a direct mapping indicating whether a user has access to a specific persona? -def get_persona_by_id( - persona_id: int, - # if user is `None` assume the user is an admin or auth is disabled - user: User | None, - db_session: Session, - include_deleted: bool = False, - is_for_edit: bool = True, # NOTE: assume true for safety -) -> Persona: - persona_stmt = ( - select(Persona) - .distinct() - .outerjoin(Persona.groups) - .outerjoin(Persona.users) - .outerjoin(UserGroup.user_group_relationships) - .where(Persona.id == persona_id) - ) - - if not include_deleted: - persona_stmt = persona_stmt.where(Persona.deleted.is_(False)) - - if not user or user.role == UserRole.ADMIN: - result = db_session.execute(persona_stmt) - persona = result.scalar_one_or_none() - if persona is None: - raise ValueError( - f"Persona with ID {persona_id} does not exist or does not belong to user" - ) - return persona - - # or check if user owns persona - or_conditions = Persona.user_id == user.id - # allow access if persona user id is None - or_conditions |= Persona.user_id == None # noqa: E711 - if not is_for_edit: - # if the user is in a group related to the persona - or_conditions |= User__UserGroup.user_id == user.id - # if the user is in the .users of the persona - or_conditions |= User.id == user.id - or_conditions |= Persona.is_public == True # noqa: E712 - elif user.role == UserRole.GLOBAL_CURATOR: - # global curators can edit personas for the groups they are in - or_conditions |= User__UserGroup.user_id == user.id - elif user.role == UserRole.CURATOR: - # curators can edit personas for the groups they are curators of - or_conditions |= (User__UserGroup.user_id == user.id) & ( - User__UserGroup.is_curator == True # noqa: E712 - ) - - persona_stmt = persona_stmt.where(or_conditions) - result = db_session.execute(persona_stmt) - persona = result.scalar_one_or_none() - if persona is None: - raise ValueError( - f"Persona with ID {persona_id} does not exist or does not belong to user" - ) - return persona - - -def get_personas_by_ids( - persona_ids: list[int], db_session: Session -) -> Sequence[Persona]: - """Unsafe, can fetch personas from all users""" - if not persona_ids: - return [] - personas = db_session.scalars( - select(Persona).where(Persona.id.in_(persona_ids)) - ).all() - - return personas - - -def get_prompt_by_name( - prompt_name: str, user: User | None, db_session: Session -) -> Prompt | None: - stmt = select(Prompt).where(Prompt.name == prompt_name) - - # if user is not specified OR they are an admin, they should - # have access to all prompts, so this where clause is not needed - if user and user.role != UserRole.ADMIN: - stmt = stmt.where(Prompt.user_id == user.id) - - result = db_session.execute(stmt).scalar_one_or_none() - return result - - -def delete_persona_by_name( - persona_name: str, db_session: Session, is_default: bool = True -) -> None: - stmt = delete(Persona).where( - Persona.name == persona_name, Persona.default_persona == is_default - ) - - db_session.execute(stmt) - - db_session.commit() diff --git a/backend/danswer/db/pg_file_store.py b/backend/danswer/db/pg_file_store.py deleted file mode 100644 index 1333dcd6cee..00000000000 --- a/backend/danswer/db/pg_file_store.py +++ /dev/null @@ -1,150 +0,0 @@ -import tempfile -from io import BytesIO -from typing import IO - -from psycopg2.extensions import connection -from sqlalchemy.orm import Session - -from danswer.configs.constants import FileOrigin -from danswer.db.models import PGFileStore -from danswer.file_store.constants import MAX_IN_MEMORY_SIZE -from danswer.file_store.constants import STANDARD_CHUNK_SIZE -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_pg_conn_from_session(db_session: Session) -> connection: - return db_session.connection().connection.connection # type: ignore - - -def get_pgfilestore_by_file_name( - file_name: str, - db_session: Session, -) -> PGFileStore: - pgfilestore = db_session.query(PGFileStore).filter_by(file_name=file_name).first() - - if not pgfilestore: - raise RuntimeError(f"File by name {file_name} does not exist or was deleted") - - return pgfilestore - - -def delete_pgfilestore_by_file_name( - file_name: str, - db_session: Session, -) -> None: - db_session.query(PGFileStore).filter_by(file_name=file_name).delete() - - -def create_populate_lobj( - content: IO, - db_session: Session, -) -> int: - """Note, this does not commit the changes to the DB - This is because the commit should happen with the PGFileStore row creation - That step finalizes both the Large Object and the table tracking it - """ - pg_conn = get_pg_conn_from_session(db_session) - large_object = pg_conn.lobject() - - # write in multiple chunks to avoid loading the whole file into memory - while True: - chunk = content.read(STANDARD_CHUNK_SIZE) - if not chunk: - break - large_object.write(chunk) - - large_object.close() - - return large_object.oid - - -def read_lobj( - lobj_oid: int, - db_session: Session, - mode: str | None = None, - use_tempfile: bool = False, -) -> IO: - pg_conn = get_pg_conn_from_session(db_session) - large_object = ( - pg_conn.lobject(lobj_oid, mode=mode) if mode else pg_conn.lobject(lobj_oid) - ) - - if use_tempfile: - temp_file = tempfile.SpooledTemporaryFile(max_size=MAX_IN_MEMORY_SIZE) - while True: - chunk = large_object.read(STANDARD_CHUNK_SIZE) - if not chunk: - break - temp_file.write(chunk) - temp_file.seek(0) - return temp_file - else: - return BytesIO(large_object.read()) - - -def delete_lobj_by_id( - lobj_oid: int, - db_session: Session, -) -> None: - pg_conn = get_pg_conn_from_session(db_session) - pg_conn.lobject(lobj_oid).unlink() - - -def delete_lobj_by_name( - lobj_name: str, - db_session: Session, -) -> None: - try: - pgfilestore = get_pgfilestore_by_file_name(lobj_name, db_session) - except RuntimeError: - logger.info(f"no file with name {lobj_name} found") - return - - pg_conn = get_pg_conn_from_session(db_session) - pg_conn.lobject(pgfilestore.lobj_oid).unlink() - - delete_pgfilestore_by_file_name(lobj_name, db_session) - db_session.commit() - - -def upsert_pgfilestore( - file_name: str, - display_name: str | None, - file_origin: FileOrigin, - file_type: str, - lobj_oid: int, - db_session: Session, - commit: bool = False, - file_metadata: dict | None = None, -) -> PGFileStore: - pgfilestore = db_session.query(PGFileStore).filter_by(file_name=file_name).first() - - if pgfilestore: - try: - # This should not happen in normal execution - delete_lobj_by_id(lobj_oid=pgfilestore.lobj_oid, db_session=db_session) - except Exception: - # If the delete fails as well, the large object doesn't exist anyway and even if it - # fails to delete, it's not too terrible as most files sizes are insignificant - logger.error( - f"Failed to delete large object with oid {pgfilestore.lobj_oid}" - ) - - pgfilestore.lobj_oid = lobj_oid - else: - pgfilestore = PGFileStore( - file_name=file_name, - display_name=display_name, - file_origin=file_origin, - file_type=file_type, - file_metadata=file_metadata, - lobj_oid=lobj_oid, - ) - db_session.add(pgfilestore) - - if commit: - db_session.commit() - - return pgfilestore diff --git a/backend/danswer/db/pydantic_type.py b/backend/danswer/db/pydantic_type.py deleted file mode 100644 index 1f37152a851..00000000000 --- a/backend/danswer/db/pydantic_type.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -from typing import Any -from typing import Optional -from typing import Type - -from pydantic import BaseModel -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.types import TypeDecorator - - -class PydanticType(TypeDecorator): - impl = JSONB - - def __init__( - self, pydantic_model: Type[BaseModel], *args: Any, **kwargs: Any - ) -> None: - super().__init__(*args, **kwargs) - self.pydantic_model = pydantic_model - - def process_bind_param( - self, value: Optional[BaseModel], dialect: Any - ) -> Optional[dict]: - if value is not None: - return json.loads(value.json()) - return None - - def process_result_value( - self, value: Optional[dict], dialect: Any - ) -> Optional[BaseModel]: - if value is not None: - return self.pydantic_model.parse_obj(value) - return None diff --git a/backend/danswer/db/search_settings.py b/backend/danswer/db/search_settings.py deleted file mode 100644 index 1d0c218e10a..00000000000 --- a/backend/danswer/db/search_settings.py +++ /dev/null @@ -1,249 +0,0 @@ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.model_configs import ASYM_PASSAGE_PREFIX -from danswer.configs.model_configs import ASYM_QUERY_PREFIX -from danswer.configs.model_configs import DEFAULT_DOCUMENT_ENCODER_MODEL -from danswer.configs.model_configs import DOC_EMBEDDING_DIM -from danswer.configs.model_configs import DOCUMENT_ENCODER_MODEL -from danswer.configs.model_configs import NORMALIZE_EMBEDDINGS -from danswer.configs.model_configs import OLD_DEFAULT_DOCUMENT_ENCODER_MODEL -from danswer.configs.model_configs import OLD_DEFAULT_MODEL_DOC_EMBEDDING_DIM -from danswer.configs.model_configs import OLD_DEFAULT_MODEL_NORMALIZE_EMBEDDINGS -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.llm import fetch_embedding_provider -from danswer.db.models import CloudEmbeddingProvider -from danswer.db.models import IndexModelStatus -from danswer.db.models import SearchSettings -from danswer.indexing.models import IndexingSetting -from danswer.natural_language_processing.search_nlp_models import clean_model_name -from danswer.search.models import SavedSearchSettings -from danswer.server.manage.embedding.models import ( - CloudEmbeddingProvider as ServerCloudEmbeddingProvider, -) -from danswer.utils.logger import setup_logger -from shared_configs.configs import PRESERVED_SEARCH_FIELDS -from shared_configs.enums import EmbeddingProvider - -logger = setup_logger() - - -def create_search_settings( - search_settings: SavedSearchSettings, - db_session: Session, - status: IndexModelStatus = IndexModelStatus.FUTURE, -) -> SearchSettings: - embedding_model = SearchSettings( - model_name=search_settings.model_name, - model_dim=search_settings.model_dim, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - status=status, - index_name=search_settings.index_name, - provider_type=search_settings.provider_type, - multipass_indexing=search_settings.multipass_indexing, - multilingual_expansion=search_settings.multilingual_expansion, - disable_rerank_for_streaming=search_settings.disable_rerank_for_streaming, - rerank_model_name=search_settings.rerank_model_name, - rerank_provider_type=search_settings.rerank_provider_type, - rerank_api_key=search_settings.rerank_api_key, - num_rerank=search_settings.num_rerank, - ) - - db_session.add(embedding_model) - db_session.commit() - - return embedding_model - - -def get_embedding_provider_from_provider_type( - db_session: Session, provider_type: EmbeddingProvider -) -> CloudEmbeddingProvider | None: - query = select(CloudEmbeddingProvider).where( - CloudEmbeddingProvider.provider_type == provider_type - ) - provider = db_session.execute(query).scalars().first() - return provider if provider else None - - -def get_current_db_embedding_provider( - db_session: Session, -) -> ServerCloudEmbeddingProvider | None: - search_settings = get_current_search_settings(db_session=db_session) - - if search_settings.provider_type is None: - return None - - embedding_provider = fetch_embedding_provider( - db_session=db_session, - provider_type=search_settings.provider_type, - ) - if embedding_provider is None: - raise RuntimeError("No embedding provider exists for this model.") - - current_embedding_provider = ServerCloudEmbeddingProvider.from_request( - cloud_provider_model=embedding_provider - ) - - return current_embedding_provider - - -def get_current_search_settings(db_session: Session) -> SearchSettings: - query = ( - select(SearchSettings) - .where(SearchSettings.status == IndexModelStatus.PRESENT) - .order_by(SearchSettings.id.desc()) - ) - result = db_session.execute(query) - latest_settings = result.scalars().first() - - if not latest_settings: - raise RuntimeError("No search settings specified, DB is not in a valid state") - return latest_settings - - -def get_secondary_search_settings(db_session: Session) -> SearchSettings | None: - query = ( - select(SearchSettings) - .where(SearchSettings.status == IndexModelStatus.FUTURE) - .order_by(SearchSettings.id.desc()) - ) - result = db_session.execute(query) - latest_settings = result.scalars().first() - - return latest_settings - - -def get_multilingual_expansion(db_session: Session | None = None) -> list[str]: - if db_session is None: - with Session(get_sqlalchemy_engine()) as db_session: - search_settings = get_current_search_settings(db_session) - else: - search_settings = get_current_search_settings(db_session) - if not search_settings: - return [] - return search_settings.multilingual_expansion - - -def update_search_settings( - current_settings: SearchSettings, - updated_settings: SavedSearchSettings, - preserved_fields: list[str], -) -> None: - for field, value in updated_settings.dict().items(): - if field not in preserved_fields: - setattr(current_settings, field, value) - - -def update_current_search_settings( - db_session: Session, - search_settings: SavedSearchSettings, - preserved_fields: list[str] = PRESERVED_SEARCH_FIELDS, -) -> None: - current_settings = get_current_search_settings(db_session) - if not current_settings: - logger.warning("No current search settings found to update") - return - - update_search_settings(current_settings, search_settings, preserved_fields) - db_session.commit() - logger.info("Current search settings updated successfully") - - -def update_secondary_search_settings( - db_session: Session, - search_settings: SavedSearchSettings, - preserved_fields: list[str] = PRESERVED_SEARCH_FIELDS, -) -> None: - secondary_settings = get_secondary_search_settings(db_session) - if not secondary_settings: - logger.warning("No secondary search settings found to update") - return - - preserved_fields = PRESERVED_SEARCH_FIELDS - update_search_settings(secondary_settings, search_settings, preserved_fields) - - db_session.commit() - logger.info("Secondary search settings updated successfully") - - -def update_search_settings_status( - search_settings: SearchSettings, new_status: IndexModelStatus, db_session: Session -) -> None: - search_settings.status = new_status - db_session.commit() - - -def user_has_overridden_embedding_model() -> bool: - return DOCUMENT_ENCODER_MODEL != DEFAULT_DOCUMENT_ENCODER_MODEL - - -def get_old_default_search_settings() -> SearchSettings: - is_overridden = user_has_overridden_embedding_model() - return SearchSettings( - model_name=( - DOCUMENT_ENCODER_MODEL - if is_overridden - else OLD_DEFAULT_DOCUMENT_ENCODER_MODEL - ), - model_dim=( - DOC_EMBEDDING_DIM if is_overridden else OLD_DEFAULT_MODEL_DOC_EMBEDDING_DIM - ), - normalize=( - NORMALIZE_EMBEDDINGS - if is_overridden - else OLD_DEFAULT_MODEL_NORMALIZE_EMBEDDINGS - ), - query_prefix=(ASYM_QUERY_PREFIX if is_overridden else ""), - passage_prefix=(ASYM_PASSAGE_PREFIX if is_overridden else ""), - status=IndexModelStatus.PRESENT, - index_name="danswer_chunk", - ) - - -def get_new_default_search_settings(is_present: bool) -> SearchSettings: - return SearchSettings( - model_name=DOCUMENT_ENCODER_MODEL, - model_dim=DOC_EMBEDDING_DIM, - normalize=NORMALIZE_EMBEDDINGS, - query_prefix=ASYM_QUERY_PREFIX, - passage_prefix=ASYM_PASSAGE_PREFIX, - status=IndexModelStatus.PRESENT if is_present else IndexModelStatus.FUTURE, - index_name=f"danswer_chunk_{clean_model_name(DOCUMENT_ENCODER_MODEL)}", - ) - - -def get_old_default_embedding_model() -> IndexingSetting: - is_overridden = user_has_overridden_embedding_model() - return IndexingSetting( - model_name=( - DOCUMENT_ENCODER_MODEL - if is_overridden - else OLD_DEFAULT_DOCUMENT_ENCODER_MODEL - ), - model_dim=( - DOC_EMBEDDING_DIM if is_overridden else OLD_DEFAULT_MODEL_DOC_EMBEDDING_DIM - ), - normalize=( - NORMALIZE_EMBEDDINGS - if is_overridden - else OLD_DEFAULT_MODEL_NORMALIZE_EMBEDDINGS - ), - query_prefix=(ASYM_QUERY_PREFIX if is_overridden else ""), - passage_prefix=(ASYM_PASSAGE_PREFIX if is_overridden else ""), - index_name="danswer_chunk", - multipass_indexing=False, - ) - - -def get_new_default_embedding_model() -> IndexingSetting: - return IndexingSetting( - model_name=DOCUMENT_ENCODER_MODEL, - model_dim=DOC_EMBEDDING_DIM, - normalize=NORMALIZE_EMBEDDINGS, - query_prefix=ASYM_QUERY_PREFIX, - passage_prefix=ASYM_PASSAGE_PREFIX, - index_name=f"danswer_chunk_{clean_model_name(DOCUMENT_ENCODER_MODEL)}", - multipass_indexing=False, - ) diff --git a/backend/danswer/db/slack_bot_config.py b/backend/danswer/db/slack_bot_config.py deleted file mode 100644 index 322dc4c4ed9..00000000000 --- a/backend/danswer/db/slack_bot_config.py +++ /dev/null @@ -1,205 +0,0 @@ -from collections.abc import Sequence - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT -from danswer.db.constants import SLACK_BOT_PERSONA_PREFIX -from danswer.db.models import ChannelConfig -from danswer.db.models import Persona -from danswer.db.models import Persona__DocumentSet -from danswer.db.models import SlackBotConfig -from danswer.db.models import SlackBotResponseType -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 - - -def _build_persona_name(channel_names: list[str]) -> str: - return f"{SLACK_BOT_PERSONA_PREFIX}{'-'.join(channel_names)}" - - -def _cleanup_relationships(db_session: Session, persona_id: int) -> None: - """NOTE: does not commit changes""" - # delete existing persona-document_set relationships - existing_relationships = db_session.scalars( - select(Persona__DocumentSet).where( - Persona__DocumentSet.persona_id == persona_id - ) - ) - for rel in existing_relationships: - db_session.delete(rel) - - -def create_slack_bot_persona( - db_session: Session, - channel_names: list[str], - document_set_ids: list[int], - existing_persona_id: int | None = None, - num_chunks: float = MAX_CHUNKS_FED_TO_CHAT, - enable_auto_filters: bool = False, -) -> Persona: - """NOTE: does not commit changes""" - - # create/update persona associated with the slack bot - persona_name = _build_persona_name(channel_names) - default_prompt = get_default_prompt(db_session) - persona = upsert_persona( - user=None, # Slack Bot Personas are not attached to users - persona_id=existing_persona_id, - name=persona_name, - description="", - num_chunks=num_chunks, - llm_relevance_filter=True, - llm_filter_extraction=enable_auto_filters, - recency_bias=RecencyBiasSetting.AUTO, - prompt_ids=[default_prompt.id], - document_set_ids=document_set_ids, - llm_model_provider_override=None, - llm_model_version_override=None, - starter_messages=None, - is_public=True, - default_persona=False, - db_session=db_session, - commit=False, - ) - - return persona - - -def insert_slack_bot_config( - persona_id: int | None, - channel_config: ChannelConfig, - response_type: SlackBotResponseType, - standard_answer_category_ids: list[int], - enable_auto_filters: bool, - 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, - enable_auto_filters=enable_auto_filters, - ) - db_session.add(slack_bot_config) - db_session.commit() - - return slack_bot_config - - -def update_slack_bot_config( - slack_bot_config_id: int, - persona_id: int | None, - channel_config: ChannelConfig, - response_type: SlackBotResponseType, - standard_answer_category_ids: list[int], - enable_auto_filters: bool, - db_session: Session, -) -> SlackBotConfig: - slack_bot_config = db_session.scalar( - select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id) - ) - if slack_bot_config is None: - 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 - - # update the config - # NOTE: need to do this before cleaning up the old persona or else we - # will encounter `violates foreign key constraint` errors - 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 - ) - slack_bot_config.enable_auto_filters = enable_auto_filters - - # if the persona has changed, then clean up the old persona - if persona_id != existing_persona_id and existing_persona_id: - existing_persona = db_session.scalar( - select(Persona).where(Persona.id == existing_persona_id) - ) - # if the existing persona was one created just for use with this Slack Bot, - # then clean it up - if existing_persona and existing_persona.name.startswith( - SLACK_BOT_PERSONA_PREFIX - ): - _cleanup_relationships( - db_session=db_session, persona_id=existing_persona_id - ) - - db_session.commit() - - return slack_bot_config - - -def remove_slack_bot_config( - slack_bot_config_id: int, - user: User | None, - db_session: Session, -) -> None: - slack_bot_config = db_session.scalar( - select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id) - ) - if slack_bot_config is None: - raise ValueError( - f"Unable to find slack bot config with ID {slack_bot_config_id}" - ) - - existing_persona_id = slack_bot_config.persona_id - if existing_persona_id: - existing_persona = db_session.scalar( - select(Persona).where(Persona.id == existing_persona_id) - ) - # if the existing persona was one created just for use with this Slack Bot, - # then clean it up - if existing_persona and existing_persona.name.startswith( - SLACK_BOT_PERSONA_PREFIX - ): - _cleanup_relationships( - db_session=db_session, persona_id=existing_persona_id - ) - mark_persona_as_deleted( - persona_id=existing_persona_id, user=user, db_session=db_session - ) - - db_session.delete(slack_bot_config) - db_session.commit() - - -def fetch_slack_bot_config( - db_session: Session, slack_bot_config_id: int -) -> SlackBotConfig | None: - return db_session.scalar( - select(SlackBotConfig).where(SlackBotConfig.id == slack_bot_config_id) - ) - - -def fetch_slack_bot_configs(db_session: Session) -> Sequence[SlackBotConfig]: - return db_session.scalars(select(SlackBotConfig)).all() diff --git a/backend/danswer/db/standard_answer.py b/backend/danswer/db/standard_answer.py deleted file mode 100644 index 064a5fa59ef..00000000000 --- a/backend/danswer/db/standard_answer.py +++ /dev/null @@ -1,239 +0,0 @@ -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_names( - standard_answer_category_names: list[str], - db_session: Session, -) -> Sequence[StandardAnswerCategory]: - return db_session.scalars( - select(StandardAnswerCategory).where( - StandardAnswerCategory.name.in_(standard_answer_category_names) - ) - ).all() - - -def fetch_standard_answer_categories_by_ids( - standard_answer_category_ids: list[int], - db_session: Session, -) -> 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() diff --git a/backend/danswer/db/swap_index.py b/backend/danswer/db/swap_index.py deleted file mode 100644 index 8f6d1718924..00000000000 --- a/backend/danswer/db/swap_index.py +++ /dev/null @@ -1,65 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.configs.constants import KV_REINDEX_KEY -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.connector_credential_pair import resync_cc_pair -from danswer.db.enums import IndexModelStatus -from danswer.db.index_attempt import cancel_indexing_attempts_past_model -from danswer.db.index_attempt import ( - count_unique_cc_pairs_with_successful_index_attempts, -) -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.db.search_settings import update_search_settings_status -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def check_index_swap(db_session: Session) -> None: - """Get count of cc-pairs and count of successful index_attempts for the - new model grouped by connector + credential, if it's the same, then assume - new index is done building. If so, swap the indices and expire the old one.""" - # Default CC-pair created for Ingestion API unused here - all_cc_pairs = get_connector_credential_pairs(db_session) - cc_pair_count = max(len(all_cc_pairs) - 1, 0) - search_settings = get_secondary_search_settings(db_session) - - if not search_settings: - return - - unique_cc_indexings = count_unique_cc_pairs_with_successful_index_attempts( - search_settings_id=search_settings.id, db_session=db_session - ) - - # Index Attempts are cleaned up as well when the cc-pair is deleted so the logic in this - # function is correct. The unique_cc_indexings are specifically for the existing cc-pairs - if unique_cc_indexings > cc_pair_count: - logger.error("More unique indexings than cc pairs, should not occur") - - if cc_pair_count == 0 or cc_pair_count == unique_cc_indexings: - # Swap indices - now_old_search_settings = get_current_search_settings(db_session) - update_search_settings_status( - search_settings=now_old_search_settings, - new_status=IndexModelStatus.PAST, - db_session=db_session, - ) - - update_search_settings_status( - search_settings=search_settings, - new_status=IndexModelStatus.PRESENT, - db_session=db_session, - ) - - if cc_pair_count > 0: - kv_store = get_dynamic_config_store() - kv_store.store(KV_REINDEX_KEY, False) - - # Expire jobs for the now past index/embedding model - cancel_indexing_attempts_past_model(db_session) - - # Recount aggregates - for cc_pair in all_cc_pairs: - resync_cc_pair(cc_pair, db_session=db_session) diff --git a/backend/danswer/db/tag.py b/backend/danswer/db/tag.py deleted file mode 100644 index 688b8a11272..00000000000 --- a/backend/danswer/db/tag.py +++ /dev/null @@ -1,156 +0,0 @@ -from sqlalchemy import delete -from sqlalchemy import func -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.models import Document -from danswer.db.models import Document__Tag -from danswer.db.models import Tag -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def check_tag_validity(tag_key: str, tag_value: str) -> bool: - """If a tag 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 tags are not really usable / useful.""" - if len(tag_key) + len(tag_value) > 255: - logger.error( - f"Tag with key '{tag_key}' and value '{tag_value}' is too long, cannot be used" - ) - return False - - return True - - -def create_or_add_document_tag( - tag_key: str, - tag_value: str, - source: DocumentSource, - document_id: str, - db_session: Session, -) -> Tag | None: - if not check_tag_validity(tag_key, tag_value): - return None - - document = db_session.get(Document, document_id) - if not document: - raise ValueError("Invalid Document, cannot attach Tags") - - tag_stmt = select(Tag).where( - Tag.tag_key == tag_key, - Tag.tag_value == tag_value, - Tag.source == source, - ) - tag = db_session.execute(tag_stmt).scalar_one_or_none() - - if not tag: - tag = Tag(tag_key=tag_key, tag_value=tag_value, source=source) - db_session.add(tag) - - if tag not in document.tags: - document.tags.append(tag) - - db_session.commit() - return tag - - -def create_or_add_document_tag_list( - tag_key: str, - tag_values: list[str], - source: DocumentSource, - document_id: str, - db_session: Session, -) -> list[Tag]: - valid_tag_values = [ - tag_value for tag_value in tag_values if check_tag_validity(tag_key, tag_value) - ] - if not valid_tag_values: - return [] - - document = db_session.get(Document, document_id) - if not document: - raise ValueError("Invalid Document, cannot attach Tags") - - existing_tags_stmt = select(Tag).where( - Tag.tag_key == tag_key, - Tag.tag_value.in_(valid_tag_values), - Tag.source == source, - ) - existing_tags = list(db_session.execute(existing_tags_stmt).scalars().all()) - existing_tag_values = {tag.tag_value for tag in existing_tags} - - new_tags = [] - for tag_value in valid_tag_values: - if tag_value not in existing_tag_values: - new_tag = Tag(tag_key=tag_key, tag_value=tag_value, source=source) - db_session.add(new_tag) - new_tags.append(new_tag) - existing_tag_values.add(tag_value) - - if new_tags: - logger.debug( - f"Created new tags: {', '.join([f'{tag.tag_key}:{tag.tag_value}' for tag in new_tags])}" - ) - - all_tags = existing_tags + new_tags - - for tag in all_tags: - if tag not in document.tags: - document.tags.append(tag) - - db_session.commit() - return all_tags - - -def get_tags_by_value_prefix_for_source_types( - tag_key_prefix: str | None, - tag_value_prefix: str | None, - sources: list[DocumentSource] | None, - limit: int | None, - db_session: Session, -) -> list[Tag]: - query = select(Tag) - - if tag_key_prefix or tag_value_prefix: - conditions = [] - if tag_key_prefix: - conditions.append(Tag.tag_key.ilike(f"{tag_key_prefix}%")) - if tag_value_prefix: - conditions.append(Tag.tag_value.ilike(f"{tag_value_prefix}%")) - query = query.where(or_(*conditions)) - - if sources: - query = query.where(Tag.source.in_(sources)) - - if limit: - query = query.limit(limit) - - result = db_session.execute(query) - - tags = result.scalars().all() - return list(tags) - - -def delete_document_tags_for_documents__no_commit( - document_ids: list[str], db_session: Session -) -> None: - stmt = delete(Document__Tag).where(Document__Tag.document_id.in_(document_ids)) - db_session.execute(stmt) - - orphan_tags_query = ( - select(Tag.id) - .outerjoin(Document__Tag, Tag.id == Document__Tag.tag_id) - .group_by(Tag.id) - .having(func.count(Document__Tag.document_id) == 0) - ) - - orphan_tags = db_session.execute(orphan_tags_query).scalars().all() - - if orphan_tags: - delete_orphan_tags_stmt = delete(Tag).where(Tag.id.in_(orphan_tags)) - db_session.execute(delete_orphan_tags_stmt) diff --git a/backend/danswer/db/tasks.py b/backend/danswer/db/tasks.py deleted file mode 100644 index 23a7edc9882..00000000000 --- a/backend/danswer/db/tasks.py +++ /dev/null @@ -1,102 +0,0 @@ -from sqlalchemy import desc -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import JOB_TIMEOUT -from danswer.db.engine import get_db_current_time -from danswer.db.models import TaskQueueState -from danswer.db.models import TaskStatus - - -def get_latest_task( - task_name: str, - db_session: Session, -) -> TaskQueueState | None: - stmt = ( - select(TaskQueueState) - .where(TaskQueueState.task_name == task_name) - .order_by(desc(TaskQueueState.id)) - .limit(1) - ) - - result = db_session.execute(stmt) - latest_task = result.scalars().first() - - return latest_task - - -def get_latest_task_by_type( - task_name: str, - db_session: Session, -) -> TaskQueueState | None: - stmt = ( - select(TaskQueueState) - .where(TaskQueueState.task_name.like(f"%{task_name}%")) - .order_by(desc(TaskQueueState.id)) - .limit(1) - ) - - result = db_session.execute(stmt) - latest_task = result.scalars().first() - - return latest_task - - -def register_task( - task_id: str, - task_name: str, - db_session: Session, -) -> TaskQueueState: - new_task = TaskQueueState( - task_id=task_id, task_name=task_name, status=TaskStatus.PENDING - ) - - db_session.add(new_task) - db_session.commit() - - return new_task - - -def mark_task_start( - task_name: str, - db_session: Session, -) -> None: - task = get_latest_task(task_name, db_session) - if not task: - raise ValueError(f"No task found with name {task_name}") - - task.start_time = func.now() # type: ignore - db_session.commit() - - -def mark_task_finished( - task_name: str, - db_session: Session, - success: bool = True, -) -> None: - latest_task = get_latest_task(task_name, db_session) - if latest_task is None: - raise ValueError(f"tasks for {task_name} do not exist") - - latest_task.status = TaskStatus.SUCCESS if success else TaskStatus.FAILURE - db_session.commit() - - -def check_task_is_live_and_not_timed_out( - task: TaskQueueState, - db_session: Session, - timeout: int = JOB_TIMEOUT, -) -> bool: - # We only care for live tasks to not create new periodic tasks - if task.status in [TaskStatus.SUCCESS, TaskStatus.FAILURE]: - return False - - current_db_time = get_db_current_time(db_session=db_session) - - last_update_time = task.register_time - if task.start_time: - last_update_time = max(task.register_time, task.start_time) - - time_elapsed = current_db_time - last_update_time - return time_elapsed.total_seconds() < timeout diff --git a/backend/danswer/db/tools.py b/backend/danswer/db/tools.py deleted file mode 100644 index 1e75b1c4901..00000000000 --- a/backend/danswer/db/tools.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import Any -from uuid import UUID - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import Tool -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_tools(db_session: Session) -> list[Tool]: - return list(db_session.scalars(select(Tool)).all()) - - -def get_tool_by_id(tool_id: int, db_session: Session) -> Tool: - tool = db_session.scalar(select(Tool).where(Tool.id == tool_id)) - if not tool: - raise ValueError("Tool by specified id does not exist") - return tool - - -def create_tool( - name: str, - description: str | None, - openapi_schema: dict[str, Any] | None, - user_id: UUID | None, - db_session: Session, -) -> Tool: - new_tool = Tool( - name=name, - description=description, - in_code_tool_id=None, - openapi_schema=openapi_schema, - user_id=user_id, - ) - db_session.add(new_tool) - db_session.commit() - return new_tool - - -def update_tool( - tool_id: int, - name: str | None, - description: str | None, - openapi_schema: dict[str, Any] | None, - user_id: UUID | None, - db_session: Session, -) -> Tool: - tool = get_tool_by_id(tool_id, db_session) - if tool is None: - raise ValueError(f"Tool with ID {tool_id} does not exist") - - if name is not None: - tool.name = name - if description is not None: - tool.description = description - if openapi_schema is not None: - tool.openapi_schema = openapi_schema - if user_id is not None: - tool.user_id = user_id - db_session.commit() - - return tool - - -def delete_tool(tool_id: int, db_session: Session) -> None: - tool = get_tool_by_id(tool_id, db_session) - if tool is None: - raise ValueError(f"Tool with ID {tool_id} does not exist") - - db_session.delete(tool) - db_session.commit() diff --git a/backend/danswer/db/users.py b/backend/danswer/db/users.py deleted file mode 100644 index d824ccfd921..00000000000 --- a/backend/danswer/db/users.py +++ /dev/null @@ -1,32 +0,0 @@ -from collections.abc import Sequence -from uuid import UUID - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import User - - -def list_users( - db_session: Session, email_filter_string: str = "", user: User | None = None -) -> Sequence[User]: - """List all users. No pagination as of now, as the # of users - is assumed to be relatively small (<< 1 million)""" - stmt = select(User) - - if email_filter_string: - stmt = stmt.where(User.email.ilike(f"%{email_filter_string}%")) # type: ignore - - return db_session.scalars(stmt).unique().all() - - -def get_user_by_email(email: str, db_session: Session) -> User | None: - user = db_session.query(User).filter(User.email == email).first() # type: ignore - - return user - - -def fetch_user_by_id(db_session: Session, user_id: UUID) -> User | None: - user = db_session.query(User).filter(User.id == user_id).first() # type: ignore - - return user diff --git a/backend/danswer/db/utils.py b/backend/danswer/db/utils.py deleted file mode 100644 index c188543c46a..00000000000 --- a/backend/danswer/db/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Any - -from sqlalchemy import inspect - -from danswer.db.models import Base - - -def model_to_dict(model: Base) -> dict[str, Any]: - return {c.key: getattr(model, c.key) for c in inspect(model).mapper.column_attrs} # type: ignore diff --git a/backend/danswer/document_index/__init__.py b/backend/danswer/document_index/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/document_index/document_index_utils.py b/backend/danswer/document_index/document_index_utils.py deleted file mode 100644 index fab7b85ef48..00000000000 --- a/backend/danswer/document_index/document_index_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -import math -import uuid - -from sqlalchemy.orm import Session - -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.indexing.models import IndexChunk -from danswer.search.models import InferenceChunk - - -DEFAULT_BATCH_SIZE = 30 -DEFAULT_INDEX_NAME = "danswer_chunk" - - -def get_both_index_names(db_session: Session) -> tuple[str, str | None]: - search_settings = get_current_search_settings(db_session) - - search_settings_new = get_secondary_search_settings(db_session) - if not search_settings_new: - return search_settings.index_name, None - - return search_settings.index_name, search_settings_new.index_name - - -def translate_boost_count_to_multiplier(boost: int) -> float: - """Mapping boost integer values to a multiplier according to a sigmoid curve - Piecewise such that at many downvotes, its 0.5x the score and with many upvotes - it is 2x the score. This should be in line with the Vespa calculation.""" - # 3 in the equation below stretches it out to hit asymptotes slower - if boost < 0: - # 0.5 + sigmoid -> range of 0.5 to 1 - return 0.5 + (1 / (1 + math.exp(-1 * boost / 3))) - - # 2 x sigmoid -> range of 1 to 2 - return 2 / (1 + math.exp(-1 * boost / 3)) - - -def get_uuid_from_chunk( - chunk: IndexChunk | InferenceChunk, mini_chunk_ind: int = 0 -) -> uuid.UUID: - doc_str = ( - chunk.document_id - if isinstance(chunk, InferenceChunk) - else chunk.source_document.id - ) - # Web parsing URL duplicate catching - if doc_str and doc_str[-1] == "/": - doc_str = doc_str[:-1] - unique_identifier_string = "_".join( - [doc_str, str(chunk.chunk_id), str(mini_chunk_ind)] - ) - if chunk.large_chunk_reference_ids: - unique_identifier_string += "_large" + "_".join( - [ - str(referenced_chunk_id) - for referenced_chunk_id in chunk.large_chunk_reference_ids - ] - ) - return uuid.uuid5(uuid.NAMESPACE_X500, unique_identifier_string) diff --git a/backend/danswer/document_index/factory.py b/backend/danswer/document_index/factory.py deleted file mode 100644 index 17701d98e04..00000000000 --- a/backend/danswer/document_index/factory.py +++ /dev/null @@ -1,15 +0,0 @@ -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.vespa.index import VespaIndex - - -def get_default_document_index( - primary_index_name: str, - secondary_index_name: str | None, -) -> DocumentIndex: - """Primary index is the index that is used for querying/updating etc. - Secondary index is for when both the currently used index and the upcoming - index both need to be updated, updates are applied to both indices""" - # Currently only supporting Vespa - return VespaIndex( - index_name=primary_index_name, secondary_index_name=secondary_index_name - ) diff --git a/backend/danswer/document_index/interfaces.py b/backend/danswer/document_index/interfaces.py deleted file mode 100644 index 2acd0977959..00000000000 --- a/backend/danswer/document_index/interfaces.py +++ /dev/null @@ -1,339 +0,0 @@ -import abc -from dataclasses import dataclass -from datetime import datetime -from typing import Any - -from danswer.access.models import DocumentAccess -from danswer.indexing.models import DocMetadataAwareIndexChunk -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceChunkUncleaned -from shared_configs.model_server_models import Embedding - - -@dataclass(frozen=True) -class DocumentInsertionRecord: - document_id: str - already_existed: bool - - -@dataclass(frozen=True) -class VespaChunkRequest: - document_id: str - min_chunk_ind: int | None = None - max_chunk_ind: int | None = None - - @property - def is_capped(self) -> bool: - # If the max chunk index is not None, then the chunk request is capped - # If the min chunk index is None, we can assume the min is 0 - return self.max_chunk_ind is not None - - @property - def range(self) -> int | None: - if self.max_chunk_ind is not None: - return (self.max_chunk_ind - (self.min_chunk_ind or 0)) + 1 - return None - - -@dataclass -class DocumentMetadata: - """ - Document information that needs to be inserted into Postgres on first time encountering this - document during indexing across any of the connectors. - """ - - connector_id: int - credential_id: int - document_id: str - semantic_identifier: str - first_link: str - doc_updated_at: datetime | None = None - # Emails, not necessarily attached to users - # Users may not be in Danswer - primary_owners: list[str] | None = None - secondary_owners: list[str] | None = None - from_ingestion_api: bool = False - - -@dataclass -class UpdateRequest: - """ - For all document_ids, update the allowed_users and the boost to the new values - Does not update any of the None fields - """ - - document_ids: list[str] - # all other fields except these 4 will always be left alone by the update request - access: DocumentAccess | None = None - document_sets: set[str] | None = None - boost: float | None = None - hidden: bool | None = None - - -class Verifiable(abc.ABC): - """ - Class must implement document index schema verification. For example, verify that all of the - necessary attributes for indexing, querying, filtering, and fields to return from search are - all valid in the schema. - - Parameters: - - index_name: The name of the primary index currently used for querying - - secondary_index_name: The name of the secondary index being built in the background, if it - currently exists. Some functions on the document index act on both the primary and - secondary index, some act on just one. - """ - - @abc.abstractmethod - def __init__( - self, - index_name: str, - secondary_index_name: str | None, - *args: Any, - **kwargs: Any - ) -> None: - super().__init__(*args, **kwargs) - self.index_name = index_name - self.secondary_index_name = secondary_index_name - - @abc.abstractmethod - def ensure_indices_exist( - self, - index_embedding_dim: int, - secondary_index_embedding_dim: int | None, - ) -> None: - """ - Verify that the document index exists and is consistent with the expectations in the code. - - Parameters: - - index_embedding_dim: Vector dimensionality for the vector similarity part of the search - - secondary_index_embedding_dim: Vector dimensionality of the secondary index being built - behind the scenes. The secondary index should only be built when switching - embedding models therefore this dim should be different from the primary index. - """ - raise NotImplementedError - - -class Indexable(abc.ABC): - """ - Class must implement the ability to index document chunks - """ - - @abc.abstractmethod - def index( - self, - chunks: list[DocMetadataAwareIndexChunk], - ) -> set[DocumentInsertionRecord]: - """ - Takes a list of document chunks and indexes them in the document index - - NOTE: When a document is reindexed/updated here, it must clear all of the existing document - chunks before reindexing. This is because the document may have gotten shorter since the - last run. Therefore, upserting the first 0 through n chunks may leave some old chunks that - have not been written over. - - NOTE: The chunks of a document are never separated into separate index() calls. So there is - no worry of receiving the first 0 through n chunks in one index call and the next n through - m chunks of a docu in the next index call. - - NOTE: Due to some asymmetry between the primary and secondary indexing logic, this function - only needs to index chunks into the PRIMARY index. Do not update the secondary index here, - it is done automatically outside of this code. - - Parameters: - - chunks: Document chunks with all of the information needed for indexing to the document - index. - - Returns: - List of document ids which map to unique documents and are used for deduping chunks - when updating, as well as if the document is newly indexed or already existed and - just updated - """ - raise NotImplementedError - - -class Deletable(abc.ABC): - """ - Class must implement the ability to delete document by their unique document ids. - """ - - @abc.abstractmethod - def delete(self, doc_ids: list[str]) -> None: - """ - Given a list of document ids, hard delete them from the document index - - Parameters: - - doc_ids: list of document ids as specified by the connector - """ - raise NotImplementedError - - -class Updatable(abc.ABC): - """ - Class must implement the ability to update certain attributes of a document without needing to - update all of the fields. Specifically, needs to be able to update: - - Access Control List - - Document-set membership - - Boost value (learning from feedback mechanism) - - Whether the document is hidden or not, hidden documents are not returned from search - """ - - @abc.abstractmethod - def update(self, update_requests: list[UpdateRequest]) -> None: - """ - Updates some set of chunks. The document and fields to update are specified in the update - requests. Each update request in the list applies its changes to a list of document ids. - None values mean that the field does not need an update. - - Parameters: - - update_requests: for a list of document ids in the update request, apply the same updates - to all of the documents with those ids. This is for bulk handling efficiency. Many - updates are done at the connector level which have many documents for the connector - """ - raise NotImplementedError - - -class IdRetrievalCapable(abc.ABC): - """ - Class must implement the ability to retrieve either: - - all of the chunks of a document IN ORDER given a document id. - - a specific chunk given a document id and a chunk index (0 based) - """ - - @abc.abstractmethod - def id_based_retrieval( - self, - chunk_requests: list[VespaChunkRequest], - filters: IndexFilters, - batch_retrieval: bool = False, - ) -> list[InferenceChunkUncleaned]: - """ - Fetch chunk(s) based on document id - - NOTE: This is used to reconstruct a full document or an extended (multi-chunk) section - of a document. Downstream currently assumes that the chunking does not introduce overlaps - between the chunks. If there are overlaps for the chunks, then the reconstructed document - or extended section will have duplicate segments. - - Parameters: - - chunk_requests: requests containing the document id and the chunk range to retrieve - - filters: Filters to apply to retrieval - - batch_retrieval: If True, perform a batch retrieval - - Returns: - list of chunks for the document id or the specific chunk by the specified chunk index - and document id - """ - raise NotImplementedError - - -class HybridCapable(abc.ABC): - """ - Class must implement hybrid (keyword + vector) search functionality - """ - - @abc.abstractmethod - def hybrid_retrieval( - self, - query: str, - query_embedding: Embedding, - final_keywords: list[str] | None, - filters: IndexFilters, - hybrid_alpha: float, - time_decay_multiplier: float, - num_to_retrieve: int, - offset: int = 0, - ) -> list[InferenceChunkUncleaned]: - """ - Run hybrid search and return a list of inference chunks. - - NOTE: the query passed in here is the unprocessed plain text query. Preprocessing is - expected to be handled by this function as it may depend on the index implementation. - Things like query expansion, synonym injection, stop word removal, lemmatization, etc. are - done here. - - Parameters: - - query: unmodified user query. This is needed for getting the matching highlighted - keywords - - query_embedding: vector representation of the query, must be of the correct - dimensionality for the primary index - - final_keywords: Final keywords to be used from the query, defaults to query if not set - - filters: standard filter object - - hybrid_alpha: weighting between the keyword and vector search results. It is important - that the two scores are normalized to the same range so that a meaningful - comparison can be made. 1 for 100% weighting on vector score, 0 for 100% weighting - on keyword score. - - time_decay_multiplier: how much to decay the document scores as they age. Some queries - based on the persona settings, will have this be a 2x or 3x of the default - - num_to_retrieve: number of highest matching chunks to return - - offset: number of highest matching chunks to skip (kind of like pagination) - - Returns: - best matching chunks based on weighted sum of keyword and vector/semantic search scores - """ - raise NotImplementedError - - -class AdminCapable(abc.ABC): - """ - Class must implement a search for the admin "Explorer" page. The assumption here is that the - admin is not "searching" for knowledge but has some document already in mind. They are either - looking to positively boost it because they know it's a good reference document, looking to - negatively boost it as a way of "deprecating", or hiding the document. - - Assuming the admin knows the document name, this search has high emphasis on the title match. - - Suggested implementation: - Keyword only, BM25 search with 5x weighting on the title field compared to the contents - """ - - @abc.abstractmethod - def admin_retrieval( - self, - query: str, - filters: IndexFilters, - num_to_retrieve: int, - offset: int = 0, - ) -> list[InferenceChunkUncleaned]: - """ - Run the special search for the admin document explorer page - - Parameters: - - query: unmodified user query. Though in this flow probably unmodified is best - - filters: standard filter object - - num_to_retrieve: number of highest matching chunks to return - - offset: number of highest matching chunks to skip (kind of like pagination) - - Returns: - list of best matching chunks for the explorer page query - """ - raise NotImplementedError - - -class BaseIndex( - Verifiable, - Indexable, - Updatable, - Deletable, - AdminCapable, - IdRetrievalCapable, - abc.ABC, -): - """ - All basic document index functionalities excluding the actual querying approach. - - As a summary, document indices need to be able to - - Verify the schema definition is valid - - Index new documents - - Update specific attributes of existing documents - - Delete documents - - Provide a search for the admin document explorer page - - Retrieve documents based on document id - """ - - -class DocumentIndex(HybridCapable, BaseIndex, abc.ABC): - """ - A valid document index that can plug into all Danswer flows must implement all of these - functionalities, though "technically" it does not need to be keyword or vector capable as - currently all default search flows use Hybrid Search. - """ diff --git a/backend/danswer/document_index/vespa/__init__.py b/backend/danswer/document_index/vespa/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/document_index/vespa/app_config/schemas/danswer_chunk.sd b/backend/danswer/document_index/vespa/app_config/schemas/danswer_chunk.sd deleted file mode 100644 index be279f6a611..00000000000 --- a/backend/danswer/document_index/vespa/app_config/schemas/danswer_chunk.sd +++ /dev/null @@ -1,218 +0,0 @@ -schema DANSWER_CHUNK_NAME { - document DANSWER_CHUNK_NAME { - # Not to be confused with the UUID generated for this chunk which is called documentid by default - field document_id type string { - indexing: summary | attribute - } - field chunk_id type int { - indexing: summary | attribute - } - # Displayed in the UI as the main identifier for the doc - field semantic_identifier type string { - indexing: summary | attribute - } - # Must have an additional field for whether to skip title embeddings - # This information cannot be extracted from either the title field nor title embedding - field skip_title type bool { - indexing: attribute - } - # May not always match the `semantic_identifier` e.g. for Slack docs the - # `semantic_identifier` will be the channel name, but the `title` will be empty - field title type string { - indexing: summary | index | attribute - index: enable-bm25 - } - field content type string { - indexing: summary | index - index: enable-bm25 - } - # duplication of `content` is far from ideal, but is needed for - # non-gram based highlighting for now. If the capability to re-use a - # single field to do both is added, `content_summary` should be removed - field content_summary type string { - indexing: summary | index - summary: dynamic - } - # Title embedding (x1) - field title_embedding type tensor(x[VARIABLE_DIM]) { - indexing: attribute - attribute { - distance-metric: angular - } - } - # Content embeddings (chunk + optional mini chunks embeddings) - # "t" and "x" are arbitrary names, not special keywords - field embeddings type tensor(t{},x[VARIABLE_DIM]) { - indexing: attribute - attribute { - distance-metric: angular - } - } - # Starting section of the doc, currently unused as it has been replaced by match highlighting - field blurb type string { - indexing: summary | attribute - } - # https://docs.vespa.ai/en/attributes.html potential enum store for speed, but probably not worth it - field source_type type string { - indexing: summary | attribute - rank: filter - attribute: fast-search - } - # Can also index links https://docs.vespa.ai/en/reference/schema-reference.html#attribute - # URL type matching - field source_links type string { - indexing: summary | attribute - } - field section_continuation type bool { - indexing: summary | attribute - } - # Technically this one should be int, but can't change without causing breaks to existing index - field boost type float { - indexing: summary | attribute - } - field hidden type bool { - indexing: summary | attribute - rank: filter - } - # Needs to have a separate Attribute list for efficient filtering - field metadata_list type array { - indexing: summary | attribute - rank:filter - attribute: fast-search - } - # If chunk is a large chunk, this will contain the ids of the smaller chunks - field large_chunk_reference_ids type array { - indexing: summary | attribute - } - field metadata type string { - indexing: summary | attribute - } - field metadata_suffix type string { - indexing: summary | attribute - } - field doc_updated_at type int { - indexing: summary | attribute - } - field primary_owners type array { - indexing : summary | attribute - } - field secondary_owners type array { - indexing : summary | attribute - } - field access_control_list type weightedset { - indexing: summary | attribute - rank: filter - attribute: fast-search - } - field document_sets type weightedset { - indexing: summary | attribute - rank: filter - attribute: fast-search - } - } - - # If using different tokenization settings, the fieldset has to be removed, and the field must - # be specified in the yql like: - # + 'or ({grammar: "weakAnd", defaultIndex:"title"}userInput(@query)) ' - # + 'or ({grammar: "weakAnd", defaultIndex:"content"}userInput(@query)) ' - # Note: for BM-25, the ngram size (and whether ngrams are used) changes the range of the scores - fieldset default { - fields: content, title - } - - rank-profile default_rank { - inputs { - query(decay_factor) float - } - - function inline document_boost() { - # 0.5 to 2x score: piecewise sigmoid function stretched out by factor of 3 - # meaning requires 3x the number of feedback votes to have default sigmoid effect - expression: if(attribute(boost) < 0, 0.5 + (1 / (1 + exp(-attribute(boost) / 3))), 2 / (1 + exp(-attribute(boost) / 3))) - } - - function inline document_age() { - # Time in years (91.3 days ~= 3 Months ~= 1 fiscal quarter if no age found) - expression: max(if(isNan(attribute(doc_updated_at)) == 1, 7890000, now() - attribute(doc_updated_at)) / 31536000, 0) - } - - # Document score decays from 1 to 0.75 as age of last updated time increases - function inline recency_bias() { - expression: max(1 / (1 + query(decay_factor) * document_age), 0.75) - } - - match-features: recency_bias - } - - rank-profile hybrid_searchVARIABLE_DIM inherits default, default_rank { - inputs { - query(query_embedding) tensor(x[VARIABLE_DIM]) - } - - function title_vector_score() { - expression { - # If no good matching titles, then it should use the context embeddings rather than having some - # irrelevant title have a vector score of 1. This way at least it will be the doc with the highest - # matching content score getting the full score - max(closeness(field, embeddings), closeness(field, title_embedding)) - } - } - - # First phase must be vector to allow hits that have no keyword matches - first-phase { - expression: closeness(field, embeddings) - } - - # Weighted average between Vector Search and BM-25 - global-phase { - expression { - ( - # Weighted Vector Similarity Score - ( - query(alpha) * ( - (query(title_content_ratio) * normalize_linear(title_vector_score)) - + - ((1 - query(title_content_ratio)) * normalize_linear(closeness(field, embeddings))) - ) - ) - - + - - # Weighted Keyword Similarity Score - # Note: for the BM25 Title score, it requires decent stopword removal in the query - # This needs to be the case so there aren't irrelevant titles being normalized to a score of 1 - ( - (1 - query(alpha)) * ( - (query(title_content_ratio) * normalize_linear(bm25(title))) - + - ((1 - query(title_content_ratio)) * normalize_linear(bm25(content))) - ) - ) - ) - # Boost based on user feedback - * document_boost - # Decay factor based on time document was last updated - * recency_bias - } - rerank-count: 1000 - } - - match-features { - bm25(title) - bm25(content) - closeness(field, title_embedding) - closeness(field, embeddings) - document_boost - recency_bias - closest(embeddings) - } - } - - # Used when searching from the admin UI for a specific doc to hide / boost - # Very heavily prioritize title - rank-profile admin_search inherits default, default_rank { - first-phase { - expression: bm25(content) + (5 * bm25(title)) - } - } -} diff --git a/backend/danswer/document_index/vespa/app_config/services.xml b/backend/danswer/document_index/vespa/app_config/services.xml deleted file mode 100644 index 01f2c191ac6..00000000000 --- a/backend/danswer/document_index/vespa/app_config/services.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - 1 - - - DOCUMENT_REPLACEMENT - - - - - - - - - 0.75 - - - - 3 - 750 - 350 - 300 - - - diff --git a/backend/danswer/document_index/vespa/app_config/validation-overrides.xml b/backend/danswer/document_index/vespa/app_config/validation-overrides.xml deleted file mode 100644 index d1ac1c119e5..00000000000 --- a/backend/danswer/document_index/vespa/app_config/validation-overrides.xml +++ /dev/null @@ -1,8 +0,0 @@ - - schema-removal - indexing-change - diff --git a/backend/danswer/document_index/vespa/chunk_retrieval.py b/backend/danswer/document_index/vespa/chunk_retrieval.py deleted file mode 100644 index 6a7427630b8..00000000000 --- a/backend/danswer/document_index/vespa/chunk_retrieval.py +++ /dev/null @@ -1,424 +0,0 @@ -import json -import string -from collections.abc import Callable -from collections.abc import Mapping -from datetime import datetime -from datetime import timezone -from typing import Any -from typing import cast - -import requests -from retry import retry - -from danswer.configs.app_configs import LOG_VESPA_TIMING_INFORMATION -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.document_index.vespa.shared_utils.vespa_request_builders import ( - build_vespa_filters, -) -from danswer.document_index.vespa.shared_utils.vespa_request_builders import ( - build_vespa_id_based_retrieval_yql, -) -from danswer.document_index.vespa_constants import ACCESS_CONTROL_LIST -from danswer.document_index.vespa_constants import BLURB -from danswer.document_index.vespa_constants import BOOST -from danswer.document_index.vespa_constants import CHUNK_ID -from danswer.document_index.vespa_constants import CONTENT -from danswer.document_index.vespa_constants import CONTENT_SUMMARY -from danswer.document_index.vespa_constants import DOC_UPDATED_AT -from danswer.document_index.vespa_constants import DOCUMENT_ID -from danswer.document_index.vespa_constants import DOCUMENT_ID_ENDPOINT -from danswer.document_index.vespa_constants import HIDDEN -from danswer.document_index.vespa_constants import LARGE_CHUNK_REFERENCE_IDS -from danswer.document_index.vespa_constants import MAX_ID_SEARCH_QUERY_SIZE -from danswer.document_index.vespa_constants import METADATA -from danswer.document_index.vespa_constants import METADATA_SUFFIX -from danswer.document_index.vespa_constants import PRIMARY_OWNERS -from danswer.document_index.vespa_constants import RECENCY_BIAS -from danswer.document_index.vespa_constants import SEARCH_ENDPOINT -from danswer.document_index.vespa_constants import SECONDARY_OWNERS -from danswer.document_index.vespa_constants import SECTION_CONTINUATION -from danswer.document_index.vespa_constants import SEMANTIC_IDENTIFIER -from danswer.document_index.vespa_constants import SOURCE_LINKS -from danswer.document_index.vespa_constants import SOURCE_TYPE -from danswer.document_index.vespa_constants import TITLE -from danswer.document_index.vespa_constants import YQL_BASE -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceChunkUncleaned -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - -logger = setup_logger() - - -def _process_dynamic_summary( - dynamic_summary: str, max_summary_length: int = 400 -) -> list[str]: - if not dynamic_summary: - return [] - - current_length = 0 - processed_summary: list[str] = [] - for summary_section in dynamic_summary.split(""): - # if we're past the desired max length, break at the last word - if current_length + len(summary_section) >= max_summary_length: - summary_section = summary_section[: max_summary_length - current_length] - summary_section = summary_section.lstrip() # remove any leading whitespace - - # handle the case where the truncated section is either just a - # single (partial) word or if it's empty - first_space = summary_section.find(" ") - if first_space == -1: - # add ``...`` to previous section - if processed_summary: - processed_summary[-1] += "..." - break - - # handle the valid truncated section case - summary_section = summary_section.rsplit(" ", 1)[0] - if summary_section[-1] in string.punctuation: - summary_section = summary_section[:-1] - summary_section += "..." - processed_summary.append(summary_section) - break - - processed_summary.append(summary_section) - current_length += len(summary_section) - - return processed_summary - - -def _vespa_hit_to_inference_chunk( - hit: dict[str, Any], null_score: bool = False -) -> InferenceChunkUncleaned: - fields = cast(dict[str, Any], hit["fields"]) - - # parse fields that are stored as strings, but are really json / datetime - metadata = json.loads(fields[METADATA]) if METADATA in fields else {} - updated_at = ( - datetime.fromtimestamp(fields[DOC_UPDATED_AT], tz=timezone.utc) - if DOC_UPDATED_AT in fields - else None - ) - - match_highlights = _process_dynamic_summary( - # fallback to regular `content` if the `content_summary` field - # isn't present - dynamic_summary=hit["fields"].get(CONTENT_SUMMARY, hit["fields"][CONTENT]), - ) - semantic_identifier = fields.get(SEMANTIC_IDENTIFIER, "") - if not semantic_identifier: - logger.error( - f"Chunk with blurb: {fields.get(BLURB, 'Unknown')[:50]}... has no Semantic Identifier" - ) - - source_links = fields.get(SOURCE_LINKS, {}) - source_links_dict_unprocessed = ( - json.loads(source_links) if isinstance(source_links, str) else source_links - ) - source_links_dict = { - int(k): v - for k, v in cast(dict[str, str], source_links_dict_unprocessed).items() - } - - return InferenceChunkUncleaned( - chunk_id=fields[CHUNK_ID], - blurb=fields.get(BLURB, ""), # Unused - content=fields[CONTENT], # Includes extra title prefix and metadata suffix - source_links=source_links_dict or {0: ""}, - section_continuation=fields[SECTION_CONTINUATION], - document_id=fields[DOCUMENT_ID], - source_type=fields[SOURCE_TYPE], - title=fields.get(TITLE), - semantic_identifier=fields[SEMANTIC_IDENTIFIER], - boost=fields.get(BOOST, 1), - recency_bias=fields.get("matchfeatures", {}).get(RECENCY_BIAS, 1.0), - score=None if null_score else hit.get("relevance", 0), - hidden=fields.get(HIDDEN, False), - primary_owners=fields.get(PRIMARY_OWNERS), - secondary_owners=fields.get(SECONDARY_OWNERS), - large_chunk_reference_ids=fields.get(LARGE_CHUNK_REFERENCE_IDS, []), - metadata=metadata, - metadata_suffix=fields.get(METADATA_SUFFIX), - match_highlights=match_highlights, - updated_at=updated_at, - ) - - -def _get_chunks_via_visit_api( - chunk_request: VespaChunkRequest, - index_name: str, - filters: IndexFilters, - field_names: list[str] | None = None, - get_large_chunks: bool = False, -) -> list[dict]: - # Constructing the URL for the Visit API - # NOTE: visit API uses the same URL as the document API, but with different params - url = DOCUMENT_ID_ENDPOINT.format(index_name=index_name) - - # build the list of fields to retrieve - field_set_list = ( - None - if not field_names - else [f"{index_name}:{field_name}" for field_name in field_names] - ) - acl_fieldset_entry = f"{index_name}:{ACCESS_CONTROL_LIST}" - if ( - field_set_list - and filters.access_control_list - and acl_fieldset_entry not in field_set_list - ): - field_set_list.append(acl_fieldset_entry) - field_set = ",".join(field_set_list) if field_set_list else None - - # build filters - selection = f"{index_name}.document_id=='{chunk_request.document_id}'" - - if chunk_request.is_capped: - selection += f" and {index_name}.chunk_id>={chunk_request.min_chunk_ind or 0}" - selection += f" and {index_name}.chunk_id<={chunk_request.max_chunk_ind}" - if not get_large_chunks: - selection += f" and {index_name}.large_chunk_reference_ids == null" - - # Setting up the selection criteria in the query parameters - params = { - # NOTE: Document Selector Language doesn't allow `contains`, so we can't check - # for the ACL in the selection. Instead, we have to check as a postfilter - "selection": selection, - "continuation": None, - "wantedDocumentCount": 1_000, - "fieldSet": field_set, - } - - document_chunks: list[dict] = [] - while True: - response = requests.get(url, params=params) - try: - response.raise_for_status() - except requests.HTTPError as e: - request_info = f"Headers: {response.request.headers}\nPayload: {params}" - response_info = f"Status Code: {response.status_code}\nResponse Content: {response.text}" - error_base = f"Error occurred getting chunk by Document ID {chunk_request.document_id}" - logger.error( - f"{error_base}:\n" - f"{request_info}\n" - f"{response_info}\n" - f"Exception: {e}" - ) - raise requests.HTTPError(error_base) from e - - # Check if the response contains any documents - response_data = response.json() - if "documents" in response_data: - for document in response_data["documents"]: - if filters.access_control_list: - document_acl = document["fields"].get(ACCESS_CONTROL_LIST) - if not document_acl or not any( - user_acl_entry in document_acl - for user_acl_entry in filters.access_control_list - ): - continue - document_chunks.append(document) - - # Check for continuation token to handle pagination - if "continuation" in response_data and response_data["continuation"]: - params["continuation"] = response_data["continuation"] - else: - break # Exit loop if no continuation token - - return document_chunks - - -def get_all_vespa_ids_for_document_id( - document_id: str, - index_name: str, - filters: IndexFilters | None = None, - get_large_chunks: bool = False, -) -> list[str]: - document_chunks = _get_chunks_via_visit_api( - chunk_request=VespaChunkRequest(document_id=document_id), - index_name=index_name, - filters=filters or IndexFilters(access_control_list=None), - field_names=[DOCUMENT_ID], - get_large_chunks=get_large_chunks, - ) - return [chunk["id"].split("::", 1)[-1] for chunk in document_chunks] - - -def parallel_visit_api_retrieval( - index_name: str, - chunk_requests: list[VespaChunkRequest], - filters: IndexFilters, - get_large_chunks: bool = False, -) -> list[InferenceChunkUncleaned]: - functions_with_args: list[tuple[Callable, tuple]] = [ - ( - _get_chunks_via_visit_api, - (chunk_request, index_name, filters, get_large_chunks), - ) - for chunk_request in chunk_requests - ] - - parallel_results = run_functions_tuples_in_parallel( - functions_with_args, allow_failures=True - ) - - # Any failures to retrieve would give a None, drop the Nones and empty lists - vespa_chunk_sets = [res for res in parallel_results if res] - - flattened_vespa_chunks = [] - for chunk_set in vespa_chunk_sets: - flattened_vespa_chunks.extend(chunk_set) - - inference_chunks = [ - _vespa_hit_to_inference_chunk(chunk, null_score=True) - for chunk in flattened_vespa_chunks - ] - - return inference_chunks - - -@retry(tries=3, delay=1, backoff=2) -def query_vespa( - query_params: Mapping[str, str | int | float] -) -> list[InferenceChunkUncleaned]: - if "query" in query_params and not cast(str, query_params["query"]).strip(): - raise ValueError("No/empty query received") - - params = dict( - **query_params, - **{ - "presentation.timing": True, - } - if LOG_VESPA_TIMING_INFORMATION - else {}, - ) - - response = requests.post( - SEARCH_ENDPOINT, - json=params, - ) - try: - response.raise_for_status() - except requests.HTTPError as e: - request_info = f"Headers: {response.request.headers}\nPayload: {params}" - response_info = ( - f"Status Code: {response.status_code}\n" - f"Response Content: {response.text}" - ) - error_base = "Failed to query Vespa" - logger.error( - f"{error_base}:\n" - f"{request_info}\n" - f"{response_info}\n" - f"Exception: {e}" - ) - raise requests.HTTPError(error_base) from e - - response_json: dict[str, Any] = response.json() - if LOG_VESPA_TIMING_INFORMATION: - logger.debug("Vespa timing info: %s", response_json.get("timing")) - hits = response_json["root"].get("children", []) - - for hit in hits: - if hit["fields"].get(CONTENT) is None: - identifier = hit["fields"].get("documentid") or hit["id"] - logger.error( - f"Vespa Index with Vespa ID {identifier} has no contents. " - f"This is invalid because the vector is not meaningful and keywordsearch cannot " - f"fetch this document" - ) - - filtered_hits = [hit for hit in hits if hit["fields"].get(CONTENT) is not None] - - inference_chunks = [_vespa_hit_to_inference_chunk(hit) for hit in filtered_hits] - # Good Debugging Spot - return inference_chunks - - -def _get_chunks_via_batch_search( - index_name: str, - chunk_requests: list[VespaChunkRequest], - filters: IndexFilters, - get_large_chunks: bool = False, -) -> list[InferenceChunkUncleaned]: - if not chunk_requests: - return [] - - filters_str = build_vespa_filters(filters=filters, include_hidden=True) - - yql = ( - YQL_BASE.format(index_name=index_name) - + filters_str - + build_vespa_id_based_retrieval_yql(chunk_requests[0]) - ) - chunk_requests.pop(0) - - for request in chunk_requests: - yql += " or " + build_vespa_id_based_retrieval_yql(request) - params: dict[str, str | int | float] = { - "yql": yql, - "hits": MAX_ID_SEARCH_QUERY_SIZE, - } - - inference_chunks = query_vespa(params) - if not get_large_chunks: - inference_chunks = [ - chunk for chunk in inference_chunks if not chunk.large_chunk_reference_ids - ] - inference_chunks.sort(key=lambda chunk: chunk.chunk_id) - return inference_chunks - - -def batch_search_api_retrieval( - index_name: str, - chunk_requests: list[VespaChunkRequest], - filters: IndexFilters, - get_large_chunks: bool = False, -) -> list[InferenceChunkUncleaned]: - retrieved_chunks: list[InferenceChunkUncleaned] = [] - capped_requests: list[VespaChunkRequest] = [] - uncapped_requests: list[VespaChunkRequest] = [] - chunk_count = 0 - for request in chunk_requests: - # All requests without a chunk range are uncapped - # Uncapped requests are retrieved using the Visit API - range = request.range - if range is None: - uncapped_requests.append(request) - continue - - # If adding the range to the chunk count is greater than the - # max query size, we need to perform a retrieval to avoid hitting the limit - if chunk_count + range > MAX_ID_SEARCH_QUERY_SIZE: - retrieved_chunks.extend( - _get_chunks_via_batch_search( - index_name=index_name, - chunk_requests=capped_requests, - filters=filters, - get_large_chunks=get_large_chunks, - ) - ) - capped_requests = [] - chunk_count = 0 - capped_requests.append(request) - chunk_count += range - - if capped_requests: - retrieved_chunks.extend( - _get_chunks_via_batch_search( - index_name=index_name, - chunk_requests=capped_requests, - filters=filters, - get_large_chunks=get_large_chunks, - ) - ) - - if uncapped_requests: - logger.debug(f"Retrieving {len(uncapped_requests)} uncapped requests") - retrieved_chunks.extend( - parallel_visit_api_retrieval( - index_name, uncapped_requests, filters, get_large_chunks - ) - ) - - return retrieved_chunks diff --git a/backend/danswer/document_index/vespa/deletion.py b/backend/danswer/document_index/vespa/deletion.py deleted file mode 100644 index 3c8b7b97f15..00000000000 --- a/backend/danswer/document_index/vespa/deletion.py +++ /dev/null @@ -1,65 +0,0 @@ -import concurrent.futures - -import httpx -from retry import retry - -from danswer.document_index.vespa.chunk_retrieval import ( - get_all_vespa_ids_for_document_id, -) -from danswer.document_index.vespa_constants import DOCUMENT_ID_ENDPOINT -from danswer.document_index.vespa_constants import NUM_THREADS -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -CONTENT_SUMMARY = "content_summary" - - -@retry(tries=3, delay=1, backoff=2) -def _delete_vespa_doc_chunks( - document_id: str, index_name: str, http_client: httpx.Client -) -> None: - doc_chunk_ids = get_all_vespa_ids_for_document_id( - document_id=document_id, - index_name=index_name, - get_large_chunks=True, - ) - - for chunk_id in doc_chunk_ids: - try: - res = http_client.delete( - f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{chunk_id}" - ) - res.raise_for_status() - except httpx.HTTPStatusError as e: - logger.error(f"Failed to delete chunk, details: {e.response.text}") - raise - - -def delete_vespa_docs( - document_ids: list[str], - index_name: str, - http_client: httpx.Client, - executor: concurrent.futures.ThreadPoolExecutor | None = None, -) -> None: - external_executor = True - - if not executor: - external_executor = False - executor = concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) - - try: - doc_deletion_future = { - executor.submit( - _delete_vespa_doc_chunks, doc_id, index_name, http_client - ): doc_id - for doc_id in document_ids - } - for future in concurrent.futures.as_completed(doc_deletion_future): - # Will raise exception if the deletion raised an exception - future.result() - - finally: - if not external_executor: - executor.shutdown(wait=True) diff --git a/backend/danswer/document_index/vespa/index.py b/backend/danswer/document_index/vespa/index.py deleted file mode 100644 index d07da5b06bb..00000000000 --- a/backend/danswer/document_index/vespa/index.py +++ /dev/null @@ -1,484 +0,0 @@ -import concurrent.futures -import io -import os -import re -import time -import zipfile -from dataclasses import dataclass -from datetime import datetime -from datetime import timedelta -from typing import BinaryIO -from typing import cast - -import httpx -import requests - -from danswer.configs.chat_configs import DOC_TIME_DECAY -from danswer.configs.chat_configs import NUM_RETURNED_HITS -from danswer.configs.chat_configs import TITLE_CONTENT_RATIO -from danswer.configs.constants import KV_REINDEX_KEY -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import DocumentInsertionRecord -from danswer.document_index.interfaces import UpdateRequest -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.document_index.vespa.chunk_retrieval import batch_search_api_retrieval -from danswer.document_index.vespa.chunk_retrieval import ( - get_all_vespa_ids_for_document_id, -) -from danswer.document_index.vespa.chunk_retrieval import ( - parallel_visit_api_retrieval, -) -from danswer.document_index.vespa.chunk_retrieval import query_vespa -from danswer.document_index.vespa.deletion import delete_vespa_docs -from danswer.document_index.vespa.indexing_utils import batch_index_vespa_chunks -from danswer.document_index.vespa.indexing_utils import clean_chunk_id_copy -from danswer.document_index.vespa.indexing_utils import ( - get_existing_documents_from_chunks, -) -from danswer.document_index.vespa.shared_utils.utils import ( - replace_invalid_doc_id_characters, -) -from danswer.document_index.vespa.shared_utils.vespa_request_builders import ( - build_vespa_filters, -) -from danswer.document_index.vespa_constants import ACCESS_CONTROL_LIST -from danswer.document_index.vespa_constants import BATCH_SIZE -from danswer.document_index.vespa_constants import BOOST -from danswer.document_index.vespa_constants import CONTENT_SUMMARY -from danswer.document_index.vespa_constants import DANSWER_CHUNK_REPLACEMENT_PAT -from danswer.document_index.vespa_constants import DATE_REPLACEMENT -from danswer.document_index.vespa_constants import DOCUMENT_ID_ENDPOINT -from danswer.document_index.vespa_constants import DOCUMENT_REPLACEMENT_PAT -from danswer.document_index.vespa_constants import DOCUMENT_SETS -from danswer.document_index.vespa_constants import HIDDEN -from danswer.document_index.vespa_constants import NUM_THREADS -from danswer.document_index.vespa_constants import VESPA_APPLICATION_ENDPOINT -from danswer.document_index.vespa_constants import VESPA_DIM_REPLACEMENT_PAT -from danswer.document_index.vespa_constants import VESPA_TIMEOUT -from danswer.document_index.vespa_constants import YQL_BASE -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.indexing.models import DocMetadataAwareIndexChunk -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceChunkUncleaned -from danswer.utils.batching import batch_generator -from danswer.utils.logger import setup_logger -from shared_configs.model_server_models import Embedding - -logger = setup_logger() - - -@dataclass -class _VespaUpdateRequest: - document_id: str - url: str - update_request: dict[str, dict] - - -def in_memory_zip_from_file_bytes(file_contents: dict[str, bytes]) -> BinaryIO: - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf: - for filename, content in file_contents.items(): - zipf.writestr(filename, content) - zip_buffer.seek(0) - return zip_buffer - - -def _create_document_xml_lines(doc_names: list[str | None]) -> str: - doc_lines = [ - f'' - for doc_name in doc_names - if doc_name - ] - return "\n".join(doc_lines) - - -def add_ngrams_to_schema(schema_content: str) -> str: - # Add the match blocks containing gram and gram-size to title and content fields - schema_content = re.sub( - r"(field title type string \{[^}]*indexing: summary \| index \| attribute)", - r"\1\n match {\n gram\n gram-size: 3\n }", - schema_content, - ) - schema_content = re.sub( - r"(field content type string \{[^}]*indexing: summary \| index)", - r"\1\n match {\n gram\n gram-size: 3\n }", - schema_content, - ) - return schema_content - - -class VespaIndex(DocumentIndex): - def __init__(self, index_name: str, secondary_index_name: str | None) -> None: - self.index_name = index_name - self.secondary_index_name = secondary_index_name - - def ensure_indices_exist( - self, - index_embedding_dim: int, - secondary_index_embedding_dim: int | None, - ) -> None: - deploy_url = f"{VESPA_APPLICATION_ENDPOINT}/tenant/default/prepareandactivate" - logger.debug(f"Sending Vespa zip to {deploy_url}") - - vespa_schema_path = os.path.join( - os.getcwd(), "danswer", "document_index", "vespa", "app_config" - ) - schema_file = os.path.join(vespa_schema_path, "schemas", "danswer_chunk.sd") - services_file = os.path.join(vespa_schema_path, "services.xml") - overrides_file = os.path.join(vespa_schema_path, "validation-overrides.xml") - - with open(services_file, "r") as services_f: - services_template = services_f.read() - - schema_names = [self.index_name, self.secondary_index_name] - - doc_lines = _create_document_xml_lines(schema_names) - services = services_template.replace(DOCUMENT_REPLACEMENT_PAT, doc_lines) - kv_store = get_dynamic_config_store() - - needs_reindexing = False - try: - needs_reindexing = cast(bool, kv_store.load(KV_REINDEX_KEY)) - except Exception: - logger.debug("Could not load the reindexing flag. Using ngrams") - - with open(overrides_file, "r") as overrides_f: - overrides_template = overrides_f.read() - - # Vespa requires an override to erase data including the indices we're no longer using - # It also has a 30 day cap from current so we set it to 7 dynamically - now = datetime.now() - date_in_7_days = now + timedelta(days=7) - formatted_date = date_in_7_days.strftime("%Y-%m-%d") - - overrides = overrides_template.replace(DATE_REPLACEMENT, formatted_date) - - zip_dict = { - "services.xml": services.encode("utf-8"), - "validation-overrides.xml": overrides.encode("utf-8"), - } - - with open(schema_file, "r") as schema_f: - schema_template = schema_f.read() - schema = schema_template.replace( - DANSWER_CHUNK_REPLACEMENT_PAT, self.index_name - ).replace(VESPA_DIM_REPLACEMENT_PAT, str(index_embedding_dim)) - schema = add_ngrams_to_schema(schema) if needs_reindexing else schema - zip_dict[f"schemas/{schema_names[0]}.sd"] = schema.encode("utf-8") - - if self.secondary_index_name: - upcoming_schema = schema_template.replace( - DANSWER_CHUNK_REPLACEMENT_PAT, self.secondary_index_name - ).replace(VESPA_DIM_REPLACEMENT_PAT, str(secondary_index_embedding_dim)) - zip_dict[f"schemas/{schema_names[1]}.sd"] = upcoming_schema.encode("utf-8") - - zip_file = in_memory_zip_from_file_bytes(zip_dict) - - headers = {"Content-Type": "application/zip"} - response = requests.post(deploy_url, headers=headers, data=zip_file) - if response.status_code != 200: - raise RuntimeError( - f"Failed to prepare Vespa Danswer Index. Response: {response.text}" - ) - - def index( - self, - chunks: list[DocMetadataAwareIndexChunk], - ) -> set[DocumentInsertionRecord]: - """Receive a list of chunks from a batch of documents and index the chunks into Vespa along - with updating the associated permissions. Assumes that a document will not be split into - multiple chunk batches calling this function multiple times, otherwise only the last set of - chunks will be kept""" - # IMPORTANT: This must be done one index at a time, do not use secondary index here - cleaned_chunks = [clean_chunk_id_copy(chunk) for chunk in chunks] - - existing_docs: set[str] = set() - - # NOTE: using `httpx` here since `requests` doesn't support HTTP2. This is beneficial for - # indexing / updates / deletes since we have to make a large volume of requests. - with ( - concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) as executor, - httpx.Client(http2=True) as http_client, - ): - # Check for existing documents, existing documents need to have all of their chunks deleted - # prior to indexing as the document size (num chunks) may have shrunk - first_chunks = [chunk for chunk in cleaned_chunks if chunk.chunk_id == 0] - for chunk_batch in batch_generator(first_chunks, BATCH_SIZE): - existing_docs.update( - get_existing_documents_from_chunks( - chunks=chunk_batch, - index_name=self.index_name, - http_client=http_client, - executor=executor, - ) - ) - - for doc_id_batch in batch_generator(existing_docs, BATCH_SIZE): - delete_vespa_docs( - document_ids=doc_id_batch, - index_name=self.index_name, - http_client=http_client, - executor=executor, - ) - - for chunk_batch in batch_generator(cleaned_chunks, BATCH_SIZE): - batch_index_vespa_chunks( - chunks=chunk_batch, - index_name=self.index_name, - http_client=http_client, - executor=executor, - ) - - all_doc_ids = {chunk.source_document.id for chunk in cleaned_chunks} - - return { - DocumentInsertionRecord( - document_id=doc_id, - already_existed=doc_id in existing_docs, - ) - for doc_id in all_doc_ids - } - - @staticmethod - def _apply_updates_batched( - updates: list[_VespaUpdateRequest], - batch_size: int = BATCH_SIZE, - ) -> None: - """Runs a batch of updates in parallel via the ThreadPoolExecutor.""" - - def _update_chunk( - update: _VespaUpdateRequest, http_client: httpx.Client - ) -> httpx.Response: - logger.debug( - f"Updating with request to {update.url} with body {update.update_request}" - ) - return http_client.put( - update.url, - headers={"Content-Type": "application/json"}, - json=update.update_request, - ) - - # NOTE: using `httpx` here since `requests` doesn't support HTTP2. This is beneficient for - # indexing / updates / deletes since we have to make a large volume of requests. - with ( - concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) as executor, - httpx.Client(http2=True) as http_client, - ): - for update_batch in batch_generator(updates, batch_size): - future_to_document_id = { - executor.submit( - _update_chunk, - update, - http_client, - ): update.document_id - for update in update_batch - } - for future in concurrent.futures.as_completed(future_to_document_id): - res = future.result() - try: - res.raise_for_status() - except requests.HTTPError as e: - failure_msg = f"Failed to update document: {future_to_document_id[future]}" - raise requests.HTTPError(failure_msg) from e - - def update(self, update_requests: list[UpdateRequest]) -> None: - logger.info(f"Updating {len(update_requests)} documents in Vespa") - - # Handle Vespa character limitations - # Mutating update_requests but it's not used later anyway - for update_request in update_requests: - update_request.document_ids = [ - replace_invalid_doc_id_characters(doc_id) - for doc_id in update_request.document_ids - ] - - update_start = time.monotonic() - - processed_updates_requests: list[_VespaUpdateRequest] = [] - all_doc_chunk_ids: dict[str, list[str]] = {} - - # Fetch all chunks for each document ahead of time - index_names = [self.index_name] - if self.secondary_index_name: - index_names.append(self.secondary_index_name) - - chunk_id_start_time = time.monotonic() - with concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) as executor: - future_to_doc_chunk_ids = { - executor.submit( - get_all_vespa_ids_for_document_id, - document_id=document_id, - index_name=index_name, - filters=None, - get_large_chunks=True, - ): (document_id, index_name) - for index_name in index_names - for update_request in update_requests - for document_id in update_request.document_ids - } - for future in concurrent.futures.as_completed(future_to_doc_chunk_ids): - document_id, index_name = future_to_doc_chunk_ids[future] - try: - doc_chunk_ids = future.result() - if document_id not in all_doc_chunk_ids: - all_doc_chunk_ids[document_id] = [] - all_doc_chunk_ids[document_id].extend(doc_chunk_ids) - except Exception as e: - logger.error( - f"Error retrieving chunk IDs for document {document_id} in index {index_name}: {e}" - ) - logger.debug( - f"Took {time.monotonic() - chunk_id_start_time:.2f} seconds to fetch all Vespa chunk IDs" - ) - - # Build the _VespaUpdateRequest objects - for update_request in update_requests: - update_dict: dict[str, dict] = {"fields": {}} - if update_request.boost is not None: - update_dict["fields"][BOOST] = {"assign": update_request.boost} - if update_request.document_sets is not None: - update_dict["fields"][DOCUMENT_SETS] = { - "assign": { - document_set: 1 for document_set in update_request.document_sets - } - } - if update_request.access is not None: - update_dict["fields"][ACCESS_CONTROL_LIST] = { - "assign": { - acl_entry: 1 for acl_entry in update_request.access.to_acl() - } - } - if update_request.hidden is not None: - update_dict["fields"][HIDDEN] = {"assign": update_request.hidden} - - if not update_dict["fields"]: - logger.error("Update request received but nothing to update") - continue - - for document_id in update_request.document_ids: - for doc_chunk_id in all_doc_chunk_ids[document_id]: - processed_updates_requests.append( - _VespaUpdateRequest( - document_id=document_id, - url=f"{DOCUMENT_ID_ENDPOINT.format(index_name=self.index_name)}/{doc_chunk_id}", - update_request=update_dict, - ) - ) - - self._apply_updates_batched(processed_updates_requests) - logger.debug( - "Finished updating Vespa documents in %.2f seconds", - time.monotonic() - update_start, - ) - - def delete(self, doc_ids: list[str]) -> None: - logger.info(f"Deleting {len(doc_ids)} documents from Vespa") - - doc_ids = [replace_invalid_doc_id_characters(doc_id) for doc_id in doc_ids] - - # NOTE: using `httpx` here since `requests` doesn't support HTTP2. This is beneficial for - # indexing / updates / deletes since we have to make a large volume of requests. - with httpx.Client(http2=True) as http_client: - index_names = [self.index_name] - if self.secondary_index_name: - index_names.append(self.secondary_index_name) - - for index_name in index_names: - delete_vespa_docs( - document_ids=doc_ids, index_name=index_name, http_client=http_client - ) - - def id_based_retrieval( - self, - chunk_requests: list[VespaChunkRequest], - filters: IndexFilters, - batch_retrieval: bool = False, - get_large_chunks: bool = False, - ) -> list[InferenceChunkUncleaned]: - if batch_retrieval: - return batch_search_api_retrieval( - index_name=self.index_name, - chunk_requests=chunk_requests, - filters=filters, - get_large_chunks=get_large_chunks, - ) - return parallel_visit_api_retrieval( - index_name=self.index_name, - chunk_requests=chunk_requests, - filters=filters, - get_large_chunks=get_large_chunks, - ) - - def hybrid_retrieval( - self, - query: str, - query_embedding: Embedding, - final_keywords: list[str] | None, - filters: IndexFilters, - hybrid_alpha: float, - time_decay_multiplier: float, - num_to_retrieve: int, - offset: int = 0, - title_content_ratio: float | None = TITLE_CONTENT_RATIO, - ) -> list[InferenceChunkUncleaned]: - vespa_where_clauses = build_vespa_filters(filters) - # Needs to be at least as much as the value set in Vespa schema config - target_hits = max(10 * num_to_retrieve, 1000) - yql = ( - YQL_BASE.format(index_name=self.index_name) - + vespa_where_clauses - + f"(({{targetHits: {target_hits}}}nearestNeighbor(embeddings, query_embedding)) " - + f"or ({{targetHits: {target_hits}}}nearestNeighbor(title_embedding, query_embedding)) " - + 'or ({grammar: "weakAnd"}userInput(@query)) ' - + f'or ({{defaultIndex: "{CONTENT_SUMMARY}"}}userInput(@query)))' - ) - - final_query = " ".join(final_keywords) if final_keywords else query - - logger.debug(f"Query YQL: {yql}") - - params: dict[str, str | int | float] = { - "yql": yql, - "query": final_query, - "input.query(query_embedding)": str(query_embedding), - "input.query(decay_factor)": str(DOC_TIME_DECAY * time_decay_multiplier), - "input.query(alpha)": hybrid_alpha, - "input.query(title_content_ratio)": title_content_ratio - if title_content_ratio is not None - else TITLE_CONTENT_RATIO, - "hits": num_to_retrieve, - "offset": offset, - "ranking.profile": f"hybrid_search{len(query_embedding)}", - "timeout": VESPA_TIMEOUT, - } - - return query_vespa(params) - - def admin_retrieval( - self, - query: str, - filters: IndexFilters, - num_to_retrieve: int = NUM_RETURNED_HITS, - offset: int = 0, - ) -> list[InferenceChunkUncleaned]: - vespa_where_clauses = build_vespa_filters(filters, include_hidden=True) - yql = ( - YQL_BASE.format(index_name=self.index_name) - + vespa_where_clauses - + '({grammar: "weakAnd"}userInput(@query) ' - # `({defaultIndex: "content_summary"}userInput(@query))` section is - # needed for highlighting while the N-gram highlighting is broken / - # not working as desired - + f'or ({{defaultIndex: "{CONTENT_SUMMARY}"}}userInput(@query)))' - ) - - params: dict[str, str | int] = { - "yql": yql, - "query": query, - "hits": num_to_retrieve, - "offset": 0, - "ranking.profile": "admin_search", - "timeout": VESPA_TIMEOUT, - } - - return query_vespa(params) diff --git a/backend/danswer/document_index/vespa/indexing_utils.py b/backend/danswer/document_index/vespa/indexing_utils.py deleted file mode 100644 index 1b16cfc4947..00000000000 --- a/backend/danswer/document_index/vespa/indexing_utils.py +++ /dev/null @@ -1,227 +0,0 @@ -import concurrent.futures -import json -from datetime import datetime -from datetime import timezone - -import httpx -from retry import retry - -from danswer.connectors.cross_connector_utils.miscellaneous_utils import ( - get_experts_stores_representations, -) -from danswer.document_index.document_index_utils import get_uuid_from_chunk -from danswer.document_index.vespa.shared_utils.utils import remove_invalid_unicode_chars -from danswer.document_index.vespa.shared_utils.utils import ( - replace_invalid_doc_id_characters, -) -from danswer.document_index.vespa_constants import ACCESS_CONTROL_LIST -from danswer.document_index.vespa_constants import BLURB -from danswer.document_index.vespa_constants import BOOST -from danswer.document_index.vespa_constants import CHUNK_ID -from danswer.document_index.vespa_constants import CONTENT -from danswer.document_index.vespa_constants import CONTENT_SUMMARY -from danswer.document_index.vespa_constants import DOC_UPDATED_AT -from danswer.document_index.vespa_constants import DOCUMENT_ID -from danswer.document_index.vespa_constants import DOCUMENT_ID_ENDPOINT -from danswer.document_index.vespa_constants import DOCUMENT_SETS -from danswer.document_index.vespa_constants import EMBEDDINGS -from danswer.document_index.vespa_constants import LARGE_CHUNK_REFERENCE_IDS -from danswer.document_index.vespa_constants import METADATA -from danswer.document_index.vespa_constants import METADATA_LIST -from danswer.document_index.vespa_constants import METADATA_SUFFIX -from danswer.document_index.vespa_constants import NUM_THREADS -from danswer.document_index.vespa_constants import PRIMARY_OWNERS -from danswer.document_index.vespa_constants import SECONDARY_OWNERS -from danswer.document_index.vespa_constants import SECTION_CONTINUATION -from danswer.document_index.vespa_constants import SEMANTIC_IDENTIFIER -from danswer.document_index.vespa_constants import SKIP_TITLE_EMBEDDING -from danswer.document_index.vespa_constants import SOURCE_LINKS -from danswer.document_index.vespa_constants import SOURCE_TYPE -from danswer.document_index.vespa_constants import TITLE -from danswer.document_index.vespa_constants import TITLE_EMBEDDING -from danswer.indexing.models import DocMetadataAwareIndexChunk -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -@retry(tries=3, delay=1, backoff=2) -def _does_document_exist( - doc_chunk_id: str, - index_name: str, - http_client: httpx.Client, -) -> bool: - """Returns whether the document already exists and the users/group whitelists - Specifically in this case, document refers to a vespa document which is equivalent to a Danswer - chunk. This checks for whether the chunk exists already in the index""" - doc_url = f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{doc_chunk_id}" - doc_fetch_response = http_client.get(doc_url) - - if doc_fetch_response.status_code == 404: - return False - - if doc_fetch_response.status_code != 200: - logger.debug(f"Failed to check for document with URL {doc_url}") - raise RuntimeError( - f"Unexpected fetch document by ID value from Vespa " - f"with error {doc_fetch_response.status_code}" - ) - return True - - -def _vespa_get_updated_at_attribute(t: datetime | None) -> int | None: - if not t: - return None - - if t.tzinfo != timezone.utc: - raise ValueError("Connectors must provide document update time in UTC") - - return int(t.timestamp()) - - -def get_existing_documents_from_chunks( - chunks: list[DocMetadataAwareIndexChunk], - index_name: str, - http_client: httpx.Client, - executor: concurrent.futures.ThreadPoolExecutor | None = None, -) -> set[str]: - external_executor = True - - if not executor: - external_executor = False - executor = concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) - - document_ids: set[str] = set() - try: - chunk_existence_future = { - executor.submit( - _does_document_exist, - str(get_uuid_from_chunk(chunk)), - index_name, - http_client, - ): chunk - for chunk in chunks - } - for future in concurrent.futures.as_completed(chunk_existence_future): - chunk = chunk_existence_future[future] - chunk_already_existed = future.result() - if chunk_already_existed: - document_ids.add(chunk.source_document.id) - - finally: - if not external_executor: - executor.shutdown(wait=True) - - return document_ids - - -@retry(tries=3, delay=1, backoff=2) -def _index_vespa_chunk( - chunk: DocMetadataAwareIndexChunk, index_name: str, http_client: httpx.Client -) -> None: - json_header = { - "Content-Type": "application/json", - } - document = chunk.source_document - - # No minichunk documents in vespa, minichunk vectors are stored in the chunk itself - vespa_chunk_id = str(get_uuid_from_chunk(chunk)) - embeddings = chunk.embeddings - - embeddings_name_vector_map = {"full_chunk": embeddings.full_embedding} - - if embeddings.mini_chunk_embeddings: - for ind, m_c_embed in enumerate(embeddings.mini_chunk_embeddings): - embeddings_name_vector_map[f"mini_chunk_{ind}"] = m_c_embed - - title = document.get_title_for_document_index() - - vespa_document_fields = { - DOCUMENT_ID: document.id, - CHUNK_ID: chunk.chunk_id, - BLURB: remove_invalid_unicode_chars(chunk.blurb), - TITLE: remove_invalid_unicode_chars(title) if title else None, - SKIP_TITLE_EMBEDDING: not title, - # For the BM25 index, the keyword suffix is used, the vector is already generated with the more - # natural language representation of the metadata section - CONTENT: remove_invalid_unicode_chars( - f"{chunk.title_prefix}{chunk.content}{chunk.metadata_suffix_keyword}" - ), - # This duplication of `content` is needed for keyword highlighting - # Note that it's not exactly the same as the actual content - # which contains the title prefix and metadata suffix - CONTENT_SUMMARY: remove_invalid_unicode_chars(chunk.content), - SOURCE_TYPE: str(document.source.value), - SOURCE_LINKS: json.dumps(chunk.source_links), - SEMANTIC_IDENTIFIER: remove_invalid_unicode_chars(document.semantic_identifier), - SECTION_CONTINUATION: chunk.section_continuation, - LARGE_CHUNK_REFERENCE_IDS: chunk.large_chunk_reference_ids, - METADATA: json.dumps(document.metadata), - # Save as a list for efficient extraction as an Attribute - METADATA_LIST: chunk.source_document.get_metadata_str_attributes(), - METADATA_SUFFIX: chunk.metadata_suffix_keyword, - EMBEDDINGS: embeddings_name_vector_map, - TITLE_EMBEDDING: chunk.title_embedding, - BOOST: chunk.boost, - DOC_UPDATED_AT: _vespa_get_updated_at_attribute(document.doc_updated_at), - PRIMARY_OWNERS: get_experts_stores_representations(document.primary_owners), - SECONDARY_OWNERS: get_experts_stores_representations(document.secondary_owners), - # the only `set` vespa has is `weightedset`, so we have to give each - # element an arbitrary weight - ACCESS_CONTROL_LIST: {acl_entry: 1 for acl_entry in chunk.access.to_acl()}, - DOCUMENT_SETS: {document_set: 1 for document_set in chunk.document_sets}, - } - - vespa_url = f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{vespa_chunk_id}" - logger.debug(f'Indexing to URL "{vespa_url}"') - res = http_client.post( - vespa_url, headers=json_header, json={"fields": vespa_document_fields} - ) - try: - res.raise_for_status() - except Exception as e: - logger.exception( - f"Failed to index document: '{document.id}'. Got response: '{res.text}'" - ) - raise e - - -def batch_index_vespa_chunks( - chunks: list[DocMetadataAwareIndexChunk], - index_name: str, - http_client: httpx.Client, - executor: concurrent.futures.ThreadPoolExecutor | None = None, -) -> None: - external_executor = True - - if not executor: - external_executor = False - executor = concurrent.futures.ThreadPoolExecutor(max_workers=NUM_THREADS) - - try: - chunk_index_future = { - executor.submit(_index_vespa_chunk, chunk, index_name, http_client): chunk - for chunk in chunks - } - for future in concurrent.futures.as_completed(chunk_index_future): - # Will raise exception if any indexing raised an exception - future.result() - - finally: - if not external_executor: - executor.shutdown(wait=True) - - -def clean_chunk_id_copy( - chunk: DocMetadataAwareIndexChunk, -) -> DocMetadataAwareIndexChunk: - clean_chunk = chunk.copy( - update={ - "source_document": chunk.source_document.copy( - update={ - "id": replace_invalid_doc_id_characters(chunk.source_document.id) - } - ) - } - ) - return clean_chunk diff --git a/backend/danswer/document_index/vespa/shared_utils/utils.py b/backend/danswer/document_index/vespa/shared_utils/utils.py deleted file mode 100644 index c74afc9a629..00000000000 --- a/backend/danswer/document_index/vespa/shared_utils/utils.py +++ /dev/null @@ -1,47 +0,0 @@ -import re - -# NOTE: This does not seem to be used in reality despite the Vespa Docs pointing to this code -# See here for reference: https://docs.vespa.ai/en/documents.html -# https://github.com/vespa-engine/vespa/blob/master/vespajlib/src/main/java/com/yahoo/text/Text.java - -# Define allowed ASCII characters -ALLOWED_ASCII_CHARS: list[bool] = [False] * 0x80 -ALLOWED_ASCII_CHARS[0x9] = True # tab -ALLOWED_ASCII_CHARS[0xA] = True # newline -ALLOWED_ASCII_CHARS[0xD] = True # carriage return -for i in range(0x20, 0x7F): - ALLOWED_ASCII_CHARS[i] = True # printable ASCII chars -ALLOWED_ASCII_CHARS[0x7F] = True # del - discouraged, but allowed - - -def is_text_character(codepoint: int) -> bool: - """Returns whether the given codepoint is a valid text character.""" - if codepoint < 0x80: - return ALLOWED_ASCII_CHARS[codepoint] - if codepoint < 0xD800: - return True - if codepoint <= 0xDFFF: - return False - if codepoint < 0xFDD0: - return True - if codepoint <= 0xFDEF: - return False - if codepoint >= 0x10FFFE: - return False - return (codepoint & 0xFFFF) < 0xFFFE - - -def replace_invalid_doc_id_characters(text: str) -> str: - """Replaces invalid document ID characters in text.""" - # There may be a more complete set of replacements that need to be made but Vespa docs are unclear - # and users only seem to be running into this error with single quotes - return text.replace("'", "_") - - -def remove_invalid_unicode_chars(text: str) -> str: - """Vespa does not take in unicode chars that aren't valid for XML. - This removes them.""" - _illegal_xml_chars_RE: re.Pattern = re.compile( - "[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]" - ) - return _illegal_xml_chars_RE.sub("", text) diff --git a/backend/danswer/document_index/vespa/shared_utils/vespa_request_builders.py b/backend/danswer/document_index/vespa/shared_utils/vespa_request_builders.py deleted file mode 100644 index 65752aa09c1..00000000000 --- a/backend/danswer/document_index/vespa/shared_utils/vespa_request_builders.py +++ /dev/null @@ -1,96 +0,0 @@ -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -from danswer.configs.constants import INDEX_SEPARATOR -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.document_index.vespa_constants import ACCESS_CONTROL_LIST -from danswer.document_index.vespa_constants import CHUNK_ID -from danswer.document_index.vespa_constants import DOC_UPDATED_AT -from danswer.document_index.vespa_constants import DOCUMENT_ID -from danswer.document_index.vespa_constants import DOCUMENT_SETS -from danswer.document_index.vespa_constants import HIDDEN -from danswer.document_index.vespa_constants import METADATA_LIST -from danswer.document_index.vespa_constants import SOURCE_TYPE -from danswer.search.models import IndexFilters -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def build_vespa_filters(filters: IndexFilters, include_hidden: bool = False) -> str: - def _build_or_filters(key: str, vals: list[str] | None) -> str: - if vals is None: - return "" - - valid_vals = [val for val in vals if val] - if not key or not valid_vals: - return "" - - eq_elems = [f'{key} contains "{elem}"' for elem in valid_vals] - or_clause = " or ".join(eq_elems) - return f"({or_clause}) and " - - def _build_time_filter( - cutoff: datetime | None, - # Slightly over 3 Months, approximately 1 fiscal quarter - untimed_doc_cutoff: timedelta = timedelta(days=92), - ) -> str: - if not cutoff: - return "" - - # For Documents that don't have an updated at, filter them out for queries asking for - # very recent documents (3 months) default. Documents that don't have an updated at - # time are assigned 3 months for time decay value - include_untimed = datetime.now(timezone.utc) - untimed_doc_cutoff > cutoff - cutoff_secs = int(cutoff.timestamp()) - - if include_untimed: - # Documents without updated_at are assigned -1 as their date - return f"!({DOC_UPDATED_AT} < {cutoff_secs}) and " - - return f"({DOC_UPDATED_AT} >= {cutoff_secs}) and " - - filter_str = f"!({HIDDEN}=true) and " if not include_hidden else "" - - # CAREFUL touching this one, currently there is no second ACL double-check post retrieval - if filters.access_control_list is not None: - filter_str += _build_or_filters( - ACCESS_CONTROL_LIST, filters.access_control_list - ) - - source_strs = ( - [s.value for s in filters.source_type] if filters.source_type else None - ) - filter_str += _build_or_filters(SOURCE_TYPE, source_strs) - - tag_attributes = None - tags = filters.tags - if tags: - tag_attributes = [tag.tag_key + INDEX_SEPARATOR + tag.tag_value for tag in tags] - filter_str += _build_or_filters(METADATA_LIST, tag_attributes) - - filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set) - - filter_str += _build_time_filter(filters.time_cutoff) - - return filter_str - - -def build_vespa_id_based_retrieval_yql( - chunk_request: VespaChunkRequest, -) -> str: - id_based_retrieval_yql_section = ( - f'({DOCUMENT_ID} contains "{chunk_request.document_id}"' - ) - - if chunk_request.is_capped: - id_based_retrieval_yql_section += ( - f" and {CHUNK_ID} >= {chunk_request.min_chunk_ind or 0}" - ) - id_based_retrieval_yql_section += ( - f" and {CHUNK_ID} <= {chunk_request.max_chunk_ind}" - ) - - id_based_retrieval_yql_section += ")" - return id_based_retrieval_yql_section diff --git a/backend/danswer/document_index/vespa_constants.py b/backend/danswer/document_index/vespa_constants.py deleted file mode 100644 index 0b8949b4264..00000000000 --- a/backend/danswer/document_index/vespa_constants.py +++ /dev/null @@ -1,85 +0,0 @@ -from danswer.configs.app_configs import VESPA_CONFIG_SERVER_HOST -from danswer.configs.app_configs import VESPA_HOST -from danswer.configs.app_configs import VESPA_PORT -from danswer.configs.app_configs import VESPA_TENANT_PORT -from danswer.configs.constants import SOURCE_TYPE - -VESPA_DIM_REPLACEMENT_PAT = "VARIABLE_DIM" -DANSWER_CHUNK_REPLACEMENT_PAT = "DANSWER_CHUNK_NAME" -DOCUMENT_REPLACEMENT_PAT = "DOCUMENT_REPLACEMENT" -DATE_REPLACEMENT = "DATE_REPLACEMENT" - -# config server -VESPA_CONFIG_SERVER_URL = f"http://{VESPA_CONFIG_SERVER_HOST}:{VESPA_TENANT_PORT}" -VESPA_APPLICATION_ENDPOINT = f"{VESPA_CONFIG_SERVER_URL}/application/v2" - -# main search application -VESPA_APP_CONTAINER_URL = f"http://{VESPA_HOST}:{VESPA_PORT}" -# danswer_chunk below is defined in vespa/app_configs/schemas/danswer_chunk.sd -DOCUMENT_ID_ENDPOINT = ( - f"{VESPA_APP_CONTAINER_URL}/document/v1/default/{{index_name}}/docid" -) -SEARCH_ENDPOINT = f"{VESPA_APP_CONTAINER_URL}/search/" - -NUM_THREADS = ( - 32 # since Vespa doesn't allow batching of inserts / updates, we use threads -) -MAX_ID_SEARCH_QUERY_SIZE = 400 -# up from 500ms for now, since we've seen quite a few timeouts -# in the long term, we are looking to improve the performance of Vespa -# so that we can bring this back to default -VESPA_TIMEOUT = "3s" -BATCH_SIZE = 128 # Specific to Vespa - - -DOCUMENT_ID = "document_id" -CHUNK_ID = "chunk_id" -BLURB = "blurb" -CONTENT = "content" -SOURCE_LINKS = "source_links" -SEMANTIC_IDENTIFIER = "semantic_identifier" -TITLE = "title" -SKIP_TITLE_EMBEDDING = "skip_title" -SECTION_CONTINUATION = "section_continuation" -EMBEDDINGS = "embeddings" -TITLE_EMBEDDING = "title_embedding" -ACCESS_CONTROL_LIST = "access_control_list" -DOCUMENT_SETS = "document_sets" -LARGE_CHUNK_REFERENCE_IDS = "large_chunk_reference_ids" -METADATA = "metadata" -METADATA_LIST = "metadata_list" -METADATA_SUFFIX = "metadata_suffix" -BOOST = "boost" -DOC_UPDATED_AT = "doc_updated_at" # Indexed as seconds since epoch -PRIMARY_OWNERS = "primary_owners" -SECONDARY_OWNERS = "secondary_owners" -RECENCY_BIAS = "recency_bias" -HIDDEN = "hidden" - -# Specific to Vespa, needed for highlighting matching keywords / section -CONTENT_SUMMARY = "content_summary" - - -YQL_BASE = ( - f"select " - f"documentid, " - f"{DOCUMENT_ID}, " - f"{CHUNK_ID}, " - f"{BLURB}, " - f"{CONTENT}, " - f"{SOURCE_TYPE}, " - f"{SOURCE_LINKS}, " - f"{SEMANTIC_IDENTIFIER}, " - f"{TITLE}, " - f"{SECTION_CONTINUATION}, " - f"{BOOST}, " - f"{HIDDEN}, " - f"{DOC_UPDATED_AT}, " - f"{PRIMARY_OWNERS}, " - f"{SECONDARY_OWNERS}, " - f"{LARGE_CHUNK_REFERENCE_IDS}, " - f"{METADATA}, " - f"{METADATA_SUFFIX}, " - f"{CONTENT_SUMMARY} " - f"from {{index_name}} where " -) diff --git a/backend/danswer/dynamic_configs/__init__.py b/backend/danswer/dynamic_configs/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/dynamic_configs/factory.py b/backend/danswer/dynamic_configs/factory.py deleted file mode 100644 index 44b6e096b6d..00000000000 --- a/backend/danswer/dynamic_configs/factory.py +++ /dev/null @@ -1,15 +0,0 @@ -from danswer.configs.app_configs import DYNAMIC_CONFIG_STORE -from danswer.dynamic_configs.interface import DynamicConfigStore -from danswer.dynamic_configs.store import FileSystemBackedDynamicConfigStore -from danswer.dynamic_configs.store import PostgresBackedDynamicConfigStore - - -def get_dynamic_config_store() -> DynamicConfigStore: - dynamic_config_store_type = DYNAMIC_CONFIG_STORE - if dynamic_config_store_type == FileSystemBackedDynamicConfigStore.__name__: - raise NotImplementedError("File based config store no longer supported") - if dynamic_config_store_type == PostgresBackedDynamicConfigStore.__name__: - return PostgresBackedDynamicConfigStore() - - # TODO: change exception type - raise Exception("Unknown dynamic config store type") diff --git a/backend/danswer/dynamic_configs/interface.py b/backend/danswer/dynamic_configs/interface.py deleted file mode 100644 index 999ad939615..00000000000 --- a/backend/danswer/dynamic_configs/interface.py +++ /dev/null @@ -1,27 +0,0 @@ -import abc -from collections.abc import Mapping -from collections.abc import Sequence -from typing import TypeAlias - - -JSON_ro: TypeAlias = ( - Mapping[str, "JSON_ro"] | Sequence["JSON_ro"] | str | int | float | bool | None -) - - -class ConfigNotFoundError(Exception): - pass - - -class DynamicConfigStore: - @abc.abstractmethod - def store(self, key: str, val: JSON_ro, encrypt: bool = False) -> None: - raise NotImplementedError - - @abc.abstractmethod - def load(self, key: str) -> JSON_ro: - raise NotImplementedError - - @abc.abstractmethod - def delete(self, key: str) -> None: - raise NotImplementedError diff --git a/backend/danswer/dynamic_configs/store.py b/backend/danswer/dynamic_configs/store.py deleted file mode 100644 index cc53da938ad..00000000000 --- a/backend/danswer/dynamic_configs/store.py +++ /dev/null @@ -1,102 +0,0 @@ -import json -import os -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -from typing import cast - -from filelock import FileLock -from sqlalchemy.orm import Session - -from danswer.db.engine import get_session_factory -from danswer.db.models import KVStore -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.dynamic_configs.interface import DynamicConfigStore -from danswer.dynamic_configs.interface import JSON_ro - - -FILE_LOCK_TIMEOUT = 10 - - -def _get_file_lock(file_name: Path) -> FileLock: - return FileLock(file_name.with_suffix(".lock")) - - -class FileSystemBackedDynamicConfigStore(DynamicConfigStore): - def __init__(self, dir_path: str) -> None: - # TODO (chris): maybe require all possible keys to be passed in - # at app start somehow to prevent key overlaps - self.dir_path = Path(dir_path) - - def store(self, key: str, val: JSON_ro, encrypt: bool = False) -> None: - file_path = self.dir_path / key - lock = _get_file_lock(file_path) - with lock.acquire(timeout=FILE_LOCK_TIMEOUT): - with open(file_path, "w+") as f: - json.dump(val, f) - - def load(self, key: str) -> JSON_ro: - file_path = self.dir_path / key - if not file_path.exists(): - raise ConfigNotFoundError - lock = _get_file_lock(file_path) - with lock.acquire(timeout=FILE_LOCK_TIMEOUT): - with open(self.dir_path / key) as f: - return cast(JSON_ro, json.load(f)) - - def delete(self, key: str) -> None: - file_path = self.dir_path / key - if not file_path.exists(): - raise ConfigNotFoundError - lock = _get_file_lock(file_path) - with lock.acquire(timeout=FILE_LOCK_TIMEOUT): - os.remove(file_path) - - -class PostgresBackedDynamicConfigStore(DynamicConfigStore): - @contextmanager - def get_session(self) -> Iterator[Session]: - factory = get_session_factory() - session: Session = factory() - try: - yield session - finally: - session.close() - - def store(self, key: str, val: JSON_ro, encrypt: bool = False) -> None: - # The actual encryption/decryption is done in Postgres, we just need to choose - # which field to set - encrypted_val = val if encrypt else None - plain_val = val if not encrypt else None - with self.get_session() as session: - obj = session.query(KVStore).filter_by(key=key).first() - if obj: - obj.value = plain_val - obj.encrypted_value = encrypted_val - else: - obj = KVStore( - key=key, value=plain_val, encrypted_value=encrypted_val - ) # type: ignore - session.query(KVStore).filter_by(key=key).delete() # just in case - session.add(obj) - session.commit() - - def load(self, key: str) -> JSON_ro: - with self.get_session() as session: - obj = session.query(KVStore).filter_by(key=key).first() - if not obj: - raise ConfigNotFoundError - - if obj.value is not None: - return cast(JSON_ro, obj.value) - if obj.encrypted_value is not None: - return cast(JSON_ro, obj.encrypted_value) - - return None - - def delete(self, key: str) -> None: - with self.get_session() as session: - result = session.query(KVStore).filter_by(key=key).delete() # type: ignore - if result == 0: - raise ConfigNotFoundError - session.commit() diff --git a/backend/danswer/file_processing/__init__.py b/backend/danswer/file_processing/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/file_processing/enums.py b/backend/danswer/file_processing/enums.py deleted file mode 100644 index f532d0ebfcc..00000000000 --- a/backend/danswer/file_processing/enums.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - - -class HtmlBasedConnectorTransformLinksStrategy(str, Enum): - # remove links entirely - STRIP = "strip" - # turn HTML links into markdown links - MARKDOWN = "markdown" diff --git a/backend/danswer/file_processing/extract_file_text.py b/backend/danswer/file_processing/extract_file_text.py deleted file mode 100644 index 7143b428714..00000000000 --- a/backend/danswer/file_processing/extract_file_text.py +++ /dev/null @@ -1,303 +0,0 @@ -import io -import json -import os -import re -import zipfile -from collections.abc import Callable -from collections.abc import Iterator -from email.parser import Parser as EmailParser -from pathlib import Path -from typing import Any -from typing import IO - -import chardet -import docx # type: ignore -import openpyxl # type: ignore -import pptx # type: ignore -from pypdf import PdfReader -from pypdf.errors import PdfStreamError - -from danswer.configs.constants import DANSWER_METADATA_FILENAME -from danswer.file_processing.html_utils import parse_html_page_basic -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -TEXT_SECTION_SEPARATOR = "\n\n" - - -PLAIN_TEXT_FILE_EXTENSIONS = [ - ".txt", - ".md", - ".mdx", - ".conf", - ".log", - ".json", - ".csv", - ".tsv", - ".xml", - ".yml", - ".yaml", -] - - -VALID_FILE_EXTENSIONS = PLAIN_TEXT_FILE_EXTENSIONS + [ - ".pdf", - ".docx", - ".pptx", - ".xlsx", - ".eml", - ".epub", - ".html", -] - - -def is_text_file_extension(file_name: str) -> bool: - return any(file_name.endswith(ext) for ext in PLAIN_TEXT_FILE_EXTENSIONS) - - -def get_file_ext(file_path_or_name: str | Path) -> str: - _, extension = os.path.splitext(file_path_or_name) - return extension - - -def check_file_ext_is_valid(ext: str) -> bool: - return ext in VALID_FILE_EXTENSIONS - - -def is_text_file(file: IO[bytes]) -> bool: - """ - checks if the first 1024 bytes only contain printable or whitespace characters - if it does, then we say its a plaintext file - """ - raw_data = file.read(1024) - text_chars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F}) - return all(c in text_chars for c in raw_data) - - -def detect_encoding(file: IO[bytes]) -> str: - raw_data = file.read(50000) - encoding = chardet.detect(raw_data)["encoding"] or "utf-8" - file.seek(0) - return encoding - - -def is_macos_resource_fork_file(file_name: str) -> bool: - return os.path.basename(file_name).startswith("._") and file_name.startswith( - "__MACOSX" - ) - - -# To include additional metadata in the search index, add a .danswer_metadata.json file -# to the zip file. This file should contain a list of objects with the following format: -# [{ "filename": "file1.txt", "link": "https://example.com/file1.txt" }] -def load_files_from_zip( - zip_file_io: IO, - ignore_macos_resource_fork_files: bool = True, - ignore_dirs: bool = True, -) -> Iterator[tuple[zipfile.ZipInfo, IO[Any], dict[str, Any]]]: - with zipfile.ZipFile(zip_file_io, "r") as zip_file: - zip_metadata = {} - try: - metadata_file_info = zip_file.getinfo(DANSWER_METADATA_FILENAME) - with zip_file.open(metadata_file_info, "r") as metadata_file: - try: - zip_metadata = json.load(metadata_file) - if isinstance(zip_metadata, list): - # convert list of dicts to dict of dicts - zip_metadata = {d["filename"]: d for d in zip_metadata} - except json.JSONDecodeError: - logger.warn(f"Unable to load {DANSWER_METADATA_FILENAME}") - except KeyError: - logger.info(f"No {DANSWER_METADATA_FILENAME} file") - - for file_info in zip_file.infolist(): - with zip_file.open(file_info.filename, "r") as file: - if ignore_dirs and file_info.is_dir(): - continue - - if ( - ignore_macos_resource_fork_files - and is_macos_resource_fork_file(file_info.filename) - ) or file_info.filename == DANSWER_METADATA_FILENAME: - continue - yield file_info, file, zip_metadata.get(file_info.filename, {}) - - -def _extract_danswer_metadata(line: str) -> dict | None: - html_comment_pattern = r"" - hashtag_pattern = r"#DANSWER_METADATA=\{(.*?)\}" - - html_comment_match = re.search(html_comment_pattern, line) - hashtag_match = re.search(hashtag_pattern, line) - - if html_comment_match: - json_str = html_comment_match.group(1) - elif hashtag_match: - json_str = hashtag_match.group(1) - else: - return None - - try: - return json.loads("{" + json_str + "}") - except json.JSONDecodeError: - return None - - -def read_text_file( - file: IO, - encoding: str = "utf-8", - errors: str = "replace", - ignore_danswer_metadata: bool = True, -) -> tuple[str, dict]: - metadata = {} - file_content_raw = "" - for ind, line in enumerate(file): - try: - line = line.decode(encoding) if isinstance(line, bytes) else line - except UnicodeDecodeError: - line = ( - line.decode(encoding, errors=errors) - if isinstance(line, bytes) - else line - ) - - if ind == 0: - metadata_or_none = ( - None if ignore_danswer_metadata else _extract_danswer_metadata(line) - ) - if metadata_or_none is not None: - metadata = metadata_or_none - else: - file_content_raw += line - else: - file_content_raw += line - - return file_content_raw, metadata - - -def pdf_to_text(file: IO[Any], pdf_pass: str | None = None) -> str: - try: - pdf_reader = PdfReader(file) - - # If marked as encrypted and a password is provided, try to decrypt - if pdf_reader.is_encrypted and pdf_pass is not None: - decrypt_success = False - if pdf_pass is not None: - try: - decrypt_success = pdf_reader.decrypt(pdf_pass) != 0 - except Exception: - logger.error("Unable to decrypt pdf") - else: - logger.warning("No Password available to to decrypt pdf") - - if not decrypt_success: - # By user request, keep files that are unreadable just so they - # can be discoverable by title. - return "" - - return TEXT_SECTION_SEPARATOR.join( - page.extract_text() for page in pdf_reader.pages - ) - except PdfStreamError: - logger.exception("PDF file is not a valid PDF") - except Exception: - logger.exception("Failed to read PDF") - - # File is still discoverable by title - # but the contents are not included as they cannot be parsed - return "" - - -def docx_to_text(file: IO[Any]) -> str: - doc = docx.Document(file) - full_text = [para.text for para in doc.paragraphs] - return TEXT_SECTION_SEPARATOR.join(full_text) - - -def pptx_to_text(file: IO[Any]) -> str: - presentation = pptx.Presentation(file) - text_content = [] - for slide_number, slide in enumerate(presentation.slides, start=1): - extracted_text = f"\nSlide {slide_number}:\n" - for shape in slide.shapes: - if hasattr(shape, "text"): - extracted_text += shape.text + "\n" - text_content.append(extracted_text) - return TEXT_SECTION_SEPARATOR.join(text_content) - - -def xlsx_to_text(file: IO[Any]) -> str: - workbook = openpyxl.load_workbook(file) - text_content = [] - for sheet in workbook.worksheets: - sheet_string = "\n".join( - ",".join(map(str, row)) - for row in sheet.iter_rows(min_row=1, values_only=True) - ) - text_content.append(sheet_string) - return TEXT_SECTION_SEPARATOR.join(text_content) - - -def eml_to_text(file: IO[Any]) -> str: - text_file = io.TextIOWrapper(file, encoding=detect_encoding(file)) - parser = EmailParser() - message = parser.parse(text_file) - text_content = [] - for part in message.walk(): - if part.get_content_type().startswith("text/plain"): - text_content.append(part.get_payload()) - return TEXT_SECTION_SEPARATOR.join(text_content) - - -def epub_to_text(file: IO[Any]) -> str: - with zipfile.ZipFile(file) as epub: - text_content = [] - for item in epub.infolist(): - if item.filename.endswith(".xhtml") or item.filename.endswith(".html"): - with epub.open(item) as html_file: - text_content.append(parse_html_page_basic(html_file)) - return TEXT_SECTION_SEPARATOR.join(text_content) - - -def file_io_to_text(file: IO[Any]) -> str: - encoding = detect_encoding(file) - file_content_raw, _ = read_text_file(file, encoding=encoding) - return file_content_raw - - -def extract_file_text( - file_name: str | None, - file: IO[Any], - break_on_unprocessable: bool = True, -) -> str: - extension_to_function: dict[str, Callable[[IO[Any]], str]] = { - ".pdf": pdf_to_text, - ".docx": docx_to_text, - ".pptx": pptx_to_text, - ".xlsx": xlsx_to_text, - ".eml": eml_to_text, - ".epub": epub_to_text, - ".html": parse_html_page_basic, - } - - def _process_file() -> str: - if file_name: - extension = get_file_ext(file_name) - if check_file_ext_is_valid(extension): - return extension_to_function.get(extension, file_io_to_text)(file) - - # Either the file somehow has no name or the extension is not one that we are familiar with - if is_text_file(file): - return file_io_to_text(file) - - raise ValueError("Unknown file extension and unknown text encoding") - - try: - return _process_file() - except Exception as e: - if break_on_unprocessable: - raise RuntimeError(f"Failed to process file: {str(e)}") from e - logger.warning(f"Failed to process file: {str(e)}") - return "" diff --git a/backend/danswer/file_processing/html_utils.py b/backend/danswer/file_processing/html_utils.py deleted file mode 100644 index 48782981f89..00000000000 --- a/backend/danswer/file_processing/html_utils.py +++ /dev/null @@ -1,189 +0,0 @@ -import re -from copy import copy -from dataclasses import dataclass -from typing import IO - -import bs4 - -from danswer.configs.app_configs import HTML_BASED_CONNECTOR_TRANSFORM_LINKS_STRATEGY -from danswer.configs.app_configs import WEB_CONNECTOR_IGNORED_CLASSES -from danswer.configs.app_configs import WEB_CONNECTOR_IGNORED_ELEMENTS -from danswer.file_processing.enums import HtmlBasedConnectorTransformLinksStrategy - -MINTLIFY_UNWANTED = ["sticky", "hidden"] - - -@dataclass -class ParsedHTML: - title: str | None - cleaned_text: str - - -def strip_excessive_newlines_and_spaces(document: str) -> str: - # collapse repeated spaces into one - document = re.sub(r" +", " ", document) - # remove trailing spaces - document = re.sub(r" +[\n\r]", "\n", document) - # remove repeated newlines - document = re.sub(r"[\n\r]+", "\n", document) - return document.strip() - - -def strip_newlines(document: str) -> str: - # HTML might contain newlines which are just whitespaces to a browser - return re.sub(r"[\n\r]+", " ", document) - - -def format_element_text(element_text: str, link_href: str | None) -> str: - element_text_no_newlines = strip_newlines(element_text) - - if ( - not link_href - or HTML_BASED_CONNECTOR_TRANSFORM_LINKS_STRATEGY - == HtmlBasedConnectorTransformLinksStrategy.STRIP - ): - return element_text_no_newlines - - return f"[{element_text_no_newlines}]({link_href})" - - -def format_document_soup( - document: bs4.BeautifulSoup, table_cell_separator: str = "\t" -) -> str: - """Format html to a flat text document. - - The following goals: - - Newlines from within the HTML are removed (as browser would ignore them as well). - - Repeated newlines/spaces are removed (as browsers would ignore them). - - Newlines only before and after headlines and paragraphs or when explicit (br or pre tag) - - Table columns/rows are separated by newline - - List elements are separated by newline and start with a hyphen - """ - text = "" - list_element_start = False - verbatim_output = 0 - in_table = False - last_added_newline = False - link_href: str | None = None - - for e in document.descendants: - verbatim_output -= 1 - if isinstance(e, bs4.element.NavigableString): - if isinstance(e, (bs4.element.Comment, bs4.element.Doctype)): - continue - element_text = e.text - if in_table: - # Tables are represented in natural language with rows separated by newlines - # Can't have newlines then in the table elements - element_text = element_text.replace("\n", " ").strip() - - # Some tags are translated to spaces but in the logic underneath this section, we - # translate them to newlines as a browser should render them such as with br - # This logic here avoids a space after newline when it shouldn't be there. - if last_added_newline and element_text.startswith(" "): - element_text = element_text[1:] - last_added_newline = False - - if element_text: - content_to_add = ( - element_text - if verbatim_output > 0 - else format_element_text(element_text, link_href) - ) - - # Don't join separate elements without any spacing - if (text and not text[-1].isspace()) and ( - content_to_add and not content_to_add[0].isspace() - ): - text += " " - - text += content_to_add - - list_element_start = False - elif isinstance(e, bs4.element.Tag): - # table is standard HTML element - if e.name == "table": - in_table = True - # tr is for rows - elif e.name == "tr" and in_table: - text += "\n" - # td for data cell, th for header - elif e.name in ["td", "th"] and in_table: - text += table_cell_separator - elif e.name == "/table": - in_table = False - elif in_table: - # don't handle other cases while in table - pass - elif e.name == "a": - href_value = e.get("href", None) - # mostly for typing, having multiple hrefs is not valid HTML - link_href = ( - href_value[0] if isinstance(href_value, list) else href_value - ) - elif e.name == "/a": - link_href = None - elif e.name in ["p", "div"]: - if not list_element_start: - text += "\n" - elif e.name in ["h1", "h2", "h3", "h4"]: - text += "\n" - list_element_start = False - last_added_newline = True - elif e.name == "br": - text += "\n" - list_element_start = False - last_added_newline = True - elif e.name == "li": - text += "\n- " - list_element_start = True - elif e.name == "pre": - if verbatim_output <= 0: - verbatim_output = len(list(e.childGenerator())) - return strip_excessive_newlines_and_spaces(text) - - -def parse_html_page_basic(text: str | IO[bytes]) -> str: - soup = bs4.BeautifulSoup(text, "html.parser") - return format_document_soup(soup) - - -def web_html_cleanup( - page_content: str | bs4.BeautifulSoup, - mintlify_cleanup_enabled: bool = True, - additional_element_types_to_discard: list[str] | None = None, -) -> ParsedHTML: - if isinstance(page_content, str): - soup = bs4.BeautifulSoup(page_content, "html.parser") - else: - soup = page_content - - title_tag = soup.find("title") - title = None - if title_tag and title_tag.text: - title = title_tag.text - title_tag.extract() - - # Heuristics based cleaning of elements based on css classes - unwanted_classes = copy(WEB_CONNECTOR_IGNORED_CLASSES) - if mintlify_cleanup_enabled: - unwanted_classes.extend(MINTLIFY_UNWANTED) - for undesired_element in unwanted_classes: - [ - tag.extract() - for tag in soup.find_all( - class_=lambda x: x and undesired_element in x.split() - ) - ] - - for undesired_tag in WEB_CONNECTOR_IGNORED_ELEMENTS: - [tag.extract() for tag in soup.find_all(undesired_tag)] - - if additional_element_types_to_discard: - for undesired_tag in additional_element_types_to_discard: - [tag.extract() for tag in soup.find_all(undesired_tag)] - - # 200B is ZeroWidthSpace which we don't care for - page_text = format_document_soup(soup).replace("\u200B", "") - - return ParsedHTML(title=title, cleaned_text=page_text) diff --git a/backend/danswer/file_store/constants.py b/backend/danswer/file_store/constants.py deleted file mode 100644 index a0845d35ef7..00000000000 --- a/backend/danswer/file_store/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -MAX_IN_MEMORY_SIZE = 30 * 1024 * 1024 # 30MB -STANDARD_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB chunks diff --git a/backend/danswer/file_store/file_store.py b/backend/danswer/file_store/file_store.py deleted file mode 100644 index 9bc4c41d361..00000000000 --- a/backend/danswer/file_store/file_store.py +++ /dev/null @@ -1,140 +0,0 @@ -from abc import ABC -from abc import abstractmethod -from typing import IO - -from sqlalchemy.orm import Session - -from danswer.configs.constants import FileOrigin -from danswer.db.models import PGFileStore -from danswer.db.pg_file_store import create_populate_lobj -from danswer.db.pg_file_store import delete_lobj_by_id -from danswer.db.pg_file_store import delete_pgfilestore_by_file_name -from danswer.db.pg_file_store import get_pgfilestore_by_file_name -from danswer.db.pg_file_store import read_lobj -from danswer.db.pg_file_store import upsert_pgfilestore - - -class FileStore(ABC): - """ - An abstraction for storing files and large binary objects. - """ - - @abstractmethod - def save_file( - self, - file_name: str, - content: IO, - display_name: str | None, - file_origin: FileOrigin, - file_type: str, - file_metadata: dict | None = None, - ) -> None: - """ - Save a file to the blob store - - Parameters: - - connector_name: Name of the CC-Pair (as specified by the user in the UI) - - file_name: Name of the file to save - - content: Contents of the file - - display_name: Display name of the file - - file_origin: Origin of the file - - file_type: Type of the file - """ - raise NotImplementedError - - @abstractmethod - def read_file( - self, file_name: str, mode: str | None, use_tempfile: bool = False - ) -> IO: - """ - Read the content of a given file by the name - - Parameters: - - file_name: Name of file to read - - mode: Mode to open the file (e.g. 'b' for binary) - - use_tempfile: Whether to use a temporary file to store the contents - in order to avoid loading the entire file into memory - - Returns: - Contents of the file and metadata dict - """ - - @abstractmethod - def delete_file(self, file_name: str) -> None: - """ - Delete a file by its name. - - Parameters: - - file_name: Name of file to delete - """ - - -class PostgresBackedFileStore(FileStore): - def __init__(self, db_session: Session): - self.db_session = db_session - - def save_file( - self, - file_name: str, - content: IO, - display_name: str | None, - file_origin: FileOrigin, - file_type: str, - file_metadata: dict | None = None, - ) -> None: - try: - # The large objects in postgres are saved as special objects can be listed with - # SELECT * FROM pg_largeobject_metadata; - obj_id = create_populate_lobj(content=content, db_session=self.db_session) - upsert_pgfilestore( - file_name=file_name, - display_name=display_name or file_name, - file_origin=file_origin, - file_type=file_type, - lobj_oid=obj_id, - db_session=self.db_session, - file_metadata=file_metadata, - ) - self.db_session.commit() - except Exception: - self.db_session.rollback() - raise - - def read_file( - self, file_name: str, mode: str | None = None, use_tempfile: bool = False - ) -> IO: - file_record = get_pgfilestore_by_file_name( - file_name=file_name, db_session=self.db_session - ) - return read_lobj( - lobj_oid=file_record.lobj_oid, - db_session=self.db_session, - mode=mode, - use_tempfile=use_tempfile, - ) - - def read_file_record(self, file_name: str) -> PGFileStore: - file_record = get_pgfilestore_by_file_name( - file_name=file_name, db_session=self.db_session - ) - - return file_record - - def delete_file(self, file_name: str) -> None: - try: - file_record = get_pgfilestore_by_file_name( - file_name=file_name, db_session=self.db_session - ) - delete_lobj_by_id(file_record.lobj_oid, db_session=self.db_session) - delete_pgfilestore_by_file_name( - file_name=file_name, db_session=self.db_session - ) - self.db_session.commit() - except Exception: - self.db_session.rollback() - raise - - -def get_default_file_store(db_session: Session) -> FileStore: - # The only supported file store now is the Postgres File Store - return PostgresBackedFileStore(db_session=db_session) diff --git a/backend/danswer/file_store/models.py b/backend/danswer/file_store/models.py deleted file mode 100644 index d944a2fd270..00000000000 --- a/backend/danswer/file_store/models.py +++ /dev/null @@ -1,46 +0,0 @@ -import base64 -from enum import Enum -from typing import NotRequired -from typing_extensions import TypedDict # noreorder - -from pydantic import BaseModel - - -class ChatFileType(str, Enum): - # Image types only contain the binary data - IMAGE = "image" - # Doc types are saved as both the binary, and the parsed text - DOC = "document" - # Plain text only contain the text - PLAIN_TEXT = "plain_text" - - -class FileDescriptor(TypedDict): - """NOTE: is a `TypedDict` so it can be used as a type hint for a JSONB column - in Postgres""" - - id: str - type: ChatFileType - name: NotRequired[str | None] - - -class InMemoryChatFile(BaseModel): - file_id: str - content: bytes - file_type: ChatFileType - filename: str | None = None - - def to_base64(self) -> str: - if self.file_type == ChatFileType.IMAGE: - return base64.b64encode(self.content).decode() - else: - raise RuntimeError( - "Should not be trying to convert a non-image file to base64" - ) - - def to_file_descriptor(self) -> FileDescriptor: - return { - "id": str(self.file_id), - "type": self.file_type, - "name": self.filename, - } diff --git a/backend/danswer/file_store/utils.py b/backend/danswer/file_store/utils.py deleted file mode 100644 index 4b849f70d96..00000000000 --- a/backend/danswer/file_store/utils.py +++ /dev/null @@ -1,77 +0,0 @@ -from io import BytesIO -from typing import cast -from uuid import uuid4 - -import requests -from sqlalchemy.orm import Session - -from danswer.configs.constants import FileOrigin -from danswer.db.engine import get_session_context_manager -from danswer.db.models import ChatMessage -from danswer.file_store.file_store import get_default_file_store -from danswer.file_store.models import FileDescriptor -from danswer.file_store.models import InMemoryChatFile -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - - -def load_chat_file( - file_descriptor: FileDescriptor, db_session: Session -) -> InMemoryChatFile: - file_io = get_default_file_store(db_session).read_file( - file_descriptor["id"], mode="b" - ) - return InMemoryChatFile( - file_id=file_descriptor["id"], - content=file_io.read(), - file_type=file_descriptor["type"], - filename=file_descriptor.get("name"), - ) - - -def load_all_chat_files( - chat_messages: list[ChatMessage], - file_descriptors: list[FileDescriptor], - db_session: Session, -) -> list[InMemoryChatFile]: - file_descriptors_for_history: list[FileDescriptor] = [] - for chat_message in chat_messages: - if chat_message.files: - file_descriptors_for_history.extend(chat_message.files) - - files = cast( - list[InMemoryChatFile], - run_functions_tuples_in_parallel( - [ - (load_chat_file, (file, db_session)) - for file in file_descriptors + file_descriptors_for_history - ] - ), - ) - return files - - -def save_file_from_url(url: str) -> str: - """NOTE: using multiple sessions here, since this is often called - using multithreading. In practice, sharing a session has resulted in - weird errors.""" - with get_session_context_manager() as db_session: - response = requests.get(url) - response.raise_for_status() - - unique_id = str(uuid4()) - - file_io = BytesIO(response.content) - file_store = get_default_file_store(db_session) - file_store.save_file( - file_name=unique_id, - content=file_io, - display_name="GeneratedImage", - file_origin=FileOrigin.CHAT_IMAGE_GEN, - file_type="image/png;base64", - ) - return unique_id - - -def save_files_from_urls(urls: list[str]) -> list[str]: - funcs = [(save_file_from_url, (url,)) for url in urls] - return run_functions_tuples_in_parallel(funcs) diff --git a/backend/danswer/indexing/__init__.py b/backend/danswer/indexing/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/indexing/chunker.py b/backend/danswer/indexing/chunker.py deleted file mode 100644 index 03a03f30f49..00000000000 --- a/backend/danswer/indexing/chunker.py +++ /dev/null @@ -1,304 +0,0 @@ -from danswer.configs.app_configs import BLURB_SIZE -from danswer.configs.app_configs import LARGE_CHUNK_RATIO -from danswer.configs.app_configs import MINI_CHUNK_SIZE -from danswer.configs.app_configs import SKIP_METADATA_IN_CHUNK -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import RETURN_SEPARATOR -from danswer.configs.constants import SECTION_SEPARATOR -from danswer.configs.model_configs import DOC_EMBEDDING_CONTEXT_SIZE -from danswer.connectors.cross_connector_utils.miscellaneous_utils import ( - get_metadata_keys_to_ignore, -) -from danswer.connectors.models import Document -from danswer.indexing.models import DocAwareChunk -from danswer.natural_language_processing.utils import BaseTokenizer -from danswer.utils.logger import setup_logger -from danswer.utils.text_processing import shared_precompare_cleanup - - -# Not supporting overlaps, we need a clean combination of chunks and it is unclear if overlaps -# actually help quality at all -CHUNK_OVERLAP = 0 -# Fairly arbitrary numbers but the general concept is we don't want the title/metadata to -# overwhelm the actual contents of the chunk -# For example in a rare case, this could be 128 tokens for the 512 chunk and title prefix -# could be another 128 tokens leaving 256 for the actual contents -MAX_METADATA_PERCENTAGE = 0.25 -CHUNK_MIN_CONTENT = 256 - -logger = setup_logger() - - -def _get_metadata_suffix_for_document_index( - metadata: dict[str, str | list[str]], include_separator: bool = False -) -> tuple[str, str]: - """ - Returns the metadata as a natural language string representation with all of the keys and values for the vector embedding - and a string of all of the values for the keyword search - - For example, if we have the following metadata: - { - "author": "John Doe", - "space": "Engineering" - } - The vector embedding string should include the relation between the key and value wheres as for keyword we only want John Doe - and Engineering. The keys are repeat and much more noisy. - """ - if not metadata: - return "", "" - - metadata_str = "Metadata:\n" - metadata_values = [] - for key, value in metadata.items(): - if key in get_metadata_keys_to_ignore(): - continue - - value_str = ", ".join(value) if isinstance(value, list) else value - - if isinstance(value, list): - metadata_values.extend(value) - else: - metadata_values.append(value) - - metadata_str += f"\t{key} - {value_str}\n" - - metadata_semantic = metadata_str.strip() - metadata_keyword = " ".join(metadata_values) - - if include_separator: - return RETURN_SEPARATOR + metadata_semantic, RETURN_SEPARATOR + metadata_keyword - return metadata_semantic, metadata_keyword - - -def _combine_chunks(chunks: list[DocAwareChunk], index: int) -> DocAwareChunk: - merged_chunk = DocAwareChunk( - source_document=chunks[0].source_document, - chunk_id=index, - blurb=chunks[0].blurb, - content=chunks[0].content, - source_links=chunks[0].source_links or {}, - section_continuation=(index > 0), - title_prefix=chunks[0].title_prefix, - metadata_suffix_semantic=chunks[0].metadata_suffix_semantic, - metadata_suffix_keyword=chunks[0].metadata_suffix_keyword, - large_chunk_reference_ids=[chunks[0].chunk_id], - mini_chunk_texts=None, - ) - - offset = 0 - for i in range(1, len(chunks)): - merged_chunk.content += SECTION_SEPARATOR + chunks[i].content - merged_chunk.large_chunk_reference_ids.append(chunks[i].chunk_id) - - offset += len(SECTION_SEPARATOR) + len(chunks[i - 1].content) - for link_offset, link_text in (chunks[i].source_links or {}).items(): - if merged_chunk.source_links is None: - merged_chunk.source_links = {} - merged_chunk.source_links[link_offset + offset] = link_text - - return merged_chunk - - -def generate_large_chunks(chunks: list[DocAwareChunk]) -> list[DocAwareChunk]: - large_chunks = [ - _combine_chunks(chunks[i : i + LARGE_CHUNK_RATIO], idx) - for idx, i in enumerate(range(0, len(chunks), LARGE_CHUNK_RATIO)) - if len(chunks[i : i + LARGE_CHUNK_RATIO]) > 1 - ] - return large_chunks - - -class Chunker: - """ - Chunks documents into smaller chunks for indexing. - """ - - def __init__( - self, - tokenizer: BaseTokenizer, - enable_multipass: bool = False, - enable_large_chunks: bool = False, - blurb_size: int = BLURB_SIZE, - include_metadata: bool = not SKIP_METADATA_IN_CHUNK, - chunk_token_limit: int = DOC_EMBEDDING_CONTEXT_SIZE, - chunk_overlap: int = CHUNK_OVERLAP, - mini_chunk_size: int = MINI_CHUNK_SIZE, - ) -> None: - from llama_index.text_splitter import SentenceSplitter - - self.include_metadata = include_metadata - self.chunk_token_limit = chunk_token_limit - self.enable_multipass = enable_multipass - self.enable_large_chunks = enable_large_chunks - self.tokenizer = tokenizer - - self.blurb_splitter = SentenceSplitter( - tokenizer=tokenizer.tokenize, - chunk_size=blurb_size, - chunk_overlap=0, - ) - - self.chunk_splitter = SentenceSplitter( - tokenizer=tokenizer.tokenize, - chunk_size=chunk_token_limit, - chunk_overlap=chunk_overlap, - ) - - self.mini_chunk_splitter = ( - SentenceSplitter( - tokenizer=tokenizer.tokenize, - chunk_size=mini_chunk_size, - chunk_overlap=0, - ) - if enable_multipass - else None - ) - - def _extract_blurb(self, text: str) -> str: - texts = self.blurb_splitter.split_text(text) - if not texts: - return "" - return texts[0] - - def _get_mini_chunk_texts(self, chunk_text: str) -> list[str] | None: - if self.mini_chunk_splitter and chunk_text.strip(): - return self.mini_chunk_splitter.split_text(chunk_text) - return None - - def _chunk_document( - self, - document: Document, - title_prefix: str, - metadata_suffix_semantic: str, - metadata_suffix_keyword: str, - content_token_limit: int, - ) -> list[DocAwareChunk]: - """ - Loops through sections of the document, adds metadata and converts them into chunks. - """ - chunks: list[DocAwareChunk] = [] - link_offsets: dict[int, str] = {} - chunk_text = "" - - def _create_chunk( - text: str, - links: dict[int, str], - is_continuation: bool = False, - ) -> DocAwareChunk: - return DocAwareChunk( - source_document=document, - chunk_id=len(chunks), - blurb=self._extract_blurb(text), - content=text, - source_links=links or {0: ""}, - section_continuation=is_continuation, - title_prefix=title_prefix, - metadata_suffix_semantic=metadata_suffix_semantic, - metadata_suffix_keyword=metadata_suffix_keyword, - mini_chunk_texts=self._get_mini_chunk_texts(text), - ) - - for section in document.sections: - section_text = section.text - section_link_text = section.link or "" - - section_token_count = len(self.tokenizer.tokenize(section_text)) - - # Large sections are considered self-contained/unique - # Therefore, they start a new chunk and are not concatenated - # at the end by other sections - if section_token_count > content_token_limit: - if chunk_text: - chunks.append(_create_chunk(chunk_text, link_offsets)) - link_offsets = {} - chunk_text = "" - - split_texts = self.chunk_splitter.split_text(section_text) - for i, split_text in enumerate(split_texts): - chunks.append( - _create_chunk( - text=split_text, - links={0: section_link_text}, - is_continuation=(i != 0), - ) - ) - continue - - current_token_count = len(self.tokenizer.tokenize(chunk_text)) - current_offset = len(shared_precompare_cleanup(chunk_text)) - # In the case where the whole section is shorter than a chunk, either add - # to chunk or start a new one - next_section_tokens = ( - len(self.tokenizer.tokenize(SECTION_SEPARATOR)) + section_token_count - ) - if next_section_tokens + current_token_count <= content_token_limit: - if chunk_text: - chunk_text += SECTION_SEPARATOR - chunk_text += section_text - link_offsets[current_offset] = section_link_text - else: - chunks.append(_create_chunk(chunk_text, link_offsets)) - link_offsets = {0: section_link_text} - chunk_text = section_text - - # Once we hit the end, if we're still in the process of building a chunk, add what we have. - # If there is only whitespace left then don't include it. If there are no chunks at all - # from the doc, we can just create a single chunk with the title. - if chunk_text.strip() or not chunks: - chunks.append( - _create_chunk( - chunk_text, - link_offsets or {0: section_link_text}, - ) - ) - - # If the chunk does not have any useable content, it will not be indexed - return chunks - - def chunk(self, document: Document) -> list[DocAwareChunk]: - # Specifically for reproducing an issue with gmail - if document.source == DocumentSource.GMAIL: - logger.debug(f"Chunking {document.semantic_identifier}") - - title = self._extract_blurb(document.get_title_for_document_index() or "") - title_prefix = title + RETURN_SEPARATOR if title else "" - title_tokens = len(self.tokenizer.tokenize(title_prefix)) - - metadata_suffix_semantic = "" - metadata_suffix_keyword = "" - metadata_tokens = 0 - if self.include_metadata: - ( - metadata_suffix_semantic, - metadata_suffix_keyword, - ) = _get_metadata_suffix_for_document_index( - document.metadata, include_separator=True - ) - metadata_tokens = len(self.tokenizer.tokenize(metadata_suffix_semantic)) - - if metadata_tokens >= self.chunk_token_limit * MAX_METADATA_PERCENTAGE: - # Note: we can keep the keyword suffix even if the semantic suffix is too long to fit in the model - # context, there is no limit for the keyword component - metadata_suffix_semantic = "" - metadata_tokens = 0 - - content_token_limit = self.chunk_token_limit - title_tokens - metadata_tokens - # If there is not enough context remaining then just index the chunk with no prefix/suffix - if content_token_limit <= CHUNK_MIN_CONTENT: - content_token_limit = self.chunk_token_limit - title_prefix = "" - metadata_suffix_semantic = "" - - normal_chunks = self._chunk_document( - document, - title_prefix, - metadata_suffix_semantic, - metadata_suffix_keyword, - content_token_limit, - ) - - if self.enable_multipass and self.enable_large_chunks: - large_chunks = generate_large_chunks(normal_chunks) - normal_chunks.extend(large_chunks) - - return normal_chunks diff --git a/backend/danswer/indexing/embedder.py b/backend/danswer/indexing/embedder.py deleted file mode 100644 index f7d8f4e7400..00000000000 --- a/backend/danswer/indexing/embedder.py +++ /dev/null @@ -1,205 +0,0 @@ -from abc import ABC -from abc import abstractmethod - -from sqlalchemy.orm import Session - -from danswer.db.models import IndexModelStatus -from danswer.db.models import SearchSettings -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.indexing.models import ChunkEmbedding -from danswer.indexing.models import DocAwareChunk -from danswer.indexing.models import IndexChunk -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.utils.logger import setup_logger -from danswer.utils.timing import log_function_time -from shared_configs.configs import INDEXING_MODEL_SERVER_HOST -from shared_configs.configs import INDEXING_MODEL_SERVER_PORT -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import EmbedTextType -from shared_configs.model_server_models import Embedding - - -logger = setup_logger() - - -class IndexingEmbedder(ABC): - def __init__( - self, - model_name: str, - normalize: bool, - query_prefix: str | None, - passage_prefix: str | None, - provider_type: EmbeddingProvider | None, - api_key: str | None, - ): - self.model_name = model_name - self.normalize = normalize - self.query_prefix = query_prefix - self.passage_prefix = passage_prefix - self.provider_type = provider_type - self.api_key = api_key - - self.embedding_model = EmbeddingModel( - model_name=model_name, - query_prefix=query_prefix, - passage_prefix=passage_prefix, - normalize=normalize, - api_key=api_key, - provider_type=provider_type, - # The below are globally set, this flow always uses the indexing one - server_host=INDEXING_MODEL_SERVER_HOST, - server_port=INDEXING_MODEL_SERVER_PORT, - retrim_content=True, - ) - - @abstractmethod - def embed_chunks( - self, - chunks: list[DocAwareChunk], - ) -> list[IndexChunk]: - raise NotImplementedError - - -class DefaultIndexingEmbedder(IndexingEmbedder): - def __init__( - self, - model_name: str, - normalize: bool, - query_prefix: str | None, - passage_prefix: str | None, - provider_type: EmbeddingProvider | None = None, - api_key: str | None = None, - ): - super().__init__( - model_name, normalize, query_prefix, passage_prefix, provider_type, api_key - ) - - @log_function_time() - def embed_chunks( - self, - chunks: list[DocAwareChunk], - ) -> list[IndexChunk]: - # All chunks at this point must have some non-empty content - flat_chunk_texts: list[str] = [] - large_chunks_present = False - for chunk in chunks: - if chunk.large_chunk_reference_ids: - large_chunks_present = True - chunk_text = ( - f"{chunk.title_prefix}{chunk.content}{chunk.metadata_suffix_semantic}" - ) or chunk.source_document.get_title_for_document_index() - - if not chunk_text: - # This should never happen, the document would have been dropped - # before getting to this point - raise ValueError(f"Chunk has no content: {chunk.to_short_descriptor()}") - - flat_chunk_texts.append(chunk_text) - - if chunk.mini_chunk_texts: - flat_chunk_texts.extend(chunk.mini_chunk_texts) - - embeddings = self.embedding_model.encode( - texts=flat_chunk_texts, - text_type=EmbedTextType.PASSAGE, - large_chunks_present=large_chunks_present, - ) - - chunk_titles = { - chunk.source_document.get_title_for_document_index() for chunk in chunks - } - - # Drop any None or empty strings - # If there is no title or the title is empty, the title embedding field will be null - # which is ok, it just won't contribute at all to the scoring. - chunk_titles_list = [title for title in chunk_titles if title] - - # Cache the Title embeddings to only have to do it once - title_embed_dict: dict[str, Embedding] = {} - if chunk_titles_list: - title_embeddings = self.embedding_model.encode( - chunk_titles_list, text_type=EmbedTextType.PASSAGE - ) - title_embed_dict.update( - { - title: vector - for title, vector in zip(chunk_titles_list, title_embeddings) - } - ) - - # Mapping embeddings to chunks - embedded_chunks: list[IndexChunk] = [] - embedding_ind_start = 0 - for chunk in chunks: - num_embeddings = 1 + ( - len(chunk.mini_chunk_texts) if chunk.mini_chunk_texts else 0 - ) - chunk_embeddings = embeddings[ - embedding_ind_start : embedding_ind_start + num_embeddings - ] - - title = chunk.source_document.get_title_for_document_index() - - title_embedding = None - if title: - if title in title_embed_dict: - # Using cached value to avoid recalculating for every chunk - title_embedding = title_embed_dict[title] - else: - logger.error( - "Title had to be embedded separately, this should not happen!" - ) - title_embedding = self.embedding_model.encode( - [title], text_type=EmbedTextType.PASSAGE - )[0] - title_embed_dict[title] = title_embedding - - new_embedded_chunk = IndexChunk( - **chunk.model_dump(), - embeddings=ChunkEmbedding( - full_embedding=chunk_embeddings[0], - mini_chunk_embeddings=chunk_embeddings[1:], - ), - title_embedding=title_embedding, - ) - embedded_chunks.append(new_embedded_chunk) - embedding_ind_start += num_embeddings - - return embedded_chunks - - @classmethod - def from_db_search_settings( - cls, search_settings: SearchSettings - ) -> "DefaultIndexingEmbedder": - return cls( - model_name=search_settings.model_name, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - provider_type=search_settings.provider_type, - api_key=search_settings.api_key, - ) - - -def get_embedding_model_from_search_settings( - db_session: Session, index_model_status: IndexModelStatus = IndexModelStatus.PRESENT -) -> IndexingEmbedder: - search_settings: SearchSettings | None - if index_model_status == IndexModelStatus.PRESENT: - search_settings = get_current_search_settings(db_session) - elif index_model_status == IndexModelStatus.FUTURE: - search_settings = get_secondary_search_settings(db_session) - if not search_settings: - raise RuntimeError("No secondary index configured") - else: - raise RuntimeError("Not supporting embedding model rollbacks") - - return DefaultIndexingEmbedder( - model_name=search_settings.model_name, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - provider_type=search_settings.provider_type, - api_key=search_settings.api_key, - ) diff --git a/backend/danswer/indexing/indexing_pipeline.py b/backend/danswer/indexing/indexing_pipeline.py deleted file mode 100644 index 3517b55767d..00000000000 --- a/backend/danswer/indexing/indexing_pipeline.py +++ /dev/null @@ -1,396 +0,0 @@ -import traceback -from functools import partial -from typing import Protocol - -from pydantic import BaseModel -from pydantic import ConfigDict -from sqlalchemy.orm import Session - -from danswer.access.access import get_access_for_documents -from danswer.configs.app_configs import ENABLE_MULTIPASS_INDEXING -from danswer.configs.app_configs import INDEXING_EXCEPTION_LIMIT -from danswer.configs.constants import DEFAULT_BOOST -from danswer.connectors.cross_connector_utils.miscellaneous_utils import ( - get_experts_stores_representations, -) -from danswer.connectors.models import Document -from danswer.connectors.models import IndexAttemptMetadata -from danswer.db.document import get_documents_by_ids -from danswer.db.document import prepare_to_modify_documents -from danswer.db.document import update_docs_updated_at -from danswer.db.document import upsert_documents_complete -from danswer.db.document_set import fetch_document_sets_for_documents -from danswer.db.index_attempt import create_index_attempt_error -from danswer.db.models import Document as DBDocument -from danswer.db.search_settings import get_current_search_settings -from danswer.db.tag import create_or_add_document_tag -from danswer.db.tag import create_or_add_document_tag_list -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import DocumentMetadata -from danswer.indexing.chunker import Chunker -from danswer.indexing.embedder import IndexingEmbedder -from danswer.indexing.models import DocAwareChunk -from danswer.indexing.models import DocMetadataAwareIndexChunk -from danswer.utils.logger import setup_logger -from danswer.utils.timing import log_function_time -from shared_configs.enums import EmbeddingProvider - -logger = setup_logger() - - -class DocumentBatchPrepareContext(BaseModel): - updatable_docs: list[Document] - id_to_db_doc_map: dict[str, DBDocument] - model_config = ConfigDict(arbitrary_types_allowed=True) - - -class IndexingPipelineProtocol(Protocol): - def __call__( - self, - document_batch: list[Document], - index_attempt_metadata: IndexAttemptMetadata, - ) -> tuple[int, int]: - ... - - -def upsert_documents_in_db( - documents: list[Document], - index_attempt_metadata: IndexAttemptMetadata, - db_session: Session, -) -> None: - # Metadata here refers to basic document info, not metadata about the actual content - doc_m_batch: list[DocumentMetadata] = [] - for doc in documents: - first_link = next( - (section.link for section in doc.sections if section.link), "" - ) - db_doc_metadata = DocumentMetadata( - connector_id=index_attempt_metadata.connector_id, - credential_id=index_attempt_metadata.credential_id, - document_id=doc.id, - semantic_identifier=doc.semantic_identifier, - first_link=first_link, - primary_owners=get_experts_stores_representations(doc.primary_owners), - secondary_owners=get_experts_stores_representations(doc.secondary_owners), - from_ingestion_api=doc.from_ingestion_api, - ) - doc_m_batch.append(db_doc_metadata) - - upsert_documents_complete( - db_session=db_session, - document_metadata_batch=doc_m_batch, - ) - - # Insert document content metadata - for doc in documents: - for k, v in doc.metadata.items(): - if isinstance(v, list): - create_or_add_document_tag_list( - tag_key=k, - tag_values=v, - source=doc.source, - document_id=doc.id, - db_session=db_session, - ) - else: - create_or_add_document_tag( - tag_key=k, - tag_value=v, - source=doc.source, - document_id=doc.id, - db_session=db_session, - ) - - -def get_doc_ids_to_update( - documents: list[Document], db_docs: list[DBDocument] -) -> list[Document]: - """Figures out which documents actually need to be updated. If a document is already present - and the `updated_at` hasn't changed, we shouldn't need to do anything with it.""" - id_update_time_map = { - doc.id: doc.doc_updated_at for doc in db_docs if doc.doc_updated_at - } - - updatable_docs: list[Document] = [] - for doc in documents: - if ( - doc.id in id_update_time_map - and doc.doc_updated_at - and doc.doc_updated_at <= id_update_time_map[doc.id] - ): - continue - updatable_docs.append(doc) - - return updatable_docs - - -def index_doc_batch_with_handler( - *, - chunker: Chunker, - embedder: IndexingEmbedder, - document_index: DocumentIndex, - document_batch: list[Document], - index_attempt_metadata: IndexAttemptMetadata, - attempt_id: int | None, - db_session: Session, - ignore_time_skip: bool = False, -) -> tuple[int, int]: - r = (0, 0) - try: - r = index_doc_batch( - chunker=chunker, - embedder=embedder, - document_index=document_index, - document_batch=document_batch, - index_attempt_metadata=index_attempt_metadata, - db_session=db_session, - ignore_time_skip=ignore_time_skip, - ) - except Exception as e: - if INDEXING_EXCEPTION_LIMIT == 0: - raise - - trace = traceback.format_exc() - create_index_attempt_error( - attempt_id, - batch=index_attempt_metadata.batch_num, - docs=document_batch, - exception_msg=str(e), - exception_traceback=trace, - db_session=db_session, - ) - logger.exception( - f"Indexing batch {index_attempt_metadata.batch_num} failed. msg='{e}' trace='{trace}'" - ) - - index_attempt_metadata.num_exceptions += 1 - if index_attempt_metadata.num_exceptions == INDEXING_EXCEPTION_LIMIT: - logger.warning( - f"Maximum number of exceptions for this index attempt " - f"({INDEXING_EXCEPTION_LIMIT}) has been reached. " - f"The next exception will abort the indexing attempt." - ) - elif index_attempt_metadata.num_exceptions > INDEXING_EXCEPTION_LIMIT: - logger.warning( - f"Maximum number of exceptions for this index attempt " - f"({INDEXING_EXCEPTION_LIMIT}) has been exceeded." - ) - raise RuntimeError( - f"Maximum exception limit of {INDEXING_EXCEPTION_LIMIT} exceeded." - ) - else: - pass - - return r - - -def index_doc_batch_prepare( - document_batch: list[Document], - index_attempt_metadata: IndexAttemptMetadata, - db_session: Session, - ignore_time_skip: bool = False, -) -> DocumentBatchPrepareContext | None: - documents = [] - for document in document_batch: - empty_contents = not any(section.text.strip() for section in document.sections) - if ( - (not document.title or not document.title.strip()) - and not document.semantic_identifier.strip() - and empty_contents - ): - # Skip documents that have neither title nor content - # If the document doesn't have either, then there is no useful information in it - # This is again verified later in the pipeline after chunking but at that point there should - # already be no documents that are empty. - logger.warning( - f"Skipping document with ID {document.id} as it has neither title nor content." - ) - elif ( - document.title is not None and not document.title.strip() and empty_contents - ): - # The title is explicitly empty ("" and not None) and the document is empty - # so when building the chunk text representation, it will be empty and unuseable - logger.warning( - f"Skipping document with ID {document.id} as the chunks will be empty." - ) - else: - documents.append(document) - - document_ids = [document.id for document in documents] - db_docs: list[DBDocument] = get_documents_by_ids( - document_ids=document_ids, - db_session=db_session, - ) - - # Skip indexing docs that don't have a newer updated at - # Shortcuts the time-consuming flow on connector index retries - updatable_docs = ( - get_doc_ids_to_update(documents=documents, db_docs=db_docs) - if not ignore_time_skip - else documents - ) - - # No docs to update either because the batch is empty or every doc was already indexed - if not updatable_docs: - return None - - # Create records in the source of truth about these documents, - # does not include doc_updated_at which is also used to indicate a successful update - upsert_documents_in_db( - documents=documents, - index_attempt_metadata=index_attempt_metadata, - db_session=db_session, - ) - - id_to_db_doc_map = {doc.id: doc for doc in db_docs} - return DocumentBatchPrepareContext( - updatable_docs=updatable_docs, id_to_db_doc_map=id_to_db_doc_map - ) - - -@log_function_time() -def index_doc_batch( - *, - chunker: Chunker, - embedder: IndexingEmbedder, - document_index: DocumentIndex, - document_batch: list[Document], - index_attempt_metadata: IndexAttemptMetadata, - db_session: Session, - ignore_time_skip: bool = False, -) -> tuple[int, int]: - """Takes different pieces of the indexing pipeline and applies it to a batch of documents - Note that the documents should already be batched at this point so that it does not inflate the - memory requirements""" - - ctx = index_doc_batch_prepare( - document_batch=document_batch, - index_attempt_metadata=index_attempt_metadata, - ignore_time_skip=ignore_time_skip, - db_session=db_session, - ) - if not ctx: - return 0, 0 - - logger.debug("Starting chunking") - chunks: list[DocAwareChunk] = [] - for document in ctx.updatable_docs: - chunks.extend(chunker.chunk(document=document)) - - logger.debug("Starting embedding") - chunks_with_embeddings = ( - embedder.embed_chunks( - chunks=chunks, - ) - if chunks - else [] - ) - - updatable_ids = [doc.id for doc in ctx.updatable_docs] - - # Acquires a lock on the documents so that no other process can modify them - # NOTE: don't need to acquire till here, since this is when the actual race condition - # with Vespa can occur. - with prepare_to_modify_documents(db_session=db_session, document_ids=updatable_ids): - # Attach the latest status from Postgres (source of truth for access) to each - # chunk. This access status will be attached to each chunk in the document index - # TODO: attach document sets to the chunk based on the status of Postgres as well - document_id_to_access_info = get_access_for_documents( - document_ids=updatable_ids, db_session=db_session - ) - document_id_to_document_set = { - document_id: document_sets - for document_id, document_sets in fetch_document_sets_for_documents( - document_ids=updatable_ids, db_session=db_session - ) - } - access_aware_chunks = [ - DocMetadataAwareIndexChunk.from_index_chunk( - index_chunk=chunk, - access=document_id_to_access_info[chunk.source_document.id], - document_sets=set( - document_id_to_document_set.get(chunk.source_document.id, []) - ), - boost=( - ctx.id_to_db_doc_map[chunk.source_document.id].boost - if chunk.source_document.id in ctx.id_to_db_doc_map - else DEFAULT_BOOST - ), - ) - for chunk in chunks_with_embeddings - ] - - logger.debug( - f"Indexing the following chunks: {[chunk.to_short_descriptor() for chunk in access_aware_chunks]}" - ) - # A document will not be spread across different batches, so all the - # documents with chunks in this set, are fully represented by the chunks - # in this set - insertion_records = document_index.index(chunks=access_aware_chunks) - - successful_doc_ids = [record.document_id for record in insertion_records] - successful_docs = [ - doc for doc in ctx.updatable_docs if doc.id in successful_doc_ids - ] - - # Update the time of latest version of the doc successfully indexed - ids_to_new_updated_at = {} - for doc in successful_docs: - if doc.doc_updated_at is None: - continue - ids_to_new_updated_at[doc.id] = doc.doc_updated_at - - update_docs_updated_at( - ids_to_new_updated_at=ids_to_new_updated_at, db_session=db_session - ) - - return len([r for r in insertion_records if r.already_existed is False]), len( - access_aware_chunks - ) - - -def build_indexing_pipeline( - *, - embedder: IndexingEmbedder, - document_index: DocumentIndex, - db_session: Session, - chunker: Chunker | None = None, - ignore_time_skip: bool = False, - attempt_id: int | None = None, -) -> IndexingPipelineProtocol: - """Builds a pipeline which takes in a list (batch) of docs and indexes them.""" - search_settings = get_current_search_settings(db_session) - multipass = ( - search_settings.multipass_indexing - if search_settings - else ENABLE_MULTIPASS_INDEXING - ) - - enable_large_chunks = ( - multipass - and - # Only local models that supports larger context are from Nomic - ( - embedder.provider_type is not None - or embedder.model_name.startswith("nomic-ai") - ) - and - # Cohere does not support larger context they recommend not going above 512 tokens - embedder.provider_type != EmbeddingProvider.COHERE - ) - - chunker = chunker or Chunker( - tokenizer=embedder.embedding_model.tokenizer, - enable_multipass=multipass, - enable_large_chunks=enable_large_chunks, - ) - - return partial( - index_doc_batch_with_handler, - chunker=chunker, - embedder=embedder, - document_index=document_index, - ignore_time_skip=ignore_time_skip, - attempt_id=attempt_id, - db_session=db_session, - ) diff --git a/backend/danswer/indexing/models.py b/backend/danswer/indexing/models.py deleted file mode 100644 index b23de0eb477..00000000000 --- a/backend/danswer/indexing/models.py +++ /dev/null @@ -1,143 +0,0 @@ -from typing import TYPE_CHECKING - -from pydantic import BaseModel -from pydantic import Field - -from danswer.access.models import DocumentAccess -from danswer.connectors.models import Document -from danswer.utils.logger import setup_logger -from shared_configs.enums import EmbeddingProvider -from shared_configs.model_server_models import Embedding - -if TYPE_CHECKING: - from danswer.db.models import SearchSettings - - -logger = setup_logger() - - -class ChunkEmbedding(BaseModel): - full_embedding: Embedding - mini_chunk_embeddings: list[Embedding] - - -class BaseChunk(BaseModel): - chunk_id: int - blurb: str # The first sentence(s) of the first Section of the chunk - content: str - # Holds the link and the offsets into the raw Chunk text - source_links: dict[int, str] | None - section_continuation: bool # True if this Chunk's start is not at the start of a Section - - -class DocAwareChunk(BaseChunk): - # During indexing flow, we have access to a complete "Document" - # During inference we only have access to the document id and do not reconstruct the Document - source_document: Document - - # This could be an empty string if the title is too long and taking up too much of the chunk - # This does not mean necessarily that the document does not have a title - title_prefix: str - - # During indexing we also (optionally) build a metadata string from the metadata dict - # This is also indexed so that we can strip it out after indexing, this way it supports - # multiple iterations of metadata representation for backwards compatibility - metadata_suffix_semantic: str - metadata_suffix_keyword: str - - mini_chunk_texts: list[str] | None - - large_chunk_reference_ids: list[int] = Field(default_factory=list) - - def to_short_descriptor(self) -> str: - """Used when logging the identity of a chunk""" - return ( - f"Chunk ID: '{self.chunk_id}'; {self.source_document.to_short_descriptor()}" - ) - - -class IndexChunk(DocAwareChunk): - embeddings: ChunkEmbedding - title_embedding: Embedding | None - - -class DocMetadataAwareIndexChunk(IndexChunk): - """An `IndexChunk` that contains all necessary metadata to be indexed. This includes - the following: - - access: holds all information about which users should have access to the - source document for this chunk. - document_sets: all document sets the source document for this chunk is a part - of. This is used for filtering / personas. - boost: influences the ranking of this chunk at query time. Positive -> ranked higher, - negative -> ranked lower. - """ - - access: "DocumentAccess" - document_sets: set[str] - boost: int - - @classmethod - def from_index_chunk( - cls, - index_chunk: IndexChunk, - access: "DocumentAccess", - document_sets: set[str], - boost: int, - ) -> "DocMetadataAwareIndexChunk": - index_chunk_data = index_chunk.model_dump() - return cls( - **index_chunk_data, - access=access, - document_sets=document_sets, - boost=boost, - ) - - -class EmbeddingModelDetail(BaseModel): - model_name: str - normalize: bool - query_prefix: str | None - passage_prefix: str | None - provider_type: EmbeddingProvider | None = None - api_key: str | None = None - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - @classmethod - def from_db_model( - cls, - search_settings: "SearchSettings", - ) -> "EmbeddingModelDetail": - return cls( - model_name=search_settings.model_name, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - provider_type=search_settings.provider_type, - api_key=search_settings.api_key, - ) - - -# Additional info needed for indexing time -class IndexingSetting(EmbeddingModelDetail): - model_dim: int - index_name: str | None - multipass_indexing: bool - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - @classmethod - def from_db_model(cls, search_settings: "SearchSettings") -> "IndexingSetting": - return cls( - model_name=search_settings.model_name, - model_dim=search_settings.model_dim, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - provider_type=search_settings.provider_type, - index_name=search_settings.index_name, - multipass_indexing=search_settings.multipass_indexing, - ) diff --git a/backend/danswer/llm/__init__.py b/backend/danswer/llm/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/llm/answering/answer.py b/backend/danswer/llm/answering/answer.py deleted file mode 100644 index a664db217af..00000000000 --- a/backend/danswer/llm/answering/answer.py +++ /dev/null @@ -1,569 +0,0 @@ -from collections.abc import Callable -from collections.abc import Iterator -from typing import cast -from uuid import uuid4 - -from langchain.schema.messages import BaseMessage -from langchain_core.messages import AIMessageChunk -from langchain_core.messages import HumanMessage - -from danswer.chat.chat_utils import llm_doc_from_inference_section -from danswer.chat.models import AnswerQuestionPossibleReturn -from danswer.chat.models import CitationInfo -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import LlmDoc -from danswer.configs.chat_configs import QA_PROMPT_OVERRIDE -from danswer.file_store.utils import InMemoryChatFile -from danswer.llm.answering.models import AnswerStyleConfig -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.models import StreamProcessor -from danswer.llm.answering.prompts.build import AnswerPromptBuilder -from danswer.llm.answering.prompts.build import default_build_system_message -from danswer.llm.answering.prompts.build import default_build_user_message -from danswer.llm.answering.prompts.citations_prompt import ( - build_citations_system_message, -) -from danswer.llm.answering.prompts.citations_prompt import build_citations_user_message -from danswer.llm.answering.prompts.quotes_prompt import build_quotes_user_message -from danswer.llm.answering.stream_processing.citation_processing import ( - build_citation_processor, -) -from danswer.llm.answering.stream_processing.quotes_processing import ( - build_quotes_processor, -) -from danswer.llm.answering.stream_processing.utils import DocumentIdOrderMapping -from danswer.llm.answering.stream_processing.utils import map_document_id_order -from danswer.llm.interfaces import LLM -from danswer.llm.utils import message_generator_to_string_generator -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.tools.custom.custom_tool_prompt_builder import ( - build_user_message_for_custom_tool_for_non_tool_calling_llm, -) -from danswer.tools.force import filter_tools_for_force_tool_use -from danswer.tools.force import ForceUseTool -from danswer.tools.images.image_generation_tool import IMAGE_GENERATION_RESPONSE_ID -from danswer.tools.images.image_generation_tool import ImageGenerationResponse -from danswer.tools.images.image_generation_tool import ImageGenerationTool -from danswer.tools.images.prompt import build_image_generation_user_prompt -from danswer.tools.internet_search.internet_search_tool import InternetSearchTool -from danswer.tools.message import build_tool_message -from danswer.tools.message import ToolCallSummary -from danswer.tools.search.search_tool import FINAL_CONTEXT_DOCUMENTS -from danswer.tools.search.search_tool import SEARCH_DOC_CONTENT_ID -from danswer.tools.search.search_tool import SEARCH_RESPONSE_SUMMARY_ID -from danswer.tools.search.search_tool import SearchResponseSummary -from danswer.tools.search.search_tool import SearchTool -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.tools.tool_runner import ( - check_which_tools_should_run_for_non_tool_calling_llm, -) -from danswer.tools.tool_runner import ToolCallFinalResult -from danswer.tools.tool_runner import ToolCallKickoff -from danswer.tools.tool_runner import ToolRunner -from danswer.tools.tool_selection import select_single_tool_for_non_tool_calling_llm -from danswer.tools.utils import explicit_tool_calling_supported -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def _get_answer_stream_processor( - context_docs: list[LlmDoc], - doc_id_to_rank_map: DocumentIdOrderMapping, - answer_style_configs: AnswerStyleConfig, -) -> StreamProcessor: - if answer_style_configs.citation_config: - return build_citation_processor( - context_docs=context_docs, doc_id_to_rank_map=doc_id_to_rank_map - ) - if answer_style_configs.quotes_config: - return build_quotes_processor( - context_docs=context_docs, is_json_prompt=not (QA_PROMPT_OVERRIDE == "weak") - ) - - raise RuntimeError("Not implemented yet") - - -AnswerStream = Iterator[AnswerQuestionPossibleReturn | ToolCallKickoff | ToolResponse] - - -logger = setup_logger() - - -class Answer: - def __init__( - self, - question: str, - answer_style_config: AnswerStyleConfig, - llm: LLM, - prompt_config: PromptConfig, - force_use_tool: ForceUseTool, - # must be the same length as `docs`. If None, all docs are considered "relevant" - message_history: list[PreviousMessage] | None = None, - single_message_history: str | None = None, - # newly passed in files to include as part of this question - # TODO THIS NEEDS TO BE HANDLED - latest_query_files: list[InMemoryChatFile] | None = None, - files: list[InMemoryChatFile] | None = None, - tools: list[Tool] | None = None, - # NOTE: for native tool-calling, this is only supported by OpenAI atm, - # but we only support them anyways - # if set to True, then never use the LLMs provided tool-calling functonality - skip_explicit_tool_calling: bool = False, - # Returns the full document sections text from the search tool - return_contexts: bool = False, - skip_gen_ai_answer_generation: bool = False, - is_connected: Callable[[], bool] | None = None, - ) -> None: - if single_message_history and message_history: - raise ValueError( - "Cannot provide both `message_history` and `single_message_history`" - ) - - self.question = question - self.is_connected: Callable[[], bool] | None = is_connected - - self.latest_query_files = latest_query_files or [] - self.file_id_to_file = {file.file_id: file for file in (files or [])} - - self.tools = tools or [] - self.force_use_tool = force_use_tool - - self.skip_explicit_tool_calling = skip_explicit_tool_calling - - self.message_history = message_history or [] - # used for QA flow where we only want to send a single message - self.single_message_history = single_message_history - - self.answer_style_config = answer_style_config - self.prompt_config = prompt_config - - self.llm = llm - self.llm_tokenizer = get_tokenizer( - provider_type=llm.config.model_provider, - model_name=llm.config.model_name, - ) - - self._final_prompt: list[BaseMessage] | None = None - - self._streamed_output: list[str] | None = None - self._processed_stream: ( - list[AnswerQuestionPossibleReturn | ToolResponse | ToolCallKickoff] | None - ) = None - - self._return_contexts = return_contexts - self.skip_gen_ai_answer_generation = skip_gen_ai_answer_generation - self._is_cancelled = False - - def _update_prompt_builder_for_search_tool( - self, prompt_builder: AnswerPromptBuilder, final_context_documents: list[LlmDoc] - ) -> None: - if self.answer_style_config.citation_config: - prompt_builder.update_system_prompt( - build_citations_system_message(self.prompt_config) - ) - prompt_builder.update_user_prompt( - build_citations_user_message( - question=self.question, - prompt_config=self.prompt_config, - context_docs=final_context_documents, - files=self.latest_query_files, - all_doc_useful=( - self.answer_style_config.citation_config.all_docs_useful - if self.answer_style_config.citation_config - else False - ), - ) - ) - elif self.answer_style_config.quotes_config: - prompt_builder.update_user_prompt( - build_quotes_user_message( - question=self.question, - context_docs=final_context_documents, - history_str=self.single_message_history or "", - prompt=self.prompt_config, - ) - ) - - def _raw_output_for_explicit_tool_calling_llms( - self, - ) -> Iterator[str | ToolCallKickoff | ToolResponse | ToolCallFinalResult]: - prompt_builder = AnswerPromptBuilder(self.message_history, self.llm.config) - - tool_call_chunk: AIMessageChunk | None = None - if self.force_use_tool.force_use and self.force_use_tool.args is not None: - # if we are forcing a tool WITH args specified, we don't need to check which tools to run - # / need to generate the args - tool_call_chunk = AIMessageChunk( - content="", - ) - tool_call_chunk.tool_calls = [ - { - "name": self.force_use_tool.tool_name, - "args": self.force_use_tool.args, - "id": str(uuid4()), - } - ] - else: - # if tool calling is supported, first try the raw message - # to see if we don't need to use any tools - prompt_builder.update_system_prompt( - default_build_system_message(self.prompt_config) - ) - prompt_builder.update_user_prompt( - default_build_user_message( - self.question, self.prompt_config, self.latest_query_files - ) - ) - prompt = prompt_builder.build() - final_tool_definitions = [ - tool.tool_definition() - for tool in filter_tools_for_force_tool_use( - self.tools, self.force_use_tool - ) - ] - for message in self.llm.stream( - prompt=prompt, - tools=final_tool_definitions if final_tool_definitions else None, - tool_choice="required" if self.force_use_tool.force_use else None, - ): - if isinstance(message, AIMessageChunk) and ( - message.tool_call_chunks or message.tool_calls - ): - if tool_call_chunk is None: - tool_call_chunk = message - else: - tool_call_chunk += message # type: ignore - else: - if message.content: - if self.is_cancelled: - return - yield cast(str, message.content) - - if not tool_call_chunk: - return # no tool call needed - - # if we have a tool call, we need to call the tool - tool_call_requests = tool_call_chunk.tool_calls - for tool_call_request in tool_call_requests: - known_tools_by_name = [ - tool for tool in self.tools if tool.name == tool_call_request["name"] - ] - - if not known_tools_by_name: - logger.error( - "Tool call requested with unknown name field. \n" - f"self.tools: {self.tools}" - f"tool_call_request: {tool_call_request}" - ) - if self.tools: - tool = self.tools[0] - else: - continue - else: - tool = known_tools_by_name[0] - tool_args = ( - self.force_use_tool.args - if self.force_use_tool.tool_name == tool.name - and self.force_use_tool.args - else tool_call_request["args"] - ) - - tool_runner = ToolRunner(tool, tool_args) - yield tool_runner.kickoff() - yield from tool_runner.tool_responses() - - tool_call_summary = ToolCallSummary( - tool_call_request=tool_call_chunk, - tool_call_result=build_tool_message( - tool_call_request, tool_runner.tool_message_content() - ), - ) - - if tool.name in {SearchTool._NAME, InternetSearchTool._NAME}: - self._update_prompt_builder_for_search_tool(prompt_builder, []) - elif tool.name == ImageGenerationTool._NAME: - img_urls = [ - img_generation_result["url"] - for img_generation_result in tool_runner.tool_final_result().tool_result - ] - prompt_builder.update_user_prompt( - build_image_generation_user_prompt( - query=self.question, img_urls=img_urls - ) - ) - yield tool_runner.tool_final_result() - - prompt = prompt_builder.build(tool_call_summary=tool_call_summary) - for token in message_generator_to_string_generator( - self.llm.stream( - prompt=prompt, - tools=[tool.tool_definition() for tool in self.tools], - ) - ): - if self.is_cancelled: - return - yield token - - return - - def _raw_output_for_non_explicit_tool_calling_llms( - self, - ) -> Iterator[str | ToolCallKickoff | ToolResponse | ToolCallFinalResult]: - prompt_builder = AnswerPromptBuilder(self.message_history, self.llm.config) - chosen_tool_and_args: tuple[Tool, dict] | None = None - - if self.force_use_tool.force_use: - # if we are forcing a tool, we don't need to check which tools to run - tool = next( - iter( - [ - tool - for tool in self.tools - if tool.name == self.force_use_tool.tool_name - ] - ), - None, - ) - if not tool: - raise RuntimeError(f"Tool '{self.force_use_tool.tool_name}' not found") - - tool_args = ( - self.force_use_tool.args - if self.force_use_tool.args is not None - else tool.get_args_for_non_tool_calling_llm( - query=self.question, - history=self.message_history, - llm=self.llm, - force_run=True, - ) - ) - - if tool_args is None: - raise RuntimeError(f"Tool '{tool.name}' did not return args") - - chosen_tool_and_args = (tool, tool_args) - else: - tool_options = check_which_tools_should_run_for_non_tool_calling_llm( - tools=self.tools, - query=self.question, - history=self.message_history, - llm=self.llm, - ) - - available_tools_and_args = [ - (self.tools[ind], args) - for ind, args in enumerate(tool_options) - if args is not None - ] - - logger.info( - f"Selecting single tool from tools: {[(tool.name, args) for tool, args in available_tools_and_args]}" - ) - - chosen_tool_and_args = ( - select_single_tool_for_non_tool_calling_llm( - tools_and_args=available_tools_and_args, - history=self.message_history, - query=self.question, - llm=self.llm, - ) - if available_tools_and_args - else None - ) - - logger.notice(f"Chosen tool: {chosen_tool_and_args}") - - if not chosen_tool_and_args: - prompt_builder.update_system_prompt( - default_build_system_message(self.prompt_config) - ) - prompt_builder.update_user_prompt( - default_build_user_message( - self.question, self.prompt_config, self.latest_query_files - ) - ) - prompt = prompt_builder.build() - for token in message_generator_to_string_generator( - self.llm.stream(prompt=prompt) - ): - if self.is_cancelled: - return - yield token - - return - - tool, tool_args = chosen_tool_and_args - tool_runner = ToolRunner(tool, tool_args) - yield tool_runner.kickoff() - - if tool.name in {SearchTool._NAME, InternetSearchTool._NAME}: - final_context_documents = None - for response in tool_runner.tool_responses(): - if response.id == FINAL_CONTEXT_DOCUMENTS: - final_context_documents = cast(list[LlmDoc], response.response) - yield response - - if final_context_documents is None: - raise RuntimeError( - f"{tool.name} did not return final context documents" - ) - - self._update_prompt_builder_for_search_tool( - prompt_builder, final_context_documents - ) - elif tool.name == ImageGenerationTool._NAME: - img_urls = [] - for response in tool_runner.tool_responses(): - if response.id == IMAGE_GENERATION_RESPONSE_ID: - img_generation_response = cast( - list[ImageGenerationResponse], response.response - ) - img_urls = [img.url for img in img_generation_response] - - yield response - - prompt_builder.update_user_prompt( - build_image_generation_user_prompt( - query=self.question, - img_urls=img_urls, - ) - ) - else: - prompt_builder.update_user_prompt( - HumanMessage( - content=build_user_message_for_custom_tool_for_non_tool_calling_llm( - self.question, - tool.name, - *tool_runner.tool_responses(), - ) - ) - ) - final = tool_runner.tool_final_result() - - yield final - - prompt = prompt_builder.build() - for token in message_generator_to_string_generator( - self.llm.stream(prompt=prompt) - ): - if self.is_cancelled: - return - yield token - - @property - def processed_streamed_output(self) -> AnswerStream: - if self._processed_stream is not None: - yield from self._processed_stream - return - - output_generator = ( - self._raw_output_for_explicit_tool_calling_llms() - if explicit_tool_calling_supported( - self.llm.config.model_provider, self.llm.config.model_name - ) - and not self.skip_explicit_tool_calling - else self._raw_output_for_non_explicit_tool_calling_llms() - ) - - def _process_stream( - stream: Iterator[ToolCallKickoff | ToolResponse | str], - ) -> AnswerStream: - message = None - - # special things we need to keep track of for the SearchTool - search_results: list[ - LlmDoc - ] | None = None # raw results that will be displayed to the user - final_context_docs: list[ - LlmDoc - ] | None = None # processed docs to feed into the LLM - - for message in stream: - if isinstance(message, ToolCallKickoff) or isinstance( - message, ToolCallFinalResult - ): - yield message - elif isinstance(message, ToolResponse): - if message.id == SEARCH_RESPONSE_SUMMARY_ID: - # We don't need to run section merging in this flow, this variable is only used - # below to specify the ordering of the documents for the purpose of matching - # citations to the right search documents. The deduplication logic is more lightweight - # there and we don't need to do it twice - search_results = [ - llm_doc_from_inference_section(section) - for section in cast( - SearchResponseSummary, message.response - ).top_sections - ] - elif message.id == FINAL_CONTEXT_DOCUMENTS: - final_context_docs = cast(list[LlmDoc], message.response) - - elif ( - message.id == SEARCH_DOC_CONTENT_ID - and not self._return_contexts - ): - continue - - yield message - else: - # assumes all tool responses will come first, then the final answer - break - - if not self.skip_gen_ai_answer_generation: - process_answer_stream_fn = _get_answer_stream_processor( - context_docs=final_context_docs or [], - # if doc selection is enabled, then search_results will be None, - # so we need to use the final_context_docs - doc_id_to_rank_map=map_document_id_order( - search_results or final_context_docs or [] - ), - answer_style_configs=self.answer_style_config, - ) - - def _stream() -> Iterator[str]: - if message: - yield cast(str, message) - yield from cast(Iterator[str], stream) - - yield from process_answer_stream_fn(_stream()) - - processed_stream = [] - for processed_packet in _process_stream(output_generator): - processed_stream.append(processed_packet) - yield processed_packet - - self._processed_stream = processed_stream - - @property - def llm_answer(self) -> str: - answer = "" - for packet in self.processed_streamed_output: - if isinstance(packet, DanswerAnswerPiece) and packet.answer_piece: - answer += packet.answer_piece - - return answer - - @property - def citations(self) -> list[CitationInfo]: - citations: list[CitationInfo] = [] - for packet in self.processed_streamed_output: - if isinstance(packet, CitationInfo): - citations.append(packet) - - return citations - - @property - def is_cancelled(self) -> bool: - if self._is_cancelled: - return True - - if self.is_connected is not None: - if not self.is_connected(): - logger.debug("Answer stream has been cancelled") - self._is_cancelled = not self.is_connected() - - return self._is_cancelled diff --git a/backend/danswer/llm/answering/models.py b/backend/danswer/llm/answering/models.py deleted file mode 100644 index fb5fa9c313e..00000000000 --- a/backend/danswer/llm/answering/models.py +++ /dev/null @@ -1,160 +0,0 @@ -from collections.abc import Callable -from collections.abc import Iterator -from typing import TYPE_CHECKING - -from langchain.schema.messages import AIMessage -from langchain.schema.messages import BaseMessage -from langchain.schema.messages import HumanMessage -from langchain.schema.messages import SystemMessage -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import model_validator - -from danswer.chat.models import AnswerQuestionStreamReturn -from danswer.configs.constants import MessageType -from danswer.file_store.models import InMemoryChatFile -from danswer.llm.override_models import PromptOverride -from danswer.llm.utils import build_content_with_imgs -from danswer.tools.models import ToolCallFinalResult - -if TYPE_CHECKING: - from danswer.db.models import ChatMessage - from danswer.db.models import Prompt - - -StreamProcessor = Callable[[Iterator[str]], AnswerQuestionStreamReturn] - - -class PreviousMessage(BaseModel): - """Simplified version of `ChatMessage`""" - - message: str - token_count: int - message_type: MessageType - files: list[InMemoryChatFile] - tool_calls: list[ToolCallFinalResult] - - @classmethod - def from_chat_message( - cls, chat_message: "ChatMessage", available_files: list[InMemoryChatFile] - ) -> "PreviousMessage": - message_file_ids = ( - [file["id"] for file in chat_message.files] if chat_message.files else [] - ) - return cls( - message=chat_message.message, - token_count=chat_message.token_count, - message_type=chat_message.message_type, - files=[ - file - for file in available_files - if str(file.file_id) in message_file_ids - ], - tool_calls=[ - ToolCallFinalResult( - tool_name=tool_call.tool_name, - tool_args=tool_call.tool_arguments, - tool_result=tool_call.tool_result, - ) - for tool_call in chat_message.tool_calls - ], - ) - - def to_langchain_msg(self) -> BaseMessage: - content = build_content_with_imgs(self.message, self.files) - if self.message_type == MessageType.USER: - return HumanMessage(content=content) - elif self.message_type == MessageType.ASSISTANT: - return AIMessage(content=content) - else: - return SystemMessage(content=content) - - -class DocumentPruningConfig(BaseModel): - max_chunks: int | None = None - max_window_percentage: float | None = None - max_tokens: int | None = None - # different pruning behavior is expected when the - # user manually selects documents they want to chat with - # e.g. we don't want to truncate each document to be no more - # than one chunk long - is_manually_selected_docs: bool = False - # If user specifies to include additional context Chunks for each match, then different pruning - # is used. As many Sections as possible are included, and the last Section is truncated - # If this is false, all of the Sections are truncated if they are longer than the expected Chunk size. - # Sections are often expected to be longer than the maximum Chunk size but Chunks should not be. - use_sections: bool = True - # If using tools, then we need to consider the tool length - tool_num_tokens: int = 0 - # If using a tool message to represent the docs, then we have to JSON serialize - # the document content, which adds to the token count. - using_tool_message: bool = False - - -class ContextualPruningConfig(DocumentPruningConfig): - num_chunk_multiple: int - - @classmethod - def from_doc_pruning_config( - cls, num_chunk_multiple: int, doc_pruning_config: DocumentPruningConfig - ) -> "ContextualPruningConfig": - return cls(num_chunk_multiple=num_chunk_multiple, **doc_pruning_config.dict()) - - -class CitationConfig(BaseModel): - all_docs_useful: bool = False - - -class QuotesConfig(BaseModel): - pass - - -class AnswerStyleConfig(BaseModel): - citation_config: CitationConfig | None = None - quotes_config: QuotesConfig | None = None - document_pruning_config: DocumentPruningConfig = Field( - default_factory=DocumentPruningConfig - ) - - @model_validator(mode="after") - def check_quotes_and_citation(self) -> "AnswerStyleConfig": - if self.citation_config is None and self.quotes_config is None: - raise ValueError( - "One of `citation_config` or `quotes_config` must be provided" - ) - - if self.citation_config is not None and self.quotes_config is not None: - raise ValueError( - "Only one of `citation_config` or `quotes_config` must be provided" - ) - - return self - - -class PromptConfig(BaseModel): - """Final representation of the Prompt configuration passed - into the `Answer` object.""" - - system_prompt: str - task_prompt: str - datetime_aware: bool - include_citations: bool - - @classmethod - def from_model( - cls, model: "Prompt", prompt_override: PromptOverride | None = None - ) -> "PromptConfig": - override_system_prompt = ( - prompt_override.system_prompt if prompt_override else None - ) - override_task_prompt = prompt_override.task_prompt if prompt_override else None - - return cls( - system_prompt=override_system_prompt or model.system_prompt, - task_prompt=override_task_prompt or model.task_prompt, - datetime_aware=model.datetime_aware, - include_citations=model.include_citations, - ) - - model_config = ConfigDict(frozen=True) diff --git a/backend/danswer/llm/answering/prompts/build.py b/backend/danswer/llm/answering/prompts/build.py deleted file mode 100644 index f53d4481f6e..00000000000 --- a/backend/danswer/llm/answering/prompts/build.py +++ /dev/null @@ -1,138 +0,0 @@ -from collections.abc import Callable -from typing import cast - -from langchain_core.messages import BaseMessage -from langchain_core.messages import HumanMessage -from langchain_core.messages import SystemMessage - -from danswer.file_store.models import InMemoryChatFile -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.prompts.citations_prompt import compute_max_llm_input_tokens -from danswer.llm.interfaces import LLMConfig -from danswer.llm.utils import build_content_with_imgs -from danswer.llm.utils import check_message_tokens -from danswer.llm.utils import translate_history_to_basemessages -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.prompts.chat_prompts import CHAT_USER_CONTEXT_FREE_PROMPT -from danswer.prompts.prompt_utils import add_date_time_to_prompt -from danswer.prompts.prompt_utils import drop_messages_history_overflow -from danswer.tools.message import ToolCallSummary - - -def default_build_system_message( - prompt_config: PromptConfig, -) -> SystemMessage | None: - system_prompt = prompt_config.system_prompt.strip() - if prompt_config.datetime_aware: - system_prompt = add_date_time_to_prompt(prompt_str=system_prompt) - - if not system_prompt: - return None - - system_msg = SystemMessage(content=system_prompt) - - return system_msg - - -def default_build_user_message( - user_query: str, prompt_config: PromptConfig, files: list[InMemoryChatFile] = [] -) -> HumanMessage: - user_prompt = ( - CHAT_USER_CONTEXT_FREE_PROMPT.format( - task_prompt=prompt_config.task_prompt, user_query=user_query - ) - if prompt_config.task_prompt - else user_query - ) - user_prompt = user_prompt.strip() - user_msg = HumanMessage( - content=build_content_with_imgs(user_prompt, files) if files else user_prompt - ) - return user_msg - - -class AnswerPromptBuilder: - def __init__( - self, message_history: list[PreviousMessage], llm_config: LLMConfig - ) -> None: - self.max_tokens = compute_max_llm_input_tokens(llm_config) - - ( - self.message_history, - self.history_token_cnts, - ) = translate_history_to_basemessages(message_history) - - self.system_message_and_token_cnt: tuple[SystemMessage, int] | None = None - self.user_message_and_token_cnt: tuple[HumanMessage, int] | None = None - - llm_tokenizer = get_tokenizer( - provider_type=llm_config.model_provider, - model_name=llm_config.model_name, - ) - self.llm_tokenizer_encode_func = cast( - Callable[[str], list[int]], llm_tokenizer.encode - ) - - def update_system_prompt(self, system_message: SystemMessage | None) -> None: - if not system_message: - self.system_message_and_token_cnt = None - return - - self.system_message_and_token_cnt = ( - system_message, - check_message_tokens(system_message, self.llm_tokenizer_encode_func), - ) - - def update_user_prompt(self, user_message: HumanMessage) -> None: - if not user_message: - self.user_message_and_token_cnt = None - return - - self.user_message_and_token_cnt = ( - user_message, - check_message_tokens(user_message, self.llm_tokenizer_encode_func), - ) - - def build( - self, tool_call_summary: ToolCallSummary | None = None - ) -> list[BaseMessage]: - if not self.user_message_and_token_cnt: - raise ValueError("User message must be set before building prompt") - - final_messages_with_tokens: list[tuple[BaseMessage, int]] = [] - if self.system_message_and_token_cnt: - final_messages_with_tokens.append(self.system_message_and_token_cnt) - - final_messages_with_tokens.extend( - [ - (self.message_history[i], self.history_token_cnts[i]) - for i in range(len(self.message_history)) - ] - ) - - final_messages_with_tokens.append(self.user_message_and_token_cnt) - - if tool_call_summary: - final_messages_with_tokens.append( - ( - tool_call_summary.tool_call_request, - check_message_tokens( - tool_call_summary.tool_call_request, - self.llm_tokenizer_encode_func, - ), - ) - ) - final_messages_with_tokens.append( - ( - tool_call_summary.tool_call_result, - check_message_tokens( - tool_call_summary.tool_call_result, - self.llm_tokenizer_encode_func, - ), - ) - ) - - return drop_messages_history_overflow( - final_messages_with_tokens, self.max_tokens - ) diff --git a/backend/danswer/llm/answering/prompts/citations_prompt.py b/backend/danswer/llm/answering/prompts/citations_prompt.py deleted file mode 100644 index eddae9badb4..00000000000 --- a/backend/danswer/llm/answering/prompts/citations_prompt.py +++ /dev/null @@ -1,166 +0,0 @@ -from langchain.schema.messages import HumanMessage -from langchain.schema.messages import SystemMessage - -from danswer.chat.models import LlmDoc -from danswer.configs.model_configs import GEN_AI_SINGLE_USER_MESSAGE_EXPECTED_MAX_TOKENS -from danswer.db.models import Persona -from danswer.db.persona import get_default_prompt__read_only -from danswer.db.search_settings import get_multilingual_expansion -from danswer.file_store.utils import InMemoryChatFile -from danswer.llm.answering.models import PromptConfig -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.factory import get_main_llm_from_tuple -from danswer.llm.interfaces import LLMConfig -from danswer.llm.utils import build_content_with_imgs -from danswer.llm.utils import check_number_of_tokens -from danswer.llm.utils import get_max_input_tokens -from danswer.prompts.chat_prompts import REQUIRE_CITATION_STATEMENT -from danswer.prompts.constants import DEFAULT_IGNORE_STATEMENT -from danswer.prompts.direct_qa_prompts import CITATIONS_PROMPT -from danswer.prompts.direct_qa_prompts import CITATIONS_PROMPT_FOR_TOOL_CALLING -from danswer.prompts.prompt_utils import add_date_time_to_prompt -from danswer.prompts.prompt_utils import build_complete_context_str -from danswer.prompts.prompt_utils import build_task_prompt_reminders -from danswer.prompts.token_counts import ADDITIONAL_INFO_TOKEN_CNT -from danswer.prompts.token_counts import ( - CHAT_USER_PROMPT_WITH_CONTEXT_OVERHEAD_TOKEN_CNT, -) -from danswer.prompts.token_counts import CITATION_REMINDER_TOKEN_CNT -from danswer.prompts.token_counts import CITATION_STATEMENT_TOKEN_CNT -from danswer.prompts.token_counts import LANGUAGE_HINT_TOKEN_CNT -from danswer.search.models import InferenceChunk - - -def get_prompt_tokens(prompt_config: PromptConfig) -> int: - # Note: currently custom prompts do not allow datetime aware, only default prompts - return ( - check_number_of_tokens(prompt_config.system_prompt) - + check_number_of_tokens(prompt_config.task_prompt) - + CHAT_USER_PROMPT_WITH_CONTEXT_OVERHEAD_TOKEN_CNT - + CITATION_STATEMENT_TOKEN_CNT - + CITATION_REMINDER_TOKEN_CNT - + (LANGUAGE_HINT_TOKEN_CNT if get_multilingual_expansion() else 0) - + (ADDITIONAL_INFO_TOKEN_CNT if prompt_config.datetime_aware else 0) - ) - - -# buffer just to be safe so that we don't overflow the token limit due to -# a small miscalculation -_MISC_BUFFER = 40 - - -def compute_max_document_tokens( - prompt_config: PromptConfig, - llm_config: LLMConfig, - actual_user_input: str | None = None, - tool_token_count: int = 0, - max_llm_token_override: int | None = None, -) -> int: - """Estimates the number of tokens available for context documents. Formula is roughly: - - ( - model_context_window - reserved_output_tokens - prompt_tokens - - (actual_user_input OR reserved_user_message_tokens) - buffer (just to be safe) - ) - - The actual_user_input is used at query time. If we are calculating this before knowing the exact input (e.g. - if we're trying to determine if the user should be able to select another document) then we just set an - arbitrary "upper bound". - """ - # if we can't find a number of tokens, just assume some common default - max_input_tokens = ( - max_llm_token_override - if max_llm_token_override - else get_max_input_tokens( - model_name=llm_config.model_name, model_provider=llm_config.model_provider - ) - ) - prompt_tokens = get_prompt_tokens(prompt_config) - - user_input_tokens = ( - check_number_of_tokens(actual_user_input) - if actual_user_input is not None - else GEN_AI_SINGLE_USER_MESSAGE_EXPECTED_MAX_TOKENS - ) - - return ( - max_input_tokens - - prompt_tokens - - user_input_tokens - - tool_token_count - - _MISC_BUFFER - ) - - -def compute_max_document_tokens_for_persona( - persona: Persona, - actual_user_input: str | None = None, - max_llm_token_override: int | None = None, -) -> int: - prompt = persona.prompts[0] if persona.prompts else get_default_prompt__read_only() - return compute_max_document_tokens( - prompt_config=PromptConfig.from_model(prompt), - llm_config=get_main_llm_from_tuple(get_llms_for_persona(persona)).config, - actual_user_input=actual_user_input, - max_llm_token_override=max_llm_token_override, - ) - - -def compute_max_llm_input_tokens(llm_config: LLMConfig) -> int: - """Maximum tokens allows in the input to the LLM (of any type).""" - - input_tokens = get_max_input_tokens( - model_name=llm_config.model_name, model_provider=llm_config.model_provider - ) - return input_tokens - _MISC_BUFFER - - -def build_citations_system_message( - prompt_config: PromptConfig, -) -> SystemMessage: - system_prompt = prompt_config.system_prompt.strip() - if prompt_config.include_citations: - system_prompt += REQUIRE_CITATION_STATEMENT - if prompt_config.datetime_aware: - system_prompt = add_date_time_to_prompt(prompt_str=system_prompt) - - return SystemMessage(content=system_prompt) - - -def build_citations_user_message( - question: str, - prompt_config: PromptConfig, - context_docs: list[LlmDoc] | list[InferenceChunk], - files: list[InMemoryChatFile], - all_doc_useful: bool, - history_message: str = "", -) -> HumanMessage: - multilingual_expansion = get_multilingual_expansion() - task_prompt_with_reminder = build_task_prompt_reminders( - prompt=prompt_config, use_language_hint=bool(multilingual_expansion) - ) - - if context_docs: - context_docs_str = build_complete_context_str(context_docs) - optional_ignore = "" if all_doc_useful else DEFAULT_IGNORE_STATEMENT - - user_prompt = CITATIONS_PROMPT.format( - optional_ignore_statement=optional_ignore, - context_docs_str=context_docs_str, - task_prompt=task_prompt_with_reminder, - user_query=question, - history_block=history_message, - ) - else: - # if no context docs provided, assume we're in the tool calling flow - user_prompt = CITATIONS_PROMPT_FOR_TOOL_CALLING.format( - task_prompt=task_prompt_with_reminder, - user_query=question, - ) - - user_prompt = user_prompt.strip() - user_msg = HumanMessage( - content=build_content_with_imgs(user_prompt, files) if files else user_prompt - ) - - return user_msg diff --git a/backend/danswer/llm/answering/prompts/quotes_prompt.py b/backend/danswer/llm/answering/prompts/quotes_prompt.py deleted file mode 100644 index 07abc4356b6..00000000000 --- a/backend/danswer/llm/answering/prompts/quotes_prompt.py +++ /dev/null @@ -1,114 +0,0 @@ -from langchain.schema.messages import HumanMessage - -from danswer.chat.models import LlmDoc -from danswer.configs.chat_configs import LANGUAGE_HINT -from danswer.configs.chat_configs import QA_PROMPT_OVERRIDE -from danswer.db.search_settings import get_multilingual_expansion -from danswer.llm.answering.models import PromptConfig -from danswer.prompts.direct_qa_prompts import CONTEXT_BLOCK -from danswer.prompts.direct_qa_prompts import HISTORY_BLOCK -from danswer.prompts.direct_qa_prompts import JSON_PROMPT -from danswer.prompts.direct_qa_prompts import WEAK_LLM_PROMPT -from danswer.prompts.prompt_utils import add_date_time_to_prompt -from danswer.prompts.prompt_utils import build_complete_context_str -from danswer.search.models import InferenceChunk - - -def _build_weak_llm_quotes_prompt( - question: str, - context_docs: list[LlmDoc] | list[InferenceChunk], - history_str: str, - prompt: PromptConfig, -) -> HumanMessage: - """Since Danswer supports a variety of LLMs, this less demanding prompt is provided - as an option to use with weaker LLMs such as small version, low float precision, quantized, - or distilled models. It only uses one context document and has very weak requirements of - output format. - """ - context_block = "" - if context_docs: - context_block = CONTEXT_BLOCK.format(context_docs_str=context_docs[0].content) - - prompt_str = WEAK_LLM_PROMPT.format( - system_prompt=prompt.system_prompt, - context_block=context_block, - task_prompt=prompt.task_prompt, - user_query=question, - ) - - if prompt.datetime_aware: - prompt_str = add_date_time_to_prompt(prompt_str=prompt_str) - - return HumanMessage(content=prompt_str) - - -def _build_strong_llm_quotes_prompt( - question: str, - context_docs: list[LlmDoc] | list[InferenceChunk], - history_str: str, - prompt: PromptConfig, -) -> HumanMessage: - use_language_hint = bool(get_multilingual_expansion()) - - context_block = "" - if context_docs: - context_docs_str = build_complete_context_str(context_docs) - context_block = CONTEXT_BLOCK.format(context_docs_str=context_docs_str) - - history_block = "" - if history_str: - history_block = HISTORY_BLOCK.format(history_str=history_str) - - full_prompt = JSON_PROMPT.format( - system_prompt=prompt.system_prompt, - context_block=context_block, - history_block=history_block, - task_prompt=prompt.task_prompt, - user_query=question, - language_hint_or_none=LANGUAGE_HINT.strip() if use_language_hint else "", - ).strip() - - if prompt.datetime_aware: - full_prompt = add_date_time_to_prompt(prompt_str=full_prompt) - - return HumanMessage(content=full_prompt) - - -def build_quotes_user_message( - question: str, - context_docs: list[LlmDoc] | list[InferenceChunk], - history_str: str, - prompt: PromptConfig, -) -> HumanMessage: - prompt_builder = ( - _build_weak_llm_quotes_prompt - if QA_PROMPT_OVERRIDE == "weak" - else _build_strong_llm_quotes_prompt - ) - - return prompt_builder( - question=question, - context_docs=context_docs, - history_str=history_str, - prompt=prompt, - ) - - -def build_quotes_prompt( - question: str, - context_docs: list[LlmDoc] | list[InferenceChunk], - history_str: str, - prompt: PromptConfig, -) -> HumanMessage: - prompt_builder = ( - _build_weak_llm_quotes_prompt - if QA_PROMPT_OVERRIDE == "weak" - else _build_strong_llm_quotes_prompt - ) - - return prompt_builder( - question=question, - context_docs=context_docs, - history_str=history_str, - prompt=prompt, - ) diff --git a/backend/danswer/llm/answering/prompts/utils.py b/backend/danswer/llm/answering/prompts/utils.py deleted file mode 100644 index bcc8b891815..00000000000 --- a/backend/danswer/llm/answering/prompts/utils.py +++ /dev/null @@ -1,20 +0,0 @@ -from danswer.prompts.direct_qa_prompts import PARAMATERIZED_PROMPT -from danswer.prompts.direct_qa_prompts import PARAMATERIZED_PROMPT_WITHOUT_CONTEXT - - -def build_dummy_prompt( - system_prompt: str, task_prompt: str, retrieval_disabled: bool -) -> str: - if retrieval_disabled: - return PARAMATERIZED_PROMPT_WITHOUT_CONTEXT.format( - user_query="", - system_prompt=system_prompt, - task_prompt=task_prompt, - ).strip() - - return PARAMATERIZED_PROMPT.format( - context_docs_str="", - user_query="", - system_prompt=system_prompt, - task_prompt=task_prompt, - ).strip() diff --git a/backend/danswer/llm/answering/prune_and_merge.py b/backend/danswer/llm/answering/prune_and_merge.py deleted file mode 100644 index 0193de1f2aa..00000000000 --- a/backend/danswer/llm/answering/prune_and_merge.py +++ /dev/null @@ -1,384 +0,0 @@ -import json -from collections import defaultdict -from copy import deepcopy -from typing import TypeVar - -from pydantic import BaseModel - -from danswer.chat.models import ( - LlmDoc, -) -from danswer.configs.constants import IGNORE_FOR_QA -from danswer.configs.model_configs import DOC_EMBEDDING_CONTEXT_SIZE -from danswer.llm.answering.models import ContextualPruningConfig -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.prompts.citations_prompt import compute_max_document_tokens -from danswer.llm.interfaces import LLMConfig -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.natural_language_processing.utils import tokenizer_trim_content -from danswer.prompts.prompt_utils import build_doc_context_str -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceSection -from danswer.tools.search.search_utils import section_to_dict -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - -T = TypeVar("T", bound=LlmDoc | InferenceChunk | InferenceSection) - -_METADATA_TOKEN_ESTIMATE = 75 -# Title and additional tokens as part of the tool message json -# this is only used to log a warning so we can be more forgiving with the buffer -_OVERCOUNT_ESTIMATE = 256 - - -class PruningError(Exception): - pass - - -class ChunkRange(BaseModel): - chunks: list[InferenceChunk] - start: int - end: int - - -def merge_chunk_intervals(chunk_ranges: list[ChunkRange]) -> list[ChunkRange]: - """ - This acts on a single document to merge the overlapping ranges of chunks - Algo explained here for easy understanding: https://leetcode.com/problems/merge-intervals - - NOTE: this is used to merge chunk ranges for retrieving the right chunk_ids against the - document index, this does not merge the actual contents so it should not be used to actually - merge chunks post retrieval. - """ - sorted_ranges = sorted(chunk_ranges, key=lambda x: x.start) - - combined_ranges: list[ChunkRange] = [] - - for new_chunk_range in sorted_ranges: - if not combined_ranges or combined_ranges[-1].end < new_chunk_range.start - 1: - combined_ranges.append(new_chunk_range) - else: - current_range = combined_ranges[-1] - current_range.end = max(current_range.end, new_chunk_range.end) - current_range.chunks.extend(new_chunk_range.chunks) - - return combined_ranges - - -def _compute_limit( - prompt_config: PromptConfig, - llm_config: LLMConfig, - question: str, - max_chunks: int | None, - max_window_percentage: float | None, - max_tokens: int | None, - tool_token_count: int, -) -> int: - llm_max_document_tokens = compute_max_document_tokens( - prompt_config=prompt_config, - llm_config=llm_config, - tool_token_count=tool_token_count, - actual_user_input=question, - ) - - window_percentage_based_limit = ( - max_window_percentage * llm_max_document_tokens - if max_window_percentage - else None - ) - chunk_count_based_limit = ( - max_chunks * DOC_EMBEDDING_CONTEXT_SIZE if max_chunks else None - ) - - limit_options = [ - lim - for lim in [ - window_percentage_based_limit, - chunk_count_based_limit, - max_tokens, - llm_max_document_tokens, - ] - if lim - ] - return int(min(limit_options)) - - -def reorder_sections( - sections: list[InferenceSection], - section_relevance_list: list[bool] | None, -) -> list[InferenceSection]: - if section_relevance_list is None: - return sections - - reordered_sections: list[InferenceSection] = [] - if section_relevance_list is not None: - for selection_target in [True, False]: - for section, is_relevant in zip(sections, section_relevance_list): - if is_relevant == selection_target: - reordered_sections.append(section) - return reordered_sections - - -def _remove_sections_to_ignore( - sections: list[InferenceSection], -) -> list[InferenceSection]: - return [ - section - for section in sections - if not section.center_chunk.metadata.get(IGNORE_FOR_QA) - ] - - -def _apply_pruning( - sections: list[InferenceSection], - section_relevance_list: list[bool] | None, - token_limit: int, - is_manually_selected_docs: bool, - use_sections: bool, - using_tool_message: bool, - llm_config: LLMConfig, -) -> list[InferenceSection]: - llm_tokenizer = get_tokenizer( - provider_type=llm_config.model_provider, - model_name=llm_config.model_name, - ) - sections = deepcopy(sections) # don't modify in place - - # re-order docs with all the "relevant" docs at the front - sections = reorder_sections( - sections=sections, section_relevance_list=section_relevance_list - ) - # remove docs that are explicitly marked as not for QA - sections = _remove_sections_to_ignore(sections=sections) - - final_section_ind = None - total_tokens = 0 - for ind, section in enumerate(sections): - section_str = ( - # If using tool message, it will be a bit of an overestimate as the extra json text around the section - # will be counted towards the token count. However, once the Sections are merged, the extra json parts - # that overlap will not be counted multiple times like it is in the pruning step. - json.dumps(section_to_dict(section, ind)) - if using_tool_message - else build_doc_context_str( - semantic_identifier=section.center_chunk.semantic_identifier, - source_type=section.center_chunk.source_type, - content=section.combined_content, - metadata_dict=section.center_chunk.metadata, - updated_at=section.center_chunk.updated_at, - ind=ind, - ) - ) - - section_token_count = len(llm_tokenizer.encode(section_str)) - # if not using sections (specifically, using Sections where each section maps exactly to the one center chunk), - # truncate chunks that are way too long. This can happen if the embedding model tokenizer is different - # than the LLM tokenizer - if ( - not is_manually_selected_docs - and not use_sections - and section_token_count - > DOC_EMBEDDING_CONTEXT_SIZE + _METADATA_TOKEN_ESTIMATE - ): - if ( - section_token_count - > DOC_EMBEDDING_CONTEXT_SIZE - + _METADATA_TOKEN_ESTIMATE - + _OVERCOUNT_ESTIMATE - ): - # If the section is just a little bit over, it is likely due to the additional tool message tokens - # no need to record this, the content will be trimmed just in case - logger.warning( - "Found more tokens in Section than expected, " - "likely mismatch between embedding and LLM tokenizers. Trimming content..." - ) - section.combined_content = tokenizer_trim_content( - content=section.combined_content, - desired_length=DOC_EMBEDDING_CONTEXT_SIZE, - tokenizer=llm_tokenizer, - ) - section_token_count = DOC_EMBEDDING_CONTEXT_SIZE - - total_tokens += section_token_count - if total_tokens > token_limit: - final_section_ind = ind - break - - if final_section_ind is not None: - if is_manually_selected_docs or use_sections: - if final_section_ind != len(sections) - 1: - # If using Sections, then the final section could be more than we need, in this case we are willing to - # truncate the final section to fit the specified context window - sections = sections[: final_section_ind + 1] - - if is_manually_selected_docs: - # For document selection flow, only allow the final document/section to get truncated - # if more than that needs to be throw away then some documents are completely thrown away in which - # case this should be reported to the user as an error - raise PruningError( - "LLM context window exceeded. Please de-select some documents or shorten your query." - ) - - amount_to_truncate = total_tokens - token_limit - # NOTE: need to recalculate the length here, since the previous calculation included - # overhead from JSON-fying the doc / the metadata - final_doc_content_length = len( - llm_tokenizer.encode(sections[final_section_ind].combined_content) - ) - (amount_to_truncate) - # this could occur if we only have space for the title / metadata - # not ideal, but it's the most reasonable thing to do - # NOTE: the frontend prevents documents from being selected if - # less than 75 tokens are available to try and avoid this situation - # from occurring in the first place - if final_doc_content_length <= 0: - logger.error( - f"Final section ({sections[final_section_ind].center_chunk.semantic_identifier}) content " - "length is less than 0. Removing this section from the final prompt." - ) - sections.pop() - else: - sections[final_section_ind].combined_content = tokenizer_trim_content( - content=sections[final_section_ind].combined_content, - desired_length=final_doc_content_length, - tokenizer=llm_tokenizer, - ) - else: - # For search on chunk level (Section is just a chunk), don't truncate the final Chunk/Section unless it's the only one - # If it's not the only one, we can throw it away, if it's the only one, we have to truncate - if final_section_ind != 0: - sections = sections[:final_section_ind] - else: - sections[0].combined_content = tokenizer_trim_content( - content=sections[0].combined_content, - desired_length=token_limit - _METADATA_TOKEN_ESTIMATE, - tokenizer=llm_tokenizer, - ) - sections = [sections[0]] - - return sections - - -def prune_sections( - sections: list[InferenceSection], - section_relevance_list: list[bool] | None, - prompt_config: PromptConfig, - llm_config: LLMConfig, - question: str, - contextual_pruning_config: ContextualPruningConfig, -) -> list[InferenceSection]: - # Assumes the sections are score ordered with highest first - if section_relevance_list is not None: - assert len(sections) == len(section_relevance_list) - - actual_num_chunks = ( - contextual_pruning_config.max_chunks - * contextual_pruning_config.num_chunk_multiple - if contextual_pruning_config.max_chunks - else None - ) - - token_limit = _compute_limit( - prompt_config=prompt_config, - llm_config=llm_config, - question=question, - max_chunks=actual_num_chunks, - max_window_percentage=contextual_pruning_config.max_window_percentage, - max_tokens=contextual_pruning_config.max_tokens, - tool_token_count=contextual_pruning_config.tool_num_tokens, - ) - - return _apply_pruning( - sections=sections, - section_relevance_list=section_relevance_list, - token_limit=token_limit, - is_manually_selected_docs=contextual_pruning_config.is_manually_selected_docs, - use_sections=contextual_pruning_config.use_sections, # Now default True - using_tool_message=contextual_pruning_config.using_tool_message, - llm_config=llm_config, - ) - - -def _merge_doc_chunks(chunks: list[InferenceChunk]) -> InferenceSection: - # Assuming there are no duplicates by this point - sorted_chunks = sorted(chunks, key=lambda x: x.chunk_id) - - center_chunk = max( - chunks, key=lambda x: x.score if x.score is not None else float("-inf") - ) - - merged_content = [] - for i, chunk in enumerate(sorted_chunks): - if i > 0: - prev_chunk_id = sorted_chunks[i - 1].chunk_id - if chunk.chunk_id == prev_chunk_id + 1: - merged_content.append("\n") - else: - merged_content.append("\n\n...\n\n") - merged_content.append(chunk.content) - - combined_content = "".join(merged_content) - - return InferenceSection( - center_chunk=center_chunk, - chunks=sorted_chunks, - combined_content=combined_content, - ) - - -def _merge_sections(sections: list[InferenceSection]) -> list[InferenceSection]: - docs_map: dict[str, dict[int, InferenceChunk]] = defaultdict(dict) - doc_order: dict[str, int] = {} - for index, section in enumerate(sections): - if section.center_chunk.document_id not in doc_order: - doc_order[section.center_chunk.document_id] = index - for chunk in [section.center_chunk] + section.chunks: - chunks_map = docs_map[section.center_chunk.document_id] - existing_chunk = chunks_map.get(chunk.chunk_id) - if ( - existing_chunk is None - or existing_chunk.score is None - or chunk.score is not None - and chunk.score > existing_chunk.score - ): - chunks_map[chunk.chunk_id] = chunk - - new_sections = [] - for section_chunks in docs_map.values(): - new_sections.append(_merge_doc_chunks(chunks=list(section_chunks.values()))) - - # Sort by highest score, then by original document order - # It is now 1 large section per doc, the center chunk being the one with the highest score - new_sections.sort( - key=lambda x: ( - x.center_chunk.score if x.center_chunk.score is not None else 0, - -1 * doc_order[x.center_chunk.document_id], - ), - reverse=True, - ) - - return new_sections - - -def prune_and_merge_sections( - sections: list[InferenceSection], - section_relevance_list: list[bool] | None, - prompt_config: PromptConfig, - llm_config: LLMConfig, - question: str, - contextual_pruning_config: ContextualPruningConfig, -) -> list[InferenceSection]: - # Assumes the sections are score ordered with highest first - remaining_sections = prune_sections( - sections=sections, - section_relevance_list=section_relevance_list, - prompt_config=prompt_config, - llm_config=llm_config, - question=question, - contextual_pruning_config=contextual_pruning_config, - ) - - merged_sections = _merge_sections(sections=remaining_sections) - - return merged_sections diff --git a/backend/danswer/llm/answering/stream_processing/citation_processing.py b/backend/danswer/llm/answering/stream_processing/citation_processing.py deleted file mode 100644 index de80b6f6756..00000000000 --- a/backend/danswer/llm/answering/stream_processing/citation_processing.py +++ /dev/null @@ -1,214 +0,0 @@ -import re -from collections.abc import Iterator - -from danswer.chat.models import AnswerQuestionStreamReturn -from danswer.chat.models import CitationInfo -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import LlmDoc -from danswer.configs.chat_configs import STOP_STREAM_PAT -from danswer.llm.answering.models import StreamProcessor -from danswer.llm.answering.stream_processing.utils import DocumentIdOrderMapping -from danswer.prompts.constants import TRIPLE_BACKTICK -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def in_code_block(llm_text: str) -> bool: - count = llm_text.count(TRIPLE_BACKTICK) - return count % 2 != 0 - - -def extract_citations_from_stream( - tokens: Iterator[str], - context_docs: list[LlmDoc], - doc_id_to_rank_map: DocumentIdOrderMapping, - stop_stream: str | None = STOP_STREAM_PAT, -) -> Iterator[DanswerAnswerPiece | CitationInfo]: - """ - Key aspects: - - 1. Stream Processing: - - Processes tokens one by one, allowing for real-time handling of large texts. - - 2. Citation Detection: - - Uses regex to find citations in the format [number]. - - Example: [1], [2], etc. - - 3. Citation Mapping: - - Maps detected citation numbers to actual document ranks using doc_id_to_rank_map. - - Example: [1] might become [3] if doc_id_to_rank_map maps it to 3. - - 4. Citation Formatting: - - Replaces citations with properly formatted versions. - - Adds links if available: [[1]](https://example.com) - - Handles cases where links are not available: [[1]]() - - 5. Duplicate Handling: - - Skips consecutive citations of the same document to avoid redundancy. - - 6. Output Generation: - - Yields DanswerAnswerPiece objects for regular text. - - Yields CitationInfo objects for each unique citation encountered. - - 7. Context Awareness: - - Uses context_docs to access document information for citations. - - This function effectively processes a stream of text, identifies and reformats citations, - and provides both the processed text and citation information as output. - """ - order_mapping = doc_id_to_rank_map.order_mapping - llm_out = "" - max_citation_num = len(context_docs) - citation_order = [] - curr_segment = "" - cited_inds = set() - hold = "" - - raw_out = "" - current_citations: list[int] = [] - past_cite_count = 0 - for raw_token in tokens: - raw_out += raw_token - if stop_stream: - next_hold = hold + raw_token - if stop_stream in next_hold: - break - if next_hold == stop_stream[: len(next_hold)]: - hold = next_hold - continue - token = next_hold - hold = "" - else: - token = raw_token - - curr_segment += token - llm_out += token - - citation_pattern = r"\[(\d+)\]" - - citations_found = list(re.finditer(citation_pattern, curr_segment)) - possible_citation_pattern = r"(\[\d*$)" # [1, [, etc - possible_citation_found = re.search(possible_citation_pattern, curr_segment) - - # `past_cite_count`: number of characters since past citation - # 5 to ensure a citation hasn't occured - if len(citations_found) == 0 and len(llm_out) - past_cite_count > 5: - current_citations = [] - - if citations_found and not in_code_block(llm_out): - last_citation_end = 0 - length_to_add = 0 - while len(citations_found) > 0: - citation = citations_found.pop(0) - numerical_value = int(citation.group(1)) - - if 1 <= numerical_value <= max_citation_num: - context_llm_doc = context_docs[numerical_value - 1] - real_citation_num = order_mapping[context_llm_doc.document_id] - - if real_citation_num not in citation_order: - citation_order.append(real_citation_num) - - target_citation_num = citation_order.index(real_citation_num) + 1 - - # Skip consecutive citations of the same work - if target_citation_num in current_citations: - start, end = citation.span() - real_start = length_to_add + start - diff = end - start - curr_segment = ( - curr_segment[: length_to_add + start] - + curr_segment[real_start + diff :] - ) - length_to_add -= diff - continue - - # Handle edge case where LLM outputs citation itself - # by allowing it to generate citations on its own. - if curr_segment.startswith("[["): - match = re.match(r"\[\[(\d+)\]\]", curr_segment) - if match: - try: - doc_id = int(match.group(1)) - context_llm_doc = context_docs[doc_id - 1] - yield CitationInfo( - citation_num=target_citation_num, - document_id=context_llm_doc.document_id, - ) - except Exception as e: - logger.warning( - f"Manual LLM citation didn't properly cite documents {e}" - ) - else: - # Will continue attempt on next loops - logger.warning( - "Manual LLM citation wasn't able to close brackets" - ) - - continue - - link = context_llm_doc.link - - # Replace the citation in the current segment - start, end = citation.span() - curr_segment = ( - curr_segment[: start + length_to_add] - + f"[{target_citation_num}]" - + curr_segment[end + length_to_add :] - ) - - past_cite_count = len(llm_out) - current_citations.append(target_citation_num) - - if target_citation_num not in cited_inds: - cited_inds.add(target_citation_num) - yield CitationInfo( - citation_num=target_citation_num, - document_id=context_llm_doc.document_id, - ) - - if link: - prev_length = len(curr_segment) - curr_segment = ( - curr_segment[: start + length_to_add] - + f"[[{target_citation_num}]]({link})" - + curr_segment[end + length_to_add :] - ) - length_to_add += len(curr_segment) - prev_length - - else: - prev_length = len(curr_segment) - curr_segment = ( - curr_segment[: start + length_to_add] - + f"[[{target_citation_num}]]()" - + curr_segment[end + length_to_add :] - ) - length_to_add += len(curr_segment) - prev_length - - last_citation_end = end + length_to_add - - if last_citation_end > 0: - yield DanswerAnswerPiece(answer_piece=curr_segment[:last_citation_end]) - curr_segment = curr_segment[last_citation_end:] - if possible_citation_found: - continue - yield DanswerAnswerPiece(answer_piece=curr_segment) - curr_segment = "" - - if curr_segment: - yield DanswerAnswerPiece(answer_piece=curr_segment) - - -def build_citation_processor( - context_docs: list[LlmDoc], doc_id_to_rank_map: DocumentIdOrderMapping -) -> StreamProcessor: - def stream_processor(tokens: Iterator[str]) -> AnswerQuestionStreamReturn: - yield from extract_citations_from_stream( - tokens=tokens, - context_docs=context_docs, - doc_id_to_rank_map=doc_id_to_rank_map, - ) - - return stream_processor diff --git a/backend/danswer/llm/answering/stream_processing/quotes_processing.py b/backend/danswer/llm/answering/stream_processing/quotes_processing.py deleted file mode 100644 index 74f37b85264..00000000000 --- a/backend/danswer/llm/answering/stream_processing/quotes_processing.py +++ /dev/null @@ -1,295 +0,0 @@ -import math -import re -from collections.abc import Callable -from collections.abc import Generator -from collections.abc import Iterator -from json import JSONDecodeError -from typing import Optional - -import regex - -from danswer.chat.models import AnswerQuestionStreamReturn -from danswer.chat.models import DanswerAnswer -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import DanswerQuote -from danswer.chat.models import DanswerQuotes -from danswer.chat.models import LlmDoc -from danswer.configs.chat_configs import QUOTE_ALLOWED_ERROR_PERCENT -from danswer.prompts.constants import ANSWER_PAT -from danswer.prompts.constants import QUOTE_PAT -from danswer.search.models import InferenceChunk -from danswer.utils.logger import setup_logger -from danswer.utils.text_processing import clean_model_quote -from danswer.utils.text_processing import clean_up_code_blocks -from danswer.utils.text_processing import extract_embedded_json -from danswer.utils.text_processing import shared_precompare_cleanup - - -logger = setup_logger() -answer_pattern = re.compile(r'{\s*"answer"\s*:\s*"', re.IGNORECASE) - - -def _extract_answer_quotes_freeform( - answer_raw: str, -) -> tuple[Optional[str], Optional[list[str]]]: - """Splits the model output into an Answer and 0 or more Quote sections. - Splits by the Quote pattern, if not exist then assume it's all answer and no quotes - """ - # If no answer section, don't care about the quote - if answer_raw.lower().strip().startswith(QUOTE_PAT.lower()): - return None, None - - # Sometimes model regenerates the Answer: pattern despite it being provided in the prompt - if answer_raw.lower().startswith(ANSWER_PAT.lower()): - answer_raw = answer_raw[len(ANSWER_PAT) :] - - # Accept quote sections starting with the lower case version - answer_raw = answer_raw.replace( - f"\n{QUOTE_PAT}".lower(), f"\n{QUOTE_PAT}" - ) # Just in case model unreliable - - sections = re.split(rf"(?<=\n){QUOTE_PAT}", answer_raw) - sections_clean = [ - str(section).strip() for section in sections if str(section).strip() - ] - if not sections_clean: - return None, None - - answer = str(sections_clean[0]) - if len(sections) == 1: - return answer, None - return answer, sections_clean[1:] - - -def _extract_answer_quotes_json( - answer_dict: dict[str, str | list[str]] -) -> tuple[Optional[str], Optional[list[str]]]: - answer_dict = {k.lower(): v for k, v in answer_dict.items()} - answer = str(answer_dict.get("answer")) - quotes = answer_dict.get("quotes") or answer_dict.get("quote") - if isinstance(quotes, str): - quotes = [quotes] - return answer, quotes - - -def _extract_answer_json(raw_model_output: str) -> dict: - try: - answer_json = extract_embedded_json(raw_model_output) - except (ValueError, JSONDecodeError): - # LLMs get confused when handling the list in the json. Sometimes it doesn't attend - # enough to the previous { token so it just ends the list of quotes and stops there - # here, we add logic to try to fix this LLM error. - answer_json = extract_embedded_json(raw_model_output + "}") - - if "answer" not in answer_json: - raise ValueError("Model did not output an answer as expected.") - - return answer_json - - -def match_quotes_to_docs( - quotes: list[str], - docs: list[LlmDoc] | list[InferenceChunk], - max_error_percent: float = QUOTE_ALLOWED_ERROR_PERCENT, - fuzzy_search: bool = False, - prefix_only_length: int = 100, -) -> DanswerQuotes: - danswer_quotes: list[DanswerQuote] = [] - for quote in quotes: - max_edits = math.ceil(float(len(quote)) * max_error_percent) - - for doc in docs: - if not doc.source_links: - continue - - quote_clean = shared_precompare_cleanup( - clean_model_quote(quote, trim_length=prefix_only_length) - ) - chunk_clean = shared_precompare_cleanup(doc.content) - - # Finding the offset of the quote in the plain text - if fuzzy_search: - re_search_str = ( - r"(" + re.escape(quote_clean) + r"){e<=" + str(max_edits) + r"}" - ) - found = regex.search(re_search_str, chunk_clean) - if not found: - continue - offset = found.span()[0] - else: - if quote_clean not in chunk_clean: - continue - offset = chunk_clean.index(quote_clean) - - # Extracting the link from the offset - curr_link = None - for link_offset, link in doc.source_links.items(): - # Should always find one because offset is at least 0 and there - # must be a 0 link_offset - if int(link_offset) <= offset: - curr_link = link - else: - break - - danswer_quotes.append( - DanswerQuote( - quote=quote, - document_id=doc.document_id, - link=curr_link, - source_type=doc.source_type, - semantic_identifier=doc.semantic_identifier, - blurb=doc.blurb, - ) - ) - break - - return DanswerQuotes(quotes=danswer_quotes) - - -def separate_answer_quotes( - answer_raw: str, is_json_prompt: bool = False -) -> tuple[Optional[str], Optional[list[str]]]: - """Takes in a raw model output and pulls out the answer and the quotes sections.""" - if is_json_prompt: - model_raw_json = _extract_answer_json(answer_raw) - return _extract_answer_quotes_json(model_raw_json) - - return _extract_answer_quotes_freeform(clean_up_code_blocks(answer_raw)) - - -def process_answer( - answer_raw: str, - docs: list[LlmDoc], - is_json_prompt: bool = True, -) -> tuple[DanswerAnswer, DanswerQuotes]: - """Used (1) in the non-streaming case to process the model output - into an Answer and Quotes AND (2) after the complete streaming response - has been received to process the model output into an Answer and Quotes.""" - answer, quote_strings = separate_answer_quotes(answer_raw, is_json_prompt) - if not answer: - logger.debug("No answer extracted from raw output") - return DanswerAnswer(answer=None), DanswerQuotes(quotes=[]) - - logger.notice(f"Answer: {answer}") - if not quote_strings: - logger.debug("No quotes extracted from raw output") - return DanswerAnswer(answer=answer), DanswerQuotes(quotes=[]) - logger.debug(f"All quotes (including unmatched): {quote_strings}") - quotes = match_quotes_to_docs(quote_strings, docs) - logger.debug(f"Final quotes: {quotes}") - - return DanswerAnswer(answer=answer), quotes - - -def _stream_json_answer_end(answer_so_far: str, next_token: str) -> bool: - next_token = next_token.replace('\\"', "") - # If the previous character is an escape token, don't consider the first character of next_token - # This does not work if it's an escaped escape sign before the " but this is rare, not worth handling - if answer_so_far and answer_so_far[-1] == "\\": - next_token = next_token[1:] - if '"' in next_token: - return True - return False - - -def _extract_quotes_from_completed_token_stream( - model_output: str, context_docs: list[LlmDoc], is_json_prompt: bool = True -) -> DanswerQuotes: - answer, quotes = process_answer(model_output, context_docs, is_json_prompt) - if answer: - logger.notice(answer) - elif model_output: - logger.warning("Answer extraction from model output failed.") - - return quotes - - -def process_model_tokens( - tokens: Iterator[str], - context_docs: list[LlmDoc], - is_json_prompt: bool = True, -) -> Generator[DanswerAnswerPiece | DanswerQuotes, None, None]: - """Used in the streaming case to process the model output - into an Answer and Quotes - - Yields Answer tokens back out in a dict for streaming to frontend - When Answer section ends, yields dict with answer_finished key - Collects all the tokens at the end to form the complete model output""" - quote_pat = f"\n{QUOTE_PAT}" - # Sometimes worse model outputs new line instead of : - quote_loose = f"\n{quote_pat[:-1]}\n" - # Sometime model outputs two newlines before quote section - quote_pat_full = f"\n{quote_pat}" - model_output: str = "" - found_answer_start = False if is_json_prompt else True - found_answer_end = False - hold_quote = "" - - for token in tokens: - model_previous = model_output - model_output += token - - if not found_answer_start: - m = answer_pattern.search(model_output) - if m: - found_answer_start = True - - # Prevent heavy cases of hallucinations where model is never providing a JSON - # We want to quickly update the user - not stream forever - if is_json_prompt and len(model_output) > 70: - logger.warning("LLM did not produce json as prompted") - found_answer_end = True - continue - - remaining = model_output[m.end() :] - if len(remaining) > 0: - yield DanswerAnswerPiece(answer_piece=remaining) - continue - - if found_answer_start and not found_answer_end: - if is_json_prompt and _stream_json_answer_end(model_previous, token): - found_answer_end = True - - # return the remaining part of the answer e.g. token might be 'd.", ' and we should yield 'd.' - if token: - try: - answer_token_section = token.index('"') - yield DanswerAnswerPiece( - answer_piece=hold_quote + token[:answer_token_section] - ) - except ValueError: - logger.error("Quotation mark not found in token") - yield DanswerAnswerPiece(answer_piece=hold_quote + token) - yield DanswerAnswerPiece(answer_piece=None) - continue - elif not is_json_prompt: - if quote_pat in hold_quote + token or quote_loose in hold_quote + token: - found_answer_end = True - yield DanswerAnswerPiece(answer_piece=None) - continue - if hold_quote + token in quote_pat_full: - hold_quote += token - continue - yield DanswerAnswerPiece(answer_piece=hold_quote + token) - hold_quote = "" - - logger.debug(f"Raw Model QnA Output: {model_output}") - - yield _extract_quotes_from_completed_token_stream( - model_output=model_output, - context_docs=context_docs, - is_json_prompt=is_json_prompt, - ) - - -def build_quotes_processor( - context_docs: list[LlmDoc], is_json_prompt: bool -) -> Callable[[Iterator[str]], AnswerQuestionStreamReturn]: - def stream_processor(tokens: Iterator[str]) -> AnswerQuestionStreamReturn: - yield from process_model_tokens( - tokens=tokens, - context_docs=context_docs, - is_json_prompt=is_json_prompt, - ) - - return stream_processor diff --git a/backend/danswer/llm/answering/stream_processing/utils.py b/backend/danswer/llm/answering/stream_processing/utils.py deleted file mode 100644 index b4fb83747de..00000000000 --- a/backend/danswer/llm/answering/stream_processing/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from collections.abc import Sequence - -from pydantic import BaseModel - -from danswer.chat.models import LlmDoc -from danswer.search.models import InferenceChunk - - -class DocumentIdOrderMapping(BaseModel): - order_mapping: dict[str, int] - - -def map_document_id_order( - chunks: Sequence[InferenceChunk | LlmDoc], one_indexed: bool = True -) -> DocumentIdOrderMapping: - order_mapping = {} - current = 1 if one_indexed else 0 - for chunk in chunks: - if chunk.document_id not in order_mapping: - order_mapping[chunk.document_id] = current - current += 1 - - return DocumentIdOrderMapping(order_mapping=order_mapping) diff --git a/backend/danswer/llm/chat_llm.py b/backend/danswer/llm/chat_llm.py deleted file mode 100644 index 359e3239b9d..00000000000 --- a/backend/danswer/llm/chat_llm.py +++ /dev/null @@ -1,393 +0,0 @@ -import json -import os -from collections.abc import Iterator -from typing import Any -from typing import cast - -import litellm # type: ignore -from httpx import RemoteProtocolError -from langchain.schema.language_model import LanguageModelInput -from langchain_core.messages import AIMessage -from langchain_core.messages import AIMessageChunk -from langchain_core.messages import BaseMessage -from langchain_core.messages import BaseMessageChunk -from langchain_core.messages import ChatMessage -from langchain_core.messages import ChatMessageChunk -from langchain_core.messages import FunctionMessage -from langchain_core.messages import FunctionMessageChunk -from langchain_core.messages import HumanMessage -from langchain_core.messages import HumanMessageChunk -from langchain_core.messages import SystemMessage -from langchain_core.messages import SystemMessageChunk -from langchain_core.messages.tool import ToolCallChunk -from langchain_core.messages.tool import ToolMessage - -from danswer.configs.app_configs import LOG_ALL_MODEL_INTERACTIONS -from danswer.configs.app_configs import LOG_DANSWER_MODEL_INTERACTIONS -from danswer.configs.model_configs import DISABLE_LITELLM_STREAMING -from danswer.configs.model_configs import GEN_AI_API_ENDPOINT -from danswer.configs.model_configs import GEN_AI_API_VERSION -from danswer.configs.model_configs import GEN_AI_LLM_PROVIDER_TYPE -from danswer.configs.model_configs import GEN_AI_TEMPERATURE -from danswer.llm.interfaces import LLM -from danswer.llm.interfaces import LLMConfig -from danswer.llm.interfaces import ToolChoiceOptions -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - -# If a user configures a different model and it doesn't support all the same -# parameters like frequency and presence, just ignore them -litellm.drop_params = True -litellm.telemetry = False - -litellm.set_verbose = LOG_ALL_MODEL_INTERACTIONS - - -def _base_msg_to_role(msg: BaseMessage) -> str: - if isinstance(msg, HumanMessage) or isinstance(msg, HumanMessageChunk): - return "user" - if isinstance(msg, AIMessage) or isinstance(msg, AIMessageChunk): - return "assistant" - if isinstance(msg, SystemMessage) or isinstance(msg, SystemMessageChunk): - return "system" - if isinstance(msg, FunctionMessage) or isinstance(msg, FunctionMessageChunk): - return "function" - return "unknown" - - -def _convert_litellm_message_to_langchain_message( - litellm_message: litellm.Message, -) -> BaseMessage: - # Extracting the basic attributes from the litellm message - content = litellm_message.content or "" - role = litellm_message.role - - # Handling function calls and tool calls if present - tool_calls = ( - cast( - list[litellm.ChatCompletionMessageToolCall], - litellm_message.tool_calls, - ) - if hasattr(litellm_message, "tool_calls") - else [] - ) - - # Create the appropriate langchain message based on the role - if role == "user": - return HumanMessage(content=content) - elif role == "assistant": - return AIMessage( - content=content, - tool_calls=[ - { - "name": tool_call.function.name or "", - "args": json.loads(tool_call.function.arguments), - "id": tool_call.id, - } - for tool_call in (tool_calls if tool_calls else []) - ], - ) - elif role == "system": - return SystemMessage(content=content) - else: - raise ValueError(f"Unknown role type received: {role}") - - -def _convert_message_to_dict(message: BaseMessage) -> dict: - """Adapted from langchain_community.chat_models.litellm._convert_message_to_dict""" - if isinstance(message, ChatMessage): - message_dict = {"role": message.role, "content": message.content} - elif isinstance(message, HumanMessage): - message_dict = {"role": "user", "content": message.content} - elif isinstance(message, AIMessage): - message_dict = {"role": "assistant", "content": message.content} - if message.tool_calls: - message_dict["tool_calls"] = [ - { - "id": tool_call.get("id"), - "function": { - "name": tool_call["name"], - "arguments": json.dumps(tool_call["args"]), - }, - "type": "function", - "index": 0, # only support a single tool call atm - } - for tool_call in message.tool_calls - ] - if "function_call" in message.additional_kwargs: - message_dict["function_call"] = message.additional_kwargs["function_call"] - elif isinstance(message, SystemMessage): - message_dict = {"role": "system", "content": message.content} - elif isinstance(message, FunctionMessage): - message_dict = { - "role": "function", - "content": message.content, - "name": message.name, - } - elif isinstance(message, ToolMessage): - message_dict = { - "tool_call_id": message.tool_call_id, - "role": "tool", - "name": message.name or "", - "content": message.content, - } - else: - raise ValueError(f"Got unknown type {message}") - if "name" in message.additional_kwargs: - message_dict["name"] = message.additional_kwargs["name"] - return message_dict - - -def _convert_delta_to_message_chunk( - _dict: dict[str, Any], curr_msg: BaseMessage | None -) -> BaseMessageChunk: - """Adapted from langchain_community.chat_models.litellm._convert_delta_to_message_chunk""" - role = _dict.get("role") or (_base_msg_to_role(curr_msg) if curr_msg else None) - content = _dict.get("content") or "" - additional_kwargs = {} - if _dict.get("function_call"): - additional_kwargs.update({"function_call": dict(_dict["function_call"])}) - tool_calls = cast( - list[litellm.utils.ChatCompletionDeltaToolCall] | None, _dict.get("tool_calls") - ) - - if role == "user": - return HumanMessageChunk(content=content) - elif role == "assistant": - if tool_calls: - tool_call = tool_calls[0] - tool_name = tool_call.function.name or (curr_msg and curr_msg.name) or "" - - tool_call_chunk = ToolCallChunk( - name=tool_name, - id=tool_call.id, - args=tool_call.function.arguments, - index=0, # only support a single tool call atm - ) - return AIMessageChunk( - content=content, - additional_kwargs=additional_kwargs, - tool_call_chunks=[tool_call_chunk], - ) - return AIMessageChunk(content=content, additional_kwargs=additional_kwargs) - elif role == "system": - return SystemMessageChunk(content=content) - elif role == "function": - return FunctionMessageChunk(content=content, name=_dict["name"]) - elif role: - return ChatMessageChunk(content=content, role=role) - - raise ValueError(f"Unknown role: {role}") - - -class DefaultMultiLLM(LLM): - """Uses Litellm library to allow easy configuration to use a multitude of LLMs - See https://python.langchain.com/docs/integrations/chat/litellm""" - - def __init__( - self, - api_key: str | None, - timeout: int, - model_provider: str, - model_name: str, - max_output_tokens: int | None = None, - api_base: str | None = GEN_AI_API_ENDPOINT, - api_version: str | None = GEN_AI_API_VERSION, - custom_llm_provider: str | None = GEN_AI_LLM_PROVIDER_TYPE, - temperature: float = GEN_AI_TEMPERATURE, - custom_config: dict[str, str] | None = None, - extra_headers: dict[str, str] | None = None, - ): - self._timeout = timeout - self._model_provider = model_provider - self._model_version = model_name - self._temperature = temperature - self._api_key = api_key - self._api_base = api_base - self._api_version = api_version - self._custom_llm_provider = custom_llm_provider - - # This can be used to store the maximum output tkoens for this model. - # self._max_output_tokens = ( - # max_output_tokens - # if max_output_tokens is not None - # else get_llm_max_output_tokens( - # model_map=litellm.model_cost, - # model_name=model_name, - # model_provider=model_provider, - # ) - # ) - self._custom_config = custom_config - - # NOTE: have to set these as environment variables for Litellm since - # not all are able to passed in but they always support them set as env - # variables - if custom_config: - for k, v in custom_config.items(): - os.environ[k] = v - - model_kwargs: dict[str, Any] = {} - if extra_headers: - model_kwargs.update({"extra_headers": extra_headers}) - - self._model_kwargs = model_kwargs - - def log_model_configs(self) -> None: - logger.debug(f"Config: {self.config}") - - # def _calculate_max_output_tokens(self, prompt: LanguageModelInput) -> int: - # # NOTE: This method can be used for calculating the maximum tokens for the stream, - # # but it isn't used in practice due to the computational cost of counting tokens - # # and because LLM providers automatically cut off at the maximum output. - # # The implementation is kept for potential future use or debugging purposes. - - # # Get max input tokens for the model - # max_context_tokens = get_max_input_tokens( - # model_name=self.config.model_name, model_provider=self.config.model_provider - # ) - - # llm_tokenizer = get_tokenizer( - # model_name=self.config.model_name, - # provider_type=self.config.model_provider, - # ) - # # Calculate tokens in the input prompt - # input_tokens = sum(len(llm_tokenizer.encode(str(m))) for m in prompt) - - # # Calculate available tokens for output - # available_output_tokens = max_context_tokens - input_tokens - - # # Return the lesser of available tokens or configured max - # return min(self._max_output_tokens, available_output_tokens) - - def _completion( - self, - prompt: LanguageModelInput, - tools: list[dict] | None, - tool_choice: ToolChoiceOptions | None, - stream: bool, - ) -> litellm.ModelResponse | litellm.CustomStreamWrapper: - if isinstance(prompt, list): - prompt = [ - _convert_message_to_dict(msg) if isinstance(msg, BaseMessage) else msg - for msg in prompt - ] - elif isinstance(prompt, str): - prompt = [_convert_message_to_dict(HumanMessage(content=prompt))] - - try: - # When custom LLM provider is supplied, model name doesn't require prefix in LiteLLM - prefix = f"{self.config.model_provider}/" if not self._custom_llm_provider else "" - return litellm.completion( - # model choice - model=f"{prefix}{self.config.model_name}", - api_key=self._api_key, - base_url=self._api_base, - api_version=self._api_version, - custom_llm_provider=self._custom_llm_provider, - # actual input - messages=prompt, - tools=tools, - tool_choice=tool_choice if tools else None, - # streaming choice - stream=stream, - # model params - temperature=self._temperature, - timeout=self._timeout, - # For now, we don't support parallel tool calls - # NOTE: we can't pass this in if tools are not specified - # or else OpenAI throws an error - **({"parallel_tool_calls": False} if tools else {}), - **self._model_kwargs, - ) - except Exception as e: - # for break pointing - raise e - - @property - def config(self) -> LLMConfig: - return LLMConfig( - model_provider=self._model_provider, - model_name=self._model_version, - temperature=self._temperature, - api_key=self._api_key, - api_base=self._api_base, - api_version=self._api_version, - ) - - def _invoke_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> BaseMessage: - if LOG_DANSWER_MODEL_INTERACTIONS: - self.log_model_configs() - - response = cast( - litellm.ModelResponse, self._completion(prompt, tools, tool_choice, False) - ) - choice = response.choices[0] - if hasattr(choice, "message"): - return _convert_litellm_message_to_langchain_message(choice.message) - else: - raise ValueError("Unexpected response choice type") - - def _stream_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> Iterator[BaseMessage]: - if LOG_DANSWER_MODEL_INTERACTIONS: - self.log_model_configs() - - if DISABLE_LITELLM_STREAMING: - yield self.invoke(prompt) - return - - output = None - response = cast( - litellm.CustomStreamWrapper, - self._completion(prompt, tools, tool_choice, True), - ) - try: - for part in response: - if len(part["choices"]) == 0: - continue - delta = part["choices"][0]["delta"] - message_chunk = _convert_delta_to_message_chunk(delta, output) - if output is None: - output = message_chunk - else: - output += message_chunk - - yield message_chunk - - except RemoteProtocolError: - raise RuntimeError( - "The AI model failed partway through generation, please try again." - ) - - if LOG_DANSWER_MODEL_INTERACTIONS and output: - content = output.content or "" - if isinstance(output, AIMessage): - if content: - log_msg = content - elif output.tool_calls: - log_msg = "Tool Calls: " + str( - [ - { - key: value - for key, value in tool_call.items() - if key != "index" - } - for tool_call in output.tool_calls - ] - ) - else: - log_msg = "" - logger.debug(f"Raw Model Output:\n{log_msg}") - else: - logger.debug(f"Raw Model Output:\n{content}") diff --git a/backend/danswer/llm/custom_llm.py b/backend/danswer/llm/custom_llm.py deleted file mode 100644 index 967e014a903..00000000000 --- a/backend/danswer/llm/custom_llm.py +++ /dev/null @@ -1,93 +0,0 @@ -import json -from collections.abc import Iterator - -import requests -from langchain.schema.language_model import LanguageModelInput -from langchain_core.messages import AIMessage -from langchain_core.messages import BaseMessage -from requests import Timeout - -from danswer.configs.model_configs import GEN_AI_API_ENDPOINT -from danswer.configs.model_configs import GEN_AI_NUM_RESERVED_OUTPUT_TOKENS -from danswer.llm.interfaces import LLM -from danswer.llm.interfaces import ToolChoiceOptions -from danswer.llm.utils import convert_lm_input_to_basic_string -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -class CustomModelServer(LLM): - """This class is to provide an example for how to use Danswer - with any LLM, even servers with custom API definitions. - To use with your own model server, simply implement the functions - below to fit your model server expectation - - The implementation below works against the custom FastAPI server from the blog: - https://medium.com/@yuhongsun96/how-to-augment-llms-with-private-data-29349bd8ae9f - """ - - @property - def requires_api_key(self) -> bool: - return False - - def __init__( - self, - # Not used here but you probably want a model server that isn't completely open - api_key: str | None, - timeout: int, - endpoint: str | None = GEN_AI_API_ENDPOINT, - max_output_tokens: int = GEN_AI_NUM_RESERVED_OUTPUT_TOKENS, - ): - if not endpoint: - raise ValueError( - "Cannot point Danswer to a custom LLM server without providing the " - "endpoint for the model server." - ) - - self._endpoint = endpoint - self._max_output_tokens = max_output_tokens - self._timeout = timeout - - def _execute(self, input: LanguageModelInput) -> AIMessage: - headers = { - "Content-Type": "application/json", - } - - data = { - "inputs": convert_lm_input_to_basic_string(input), - "parameters": { - "temperature": 0.0, - "max_tokens": self._max_output_tokens, - }, - } - try: - response = requests.post( - self._endpoint, headers=headers, json=data, timeout=self._timeout - ) - except Timeout as error: - raise Timeout(f"Model inference to {self._endpoint} timed out") from error - - response.raise_for_status() - response_content = json.loads(response.content).get("generated_text", "") - return AIMessage(content=response_content) - - def log_model_configs(self) -> None: - logger.debug(f"Custom model at: {self._endpoint}") - - def _invoke_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> BaseMessage: - return self._execute(prompt) - - def _stream_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> Iterator[BaseMessage]: - yield self._execute(prompt) diff --git a/backend/danswer/llm/exceptions.py b/backend/danswer/llm/exceptions.py deleted file mode 100644 index 0cdb893c83b..00000000000 --- a/backend/danswer/llm/exceptions.py +++ /dev/null @@ -1,4 +0,0 @@ -class GenAIDisabledException(Exception): - def __init__(self, message: str = "Generative AI has been turned off") -> None: - self.message = message - super().__init__(self.message) diff --git a/backend/danswer/llm/factory.py b/backend/danswer/llm/factory.py deleted file mode 100644 index f57bfb524b9..00000000000 --- a/backend/danswer/llm/factory.py +++ /dev/null @@ -1,124 +0,0 @@ -from danswer.configs.app_configs import DISABLE_GENERATIVE_AI -from danswer.configs.chat_configs import QA_TIMEOUT -from danswer.configs.model_configs import GEN_AI_TEMPERATURE -from danswer.db.engine import get_session_context_manager -from danswer.db.llm import fetch_default_provider -from danswer.db.llm import fetch_provider -from danswer.db.models import Persona -from danswer.llm.chat_llm import DefaultMultiLLM -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.headers import build_llm_extra_headers -from danswer.llm.interfaces import LLM -from danswer.llm.override_models import LLMOverride - - -def get_main_llm_from_tuple( - llms: tuple[LLM, LLM], -) -> LLM: - return llms[0] - - -def get_llms_for_persona( - persona: Persona, - llm_override: LLMOverride | None = None, - additional_headers: dict[str, str] | None = None, -) -> tuple[LLM, LLM]: - model_provider_override = llm_override.model_provider if llm_override else None - model_version_override = llm_override.model_version if llm_override else None - temperature_override = llm_override.temperature if llm_override else None - - provider_name = model_provider_override or persona.llm_model_provider_override - if not provider_name: - return get_default_llms( - temperature=temperature_override or GEN_AI_TEMPERATURE, - additional_headers=additional_headers, - ) - - with get_session_context_manager() as db_session: - llm_provider = fetch_provider(db_session, provider_name) - - if not llm_provider: - raise ValueError("No LLM provider found") - - model = model_version_override or persona.llm_model_version_override - fast_model = llm_provider.fast_default_model_name or llm_provider.default_model_name - if not model: - raise ValueError("No model name found") - if not fast_model: - raise ValueError("No fast model name found") - - def _create_llm(model: str) -> LLM: - return get_llm( - provider=llm_provider.provider, - model=model, - api_key=llm_provider.api_key, - api_base=llm_provider.api_base, - api_version=llm_provider.api_version, - custom_config=llm_provider.custom_config, - additional_headers=additional_headers, - ) - - return _create_llm(model), _create_llm(fast_model) - - -def get_default_llms( - timeout: int = QA_TIMEOUT, - temperature: float = GEN_AI_TEMPERATURE, - additional_headers: dict[str, str] | None = None, -) -> tuple[LLM, LLM]: - if DISABLE_GENERATIVE_AI: - raise GenAIDisabledException() - - with get_session_context_manager() as db_session: - llm_provider = fetch_default_provider(db_session) - - if not llm_provider: - raise ValueError("No default LLM provider found") - - model_name = llm_provider.default_model_name - fast_model_name = ( - llm_provider.fast_default_model_name or llm_provider.default_model_name - ) - if not model_name: - raise ValueError("No default model name found") - if not fast_model_name: - raise ValueError("No fast default model name found") - - def _create_llm(model: str) -> LLM: - return get_llm( - provider=llm_provider.provider, - model=model, - api_key=llm_provider.api_key, - api_base=llm_provider.api_base, - api_version=llm_provider.api_version, - custom_config=llm_provider.custom_config, - timeout=timeout, - temperature=temperature, - additional_headers=additional_headers, - ) - - return _create_llm(model_name), _create_llm(fast_model_name) - - -def get_llm( - provider: str, - model: str, - api_key: str | None = None, - api_base: str | None = None, - api_version: str | None = None, - custom_config: dict[str, str] | None = None, - temperature: float = GEN_AI_TEMPERATURE, - timeout: int = QA_TIMEOUT, - additional_headers: dict[str, str] | None = None, -) -> LLM: - return DefaultMultiLLM( - model_provider=provider, - model_name=model, - api_key=api_key, - api_base=api_base, - api_version=api_version, - timeout=timeout, - temperature=temperature, - custom_config=custom_config, - extra_headers=build_llm_extra_headers(additional_headers), - ) diff --git a/backend/danswer/llm/headers.py b/backend/danswer/llm/headers.py deleted file mode 100644 index b43c83e141e..00000000000 --- a/backend/danswer/llm/headers.py +++ /dev/null @@ -1,34 +0,0 @@ -from fastapi.datastructures import Headers - -from danswer.configs.model_configs import LITELLM_EXTRA_HEADERS -from danswer.configs.model_configs import LITELLM_PASS_THROUGH_HEADERS - - -def get_litellm_additional_request_headers( - headers: dict[str, str] | Headers -) -> dict[str, str]: - if not LITELLM_PASS_THROUGH_HEADERS: - return {} - - pass_through_headers: dict[str, str] = {} - for key in LITELLM_PASS_THROUGH_HEADERS: - if key in headers: - pass_through_headers[key] = headers[key] - else: - # fastapi makes all header keys lowercase, handling that here - lowercase_key = key.lower() - if lowercase_key in headers: - pass_through_headers[lowercase_key] = headers[lowercase_key] - - return pass_through_headers - - -def build_llm_extra_headers( - additional_headers: dict[str, str] | None = None -) -> dict[str, str]: - extra_headers: dict[str, str] = {} - if additional_headers: - extra_headers.update(additional_headers) - if LITELLM_EXTRA_HEADERS: - extra_headers.update(LITELLM_EXTRA_HEADERS) - return extra_headers diff --git a/backend/danswer/llm/interfaces.py b/backend/danswer/llm/interfaces.py deleted file mode 100644 index 5e39792c393..00000000000 --- a/backend/danswer/llm/interfaces.py +++ /dev/null @@ -1,124 +0,0 @@ -import abc -from collections.abc import Iterator -from typing import Literal - -from langchain.schema.language_model import LanguageModelInput -from langchain_core.messages import AIMessageChunk -from langchain_core.messages import BaseMessage -from pydantic import BaseModel - -from danswer.configs.app_configs import DISABLE_GENERATIVE_AI -from danswer.configs.app_configs import LOG_DANSWER_MODEL_INTERACTIONS -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - -ToolChoiceOptions = Literal["required"] | Literal["auto"] | Literal["none"] - - -class LLMConfig(BaseModel): - model_provider: str - model_name: str - temperature: float - api_key: str | None = None - api_base: str | None = None - api_version: str | None = None - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - -def log_prompt(prompt: LanguageModelInput) -> None: - if isinstance(prompt, list): - for ind, msg in enumerate(prompt): - if isinstance(msg, AIMessageChunk): - if msg.content: - log_msg = msg.content - elif msg.tool_call_chunks: - log_msg = "Tool Calls: " + str( - [ - { - key: value - for key, value in tool_call.items() - if key != "index" - } - for tool_call in msg.tool_call_chunks - ] - ) - else: - log_msg = "" - logger.debug(f"Message {ind}:\n{log_msg}") - else: - logger.debug(f"Message {ind}:\n{msg.content}") - if isinstance(prompt, str): - logger.debug(f"Prompt:\n{prompt}") - - -class LLM(abc.ABC): - """Mimics the LangChain LLM / BaseChatModel interfaces to make it easy - to use these implementations to connect to a variety of LLM providers.""" - - @property - def requires_warm_up(self) -> bool: - """Is this model running in memory and needs an initial call to warm it up?""" - return False - - @property - def requires_api_key(self) -> bool: - return True - - @property - @abc.abstractmethod - def config(self) -> LLMConfig: - raise NotImplementedError - - @abc.abstractmethod - def log_model_configs(self) -> None: - raise NotImplementedError - - def _precall(self, prompt: LanguageModelInput) -> None: - if DISABLE_GENERATIVE_AI: - raise Exception("Generative AI is disabled") - if LOG_DANSWER_MODEL_INTERACTIONS: - log_prompt(prompt) - - def invoke( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> BaseMessage: - self._precall(prompt) - # TODO add a postcall to log model outputs independent of concrete class - # implementation - return self._invoke_implementation(prompt, tools, tool_choice) - - @abc.abstractmethod - def _invoke_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> BaseMessage: - raise NotImplementedError - - def stream( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> Iterator[BaseMessage]: - self._precall(prompt) - # TODO add a postcall to log model outputs independent of concrete class - # implementation - return self._stream_implementation(prompt, tools, tool_choice) - - @abc.abstractmethod - def _stream_implementation( - self, - prompt: LanguageModelInput, - tools: list[dict] | None = None, - tool_choice: ToolChoiceOptions | None = None, - ) -> Iterator[BaseMessage]: - raise NotImplementedError diff --git a/backend/danswer/llm/llm_initialization.py b/backend/danswer/llm/llm_initialization.py deleted file mode 100644 index db59b836d7f..00000000000 --- a/backend/danswer/llm/llm_initialization.py +++ /dev/null @@ -1,113 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import DISABLE_GENERATIVE_AI -from danswer.configs.model_configs import FAST_GEN_AI_MODEL_VERSION -from danswer.configs.model_configs import GEN_AI_API_ENDPOINT -from danswer.configs.model_configs import GEN_AI_API_KEY -from danswer.configs.model_configs import GEN_AI_API_VERSION -from danswer.configs.model_configs import GEN_AI_MODEL_PROVIDER -from danswer.configs.model_configs import GEN_AI_MODEL_VERSION -from danswer.configs.model_configs import GEN_AI_LLM_PROVIDER_TYPE -from danswer.configs.model_configs import GEN_AI_DISPLAY_NAME -from danswer.db.llm import fetch_existing_llm_providers -from danswer.db.llm import update_default_provider -from danswer.db.llm import upsert_llm_provider -from danswer.llm.llm_provider_options import AZURE_PROVIDER_NAME -from danswer.llm.llm_provider_options import BEDROCK_PROVIDER_NAME -from danswer.llm.llm_provider_options import fetch_available_well_known_llms -from danswer.server.manage.llm.models import LLMProviderUpsertRequest -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def load_llm_providers(db_session: Session) -> None: - existing_providers = fetch_existing_llm_providers(db_session) - if existing_providers: - return - - if not GEN_AI_API_KEY or DISABLE_GENERATIVE_AI: - return - - if GEN_AI_MODEL_PROVIDER == "custom": - # Validate that all required env vars are present - for var in ( - GEN_AI_LLM_PROVIDER_TYPE, - GEN_AI_API_ENDPOINT, - GEN_AI_MODEL_VERSION, - GEN_AI_DISPLAY_NAME, - ): - if not var: - logger.error( - "Cannot auto-transition custom LLM provider due to missing env vars." - "The following env vars must all be set:" - "GEN_AI_LLM_PROVIDER_TYPE, GEN_AI_API_ENDPOINT, GEN_AI_MODEL_VERSION, GEN_AI_DISPLAY_NAME" - ) - return None - llm_provider_request = LLMProviderUpsertRequest( - name=GEN_AI_DISPLAY_NAME, - provider=GEN_AI_MODEL_PROVIDER, - api_key=GEN_AI_API_KEY, - api_base=GEN_AI_API_ENDPOINT, - api_version=GEN_AI_API_VERSION, - custom_config={}, - default_model_name=GEN_AI_MODEL_VERSION, - fast_default_model_name=FAST_GEN_AI_MODEL_VERSION, - ) - - else: - - well_known_provider_name_to_provider = { - provider.name: provider - for provider in fetch_available_well_known_llms() - if provider.name != BEDROCK_PROVIDER_NAME - } - - if GEN_AI_MODEL_PROVIDER not in well_known_provider_name_to_provider: - logger.error( - f"Cannot auto-transition LLM provider: {GEN_AI_MODEL_PROVIDER}" - ) - return None - - # Azure provider requires custom model names, - # OpenAI / anthropic can just use the defaults - model_names = ( - [ - name - for name in [ - GEN_AI_MODEL_VERSION, - FAST_GEN_AI_MODEL_VERSION, - ] - if name - ] - if GEN_AI_MODEL_PROVIDER == AZURE_PROVIDER_NAME - else None - ) - - well_known_provider = well_known_provider_name_to_provider[ - GEN_AI_MODEL_PROVIDER - ] - llm_provider_request = LLMProviderUpsertRequest( - name=well_known_provider.display_name, - provider=GEN_AI_MODEL_PROVIDER, - api_key=GEN_AI_API_KEY, - api_base=GEN_AI_API_ENDPOINT, - api_version=GEN_AI_API_VERSION, - custom_config={}, - default_model_name=( - GEN_AI_MODEL_VERSION - or well_known_provider.default_model - or well_known_provider.llm_names[0] - ), - fast_default_model_name=( - FAST_GEN_AI_MODEL_VERSION or well_known_provider.default_fast_model - ), - model_names=model_names, - ) - - llm_provider = upsert_llm_provider(db_session, llm_provider_request) - update_default_provider(db_session, llm_provider.id) - logger.notice( - f"Migrated LLM provider from env variables for provider '{GEN_AI_MODEL_PROVIDER}'" - ) diff --git a/backend/danswer/llm/llm_provider_options.py b/backend/danswer/llm/llm_provider_options.py deleted file mode 100644 index 24feeb2f27c..00000000000 --- a/backend/danswer/llm/llm_provider_options.py +++ /dev/null @@ -1,138 +0,0 @@ -import litellm # type: ignore -from pydantic import BaseModel - - -class CustomConfigKey(BaseModel): - name: str - description: str | None = None - is_required: bool = True - is_secret: bool = False - - -class WellKnownLLMProviderDescriptor(BaseModel): - name: str - display_name: str - api_key_required: bool - api_base_required: bool - api_version_required: bool - custom_config_keys: list[CustomConfigKey] | None = None - - llm_names: list[str] - default_model: str | None = None - default_fast_model: str | None = None - - -OPENAI_PROVIDER_NAME = "openai" -OPEN_AI_MODEL_NAMES = [ - "gpt-4", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - "gpt-4-turbo-preview", - "gpt-4-1106-preview", - "gpt-4-vision-preview", - "gpt-4-0613", - "gpt-4o-2024-08-06", - "gpt-4-0314", - "gpt-4-32k-0314", - "gpt-3.5-turbo", - "gpt-3.5-turbo-0125", - "gpt-3.5-turbo-1106", - "gpt-3.5-turbo-16k", - "gpt-3.5-turbo-0613", - "gpt-3.5-turbo-16k-0613", - "gpt-3.5-turbo-0301", -] - -BEDROCK_PROVIDER_NAME = "bedrock" -# need to remove all the weird "bedrock/eu-central-1/anthropic.claude-v1" named -# models -BEDROCK_MODEL_NAMES = [ - model - for model in litellm.bedrock_models - if "/" not in model and "embed" not in model -][::-1] - -IGNORABLE_ANTHROPIC_MODELS = [ - "claude-2", - "claude-instant-1", -] -ANTHROPIC_PROVIDER_NAME = "anthropic" -ANTHROPIC_MODEL_NAMES = [ - model - for model in litellm.anthropic_models - if model not in IGNORABLE_ANTHROPIC_MODELS -][::-1] - -AZURE_PROVIDER_NAME = "azure" - - -_PROVIDER_TO_MODELS_MAP = { - OPENAI_PROVIDER_NAME: OPEN_AI_MODEL_NAMES, - BEDROCK_PROVIDER_NAME: BEDROCK_MODEL_NAMES, - ANTHROPIC_PROVIDER_NAME: ANTHROPIC_MODEL_NAMES, -} - - -def fetch_available_well_known_llms() -> list[WellKnownLLMProviderDescriptor]: - return [ - WellKnownLLMProviderDescriptor( - name="openai", - display_name="OpenAI", - api_key_required=True, - api_base_required=False, - api_version_required=False, - custom_config_keys=[], - llm_names=fetch_models_for_provider(OPENAI_PROVIDER_NAME), - default_model="gpt-4", - default_fast_model="gpt-4o-mini", - ), - WellKnownLLMProviderDescriptor( - name=ANTHROPIC_PROVIDER_NAME, - display_name="Anthropic", - api_key_required=True, - api_base_required=False, - api_version_required=False, - custom_config_keys=[], - llm_names=fetch_models_for_provider(ANTHROPIC_PROVIDER_NAME), - default_model="claude-3-opus-20240229", - default_fast_model="claude-3-sonnet-20240229", - ), - WellKnownLLMProviderDescriptor( - name=AZURE_PROVIDER_NAME, - display_name="Azure OpenAI", - api_key_required=True, - api_base_required=True, - api_version_required=True, - custom_config_keys=[], - llm_names=fetch_models_for_provider(AZURE_PROVIDER_NAME), - ), - WellKnownLLMProviderDescriptor( - name=BEDROCK_PROVIDER_NAME, - display_name="AWS Bedrock", - api_key_required=False, - api_base_required=False, - api_version_required=False, - custom_config_keys=[ - CustomConfigKey(name="AWS_REGION_NAME"), - CustomConfigKey( - name="AWS_ACCESS_KEY_ID", - is_required=False, - description="If using AWS IAM roles, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY can be left blank.", - ), - CustomConfigKey( - name="AWS_SECRET_ACCESS_KEY", - is_required=False, - is_secret=True, - description="If using AWS IAM roles, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY can be left blank.", - ), - ], - llm_names=fetch_models_for_provider(BEDROCK_PROVIDER_NAME), - default_model="anthropic.claude-3-sonnet-20240229-v1:0", - default_fast_model="anthropic.claude-3-haiku-20240307-v1:0", - ), - ] - - -def fetch_models_for_provider(provider_name: str) -> list[str]: - return _PROVIDER_TO_MODELS_MAP.get(provider_name, []) diff --git a/backend/danswer/llm/override_models.py b/backend/danswer/llm/override_models.py deleted file mode 100644 index 08e4258916a..00000000000 --- a/backend/danswer/llm/override_models.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Overrides sent over the wire / stored in the DB - -NOTE: these models are used in many places, so have to be -kepy in a separate file to avoid circular imports. -""" -from pydantic import BaseModel - - -class LLMOverride(BaseModel): - model_provider: str | None = None - model_version: str | None = None - temperature: float | None = None - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - -class PromptOverride(BaseModel): - system_prompt: str | None = None - task_prompt: str | None = None diff --git a/backend/danswer/llm/utils.py b/backend/danswer/llm/utils.py deleted file mode 100644 index 82617f3f05b..00000000000 --- a/backend/danswer/llm/utils.py +++ /dev/null @@ -1,434 +0,0 @@ -import json -from collections.abc import Callable -from collections.abc import Iterator -from typing import Any -from typing import cast -from typing import TYPE_CHECKING -from typing import Union - -import litellm # type: ignore -import tiktoken -from langchain.prompts.base import StringPromptValue -from langchain.prompts.chat import ChatPromptValue -from langchain.schema import PromptValue -from langchain.schema.language_model import LanguageModelInput -from langchain.schema.messages import AIMessage -from langchain.schema.messages import BaseMessage -from langchain.schema.messages import HumanMessage -from langchain.schema.messages import SystemMessage -from litellm.exceptions import APIConnectionError # type: ignore -from litellm.exceptions import APIError # type: ignore -from litellm.exceptions import AuthenticationError # type: ignore -from litellm.exceptions import BadRequestError # type: ignore -from litellm.exceptions import BudgetExceededError # type: ignore -from litellm.exceptions import ContentPolicyViolationError # type: ignore -from litellm.exceptions import ContextWindowExceededError # type: ignore -from litellm.exceptions import NotFoundError # type: ignore -from litellm.exceptions import PermissionDeniedError # type: ignore -from litellm.exceptions import RateLimitError # type: ignore -from litellm.exceptions import Timeout # type: ignore -from litellm.exceptions import UnprocessableEntityError # type: ignore - -from danswer.configs.constants import MessageType -from danswer.configs.model_configs import GEN_AI_MAX_TOKENS -from danswer.configs.model_configs import GEN_AI_MODEL_FALLBACK_MAX_TOKENS -from danswer.configs.model_configs import GEN_AI_MODEL_PROVIDER -from danswer.configs.model_configs import GEN_AI_NUM_RESERVED_OUTPUT_TOKENS -from danswer.db.models import ChatMessage -from danswer.file_store.models import ChatFileType -from danswer.file_store.models import InMemoryChatFile -from danswer.llm.interfaces import LLM -from danswer.prompts.constants import CODE_BLOCK_PAT -from danswer.utils.logger import setup_logger -from shared_configs.configs import LOG_LEVEL - -if TYPE_CHECKING: - from danswer.llm.answering.models import PreviousMessage - -logger = setup_logger() - - -def litellm_exception_to_error_msg(e: Exception, llm: LLM) -> str: - error_msg = str(e) - - if isinstance(e, BadRequestError): - error_msg = "Bad request: The server couldn't process your request. Please check your input." - elif isinstance(e, AuthenticationError): - error_msg = "Authentication failed: Please check your API key and credentials." - elif isinstance(e, PermissionDeniedError): - error_msg = ( - "Permission denied: You don't have the necessary permissions for this operation." - "Ensure you have access to this model." - ) - elif isinstance(e, NotFoundError): - error_msg = "Resource not found: The requested resource doesn't exist." - elif isinstance(e, UnprocessableEntityError): - error_msg = "Unprocessable entity: The server couldn't process your request due to semantic errors." - elif isinstance(e, RateLimitError): - error_msg = ( - "Rate limit exceeded: Please slow down your requests and try again later." - ) - elif isinstance(e, ContextWindowExceededError): - error_msg = ( - "Context window exceeded: Your input is too long for the model to process." - ) - if llm is not None: - try: - max_context = get_max_input_tokens( - model_name=llm.config.model_name, - model_provider=llm.config.model_provider, - ) - error_msg += f"Your invoked model ({llm.config.model_name}) has a maximum context size of {max_context}" - except Exception: - logger.warning( - "Unable to get maximum input token for LiteLLM excpetion handling" - ) - elif isinstance(e, ContentPolicyViolationError): - error_msg = "Content policy violation: Your request violates the content policy. Please revise your input." - elif isinstance(e, APIConnectionError): - error_msg = "API connection error: Failed to connect to the API. Please check your internet connection." - elif isinstance(e, BudgetExceededError): - error_msg = ( - "Budget exceeded: You've exceeded your allocated budget for API usage." - ) - elif isinstance(e, Timeout): - error_msg = "Request timed out: The operation took too long to complete. Please try again." - elif isinstance(e, APIError): - error_msg = f"API error: An error occurred while communicating with the API. Details: {str(e)}" - else: - error_msg = "An unexpected error occurred while processing your request. Please try again later." - return error_msg - - -def translate_danswer_msg_to_langchain( - msg: Union[ChatMessage, "PreviousMessage"], -) -> BaseMessage: - files: list[InMemoryChatFile] = [] - - # If the message is a `ChatMessage`, it doesn't have the downloaded files - # attached. Just ignore them for now. Also, OpenAI doesn't allow files to - # be attached to AI messages, so we must remove them - if not isinstance(msg, ChatMessage) and msg.message_type != MessageType.ASSISTANT: - files = msg.files - content = build_content_with_imgs(msg.message, files) - - if msg.message_type == MessageType.SYSTEM: - raise ValueError("System messages are not currently part of history") - if msg.message_type == MessageType.ASSISTANT: - return AIMessage(content=content) - if msg.message_type == MessageType.USER: - return HumanMessage(content=content) - - raise ValueError(f"New message type {msg.message_type} not handled") - - -def translate_history_to_basemessages( - history: list[ChatMessage] | list["PreviousMessage"], -) -> tuple[list[BaseMessage], list[int]]: - history_basemessages = [ - translate_danswer_msg_to_langchain(msg) - for msg in history - if msg.token_count != 0 - ] - history_token_counts = [msg.token_count for msg in history if msg.token_count != 0] - return history_basemessages, history_token_counts - - -def _build_content( - message: str, - files: list[InMemoryChatFile] | None = None, -) -> str: - """Applies all non-image files.""" - text_files = ( - [file for file in files if file.file_type == ChatFileType.PLAIN_TEXT] - if files - else None - ) - if not text_files: - return message - - final_message_with_files = "FILES:\n\n" - for file in text_files: - file_content = file.content.decode("utf-8") - file_name_section = f"DOCUMENT: {file.filename}\n" if file.filename else "" - final_message_with_files += ( - f"{file_name_section}{CODE_BLOCK_PAT.format(file_content.strip())}\n\n\n" - ) - final_message_with_files += message - - return final_message_with_files - - -def build_content_with_imgs( - message: str, - files: list[InMemoryChatFile] | None = None, - img_urls: list[str] | None = None, -) -> str | list[str | dict[str, Any]]: # matching Langchain's BaseMessage content type - files = files or [] - img_files = [file for file in files if file.file_type == ChatFileType.IMAGE] - img_urls = img_urls or [] - message_main_content = _build_content(message, files) - - if not img_files and not img_urls: - return message_main_content - - return cast( - list[str | dict[str, Any]], - [ - { - "type": "text", - "text": message_main_content, - }, - ] - + [ - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{file.to_base64()}", - }, - } - for file in files - if file.file_type == "image" - ] - + [ - { - "type": "image_url", - "image_url": { - "url": url, - }, - } - for url in img_urls - ], - ) - - -def dict_based_prompt_to_langchain_prompt( - messages: list[dict[str, str]] -) -> list[BaseMessage]: - prompt: list[BaseMessage] = [] - for message in messages: - role = message.get("role") - content = message.get("content") - if not role: - raise ValueError(f"Message missing `role`: {message}") - if not content: - raise ValueError(f"Message missing `content`: {message}") - elif role == "user": - prompt.append(HumanMessage(content=content)) - elif role == "system": - prompt.append(SystemMessage(content=content)) - elif role == "assistant": - prompt.append(AIMessage(content=content)) - else: - raise ValueError(f"Unknown role: {role}") - return prompt - - -def str_prompt_to_langchain_prompt(message: str) -> list[BaseMessage]: - return [HumanMessage(content=message)] - - -def convert_lm_input_to_basic_string(lm_input: LanguageModelInput) -> str: - """Heavily inspired by: - https://github.com/langchain-ai/langchain/blob/master/libs/langchain/langchain/chat_models/base.py#L86 - """ - prompt_value = None - if isinstance(lm_input, PromptValue): - prompt_value = lm_input - elif isinstance(lm_input, str): - prompt_value = StringPromptValue(text=lm_input) - elif isinstance(lm_input, list): - prompt_value = ChatPromptValue(messages=lm_input) - - if prompt_value is None: - raise ValueError( - f"Invalid input type {type(lm_input)}. " - "Must be a PromptValue, str, or list of BaseMessages." - ) - - return prompt_value.to_string() - - -def message_to_string(message: BaseMessage) -> str: - if not isinstance(message.content, str): - raise RuntimeError("LLM message not in expected format.") - - return message.content - - -def message_generator_to_string_generator( - messages: Iterator[BaseMessage], -) -> Iterator[str]: - for message in messages: - yield message_to_string(message) - - -def should_be_verbose() -> bool: - return LOG_LEVEL == "debug" - - -# estimate of the number of tokens in an image url -# is correct when downsampling is used. Is very wrong when OpenAI does not downsample -# TODO: improve this -_IMG_TOKENS = 85 - - -def check_message_tokens( - message: BaseMessage, encode_fn: Callable[[str], list] | None = None -) -> int: - if isinstance(message.content, str): - return check_number_of_tokens(message.content, encode_fn) - - total_tokens = 0 - for part in message.content: - if isinstance(part, str): - total_tokens += check_number_of_tokens(part, encode_fn) - continue - - if part["type"] == "text": - total_tokens += check_number_of_tokens(part["text"], encode_fn) - elif part["type"] == "image_url": - total_tokens += _IMG_TOKENS - - if isinstance(message, AIMessage) and message.tool_calls: - for tool_call in message.tool_calls: - total_tokens += check_number_of_tokens( - json.dumps(tool_call["args"]), encode_fn - ) - total_tokens += check_number_of_tokens(tool_call["name"], encode_fn) - - return total_tokens - - -def check_number_of_tokens( - text: str, encode_fn: Callable[[str], list] | None = None -) -> int: - """Gets the number of tokens in the provided text, using the provided encoding - function. If none is provided, default to the tiktoken encoder used by GPT-3.5 - and GPT-4. - """ - - if encode_fn is None: - encode_fn = tiktoken.get_encoding("cl100k_base").encode - - return len(encode_fn(text)) - - -def test_llm(llm: LLM) -> str | None: - # try for up to 2 timeouts (e.g. 10 seconds in total) - error_msg = None - for _ in range(2): - try: - llm.invoke("Do not respond") - return None - except Exception as e: - error_msg = str(e) - logger.warning(f"Failed to call LLM with the following error: {error_msg}") - - return error_msg - - -def get_llm_max_tokens( - model_map: dict, - model_name: str, - model_provider: str = GEN_AI_MODEL_PROVIDER, -) -> int: - """Best effort attempt to get the max tokens for the LLM""" - if GEN_AI_MAX_TOKENS: - # This is an override, so always return this - logger.info(f"Using override GEN_AI_MAX_TOKENS: {GEN_AI_MAX_TOKENS}") - return GEN_AI_MAX_TOKENS - - try: - model_obj = model_map.get(f"{model_provider}/{model_name}") - if not model_obj: - model_obj = model_map[model_name] - logger.debug(f"Using model object for {model_name}") - else: - logger.debug(f"Using model object for {model_provider}/{model_name}") - - if "max_input_tokens" in model_obj: - max_tokens = model_obj["max_input_tokens"] - logger.info( - f"Max tokens for {model_name}: {max_tokens} (from max_input_tokens)" - ) - return max_tokens - - if "max_tokens" in model_obj: - max_tokens = model_obj["max_tokens"] - logger.info(f"Max tokens for {model_name}: {max_tokens} (from max_tokens)") - return max_tokens - - logger.error(f"No max tokens found for LLM: {model_name}") - raise RuntimeError("No max tokens found for LLM") - except Exception: - logger.exception( - f"Failed to get max tokens for LLM with name {model_name}. Defaulting to {GEN_AI_MODEL_FALLBACK_MAX_TOKENS}." - ) - return GEN_AI_MODEL_FALLBACK_MAX_TOKENS - - -def get_llm_max_output_tokens( - model_map: dict, - model_name: str, - model_provider: str = GEN_AI_MODEL_PROVIDER, -) -> int: - """Best effort attempt to get the max output tokens for the LLM""" - try: - model_obj = model_map.get(f"{model_provider}/{model_name}") - if not model_obj: - model_obj = model_map[model_name] - logger.debug(f"Using model object for {model_name}") - else: - logger.debug(f"Using model object for {model_provider}/{model_name}") - - if "max_output_tokens" in model_obj: - max_output_tokens = model_obj["max_output_tokens"] - logger.info(f"Max output tokens for {model_name}: {max_output_tokens}") - return max_output_tokens - - # Fallback to a fraction of max_tokens if max_output_tokens is not specified - if "max_tokens" in model_obj: - max_output_tokens = int(model_obj["max_tokens"] * 0.1) - logger.info( - f"Fallback max output tokens for {model_name}: {max_output_tokens} (10% of max_tokens)" - ) - return max_output_tokens - - logger.error(f"No max output tokens found for LLM: {model_name}") - raise RuntimeError("No max output tokens found for LLM") - except Exception: - default_output_tokens = int(GEN_AI_MODEL_FALLBACK_MAX_TOKENS) - logger.exception( - f"Failed to get max output tokens for LLM with name {model_name}. " - f"Defaulting to {default_output_tokens} (fallback max tokens)." - ) - return default_output_tokens - - -def get_max_input_tokens( - model_name: str, - model_provider: str, - output_tokens: int = GEN_AI_NUM_RESERVED_OUTPUT_TOKENS, -) -> int: - # NOTE: we previously used `litellm.get_max_tokens()`, but despite the name, this actually - # returns the max OUTPUT tokens. Under the hood, this uses the `litellm.model_cost` dict, - # and there is no other interface to get what we want. This should be okay though, since the - # `model_cost` dict is a named public interface: - # https://litellm.vercel.app/docs/completion/token_usage#7-model_cost - # model_map is litellm.model_cost - litellm_model_map = litellm.model_cost - - input_toks = ( - get_llm_max_tokens( - model_name=model_name, - model_provider=model_provider, - model_map=litellm_model_map, - ) - - output_tokens - ) - - if input_toks <= 0: - raise RuntimeError("No tokens for input for the LLM given settings") - - return input_toks diff --git a/backend/danswer/main.py b/backend/danswer/main.py deleted file mode 100644 index 6652e5d3c39..00000000000 --- a/backend/danswer/main.py +++ /dev/null @@ -1,516 +0,0 @@ -import time -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import Any -from typing import cast - -import uvicorn -from fastapi import APIRouter -from fastapi import FastAPI -from fastapi import Request -from fastapi.exceptions import RequestValidationError -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from httpx_oauth.clients.google import GoogleOAuth2 -from sqlalchemy.orm import Session - -from danswer import __version__ -from danswer.auth.schemas import UserCreate -from danswer.auth.schemas import UserRead -from danswer.auth.schemas import UserUpdate -from danswer.auth.users import auth_backend -from danswer.auth.users import fastapi_users -from danswer.chat.load_yamls import load_chat_yamls -from danswer.configs.app_configs import APP_API_PREFIX -from danswer.configs.app_configs import APP_HOST -from danswer.configs.app_configs import APP_PORT -from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.app_configs import DISABLE_GENERATIVE_AI -from danswer.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP -from danswer.configs.app_configs import LOG_ENDPOINT_LATENCY -from danswer.configs.app_configs import OAUTH_CLIENT_ID -from danswer.configs.app_configs import OAUTH_CLIENT_SECRET -from danswer.configs.app_configs import USER_AUTH_SECRET -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import AuthType -from danswer.configs.constants import KV_REINDEX_KEY -from danswer.configs.constants import KV_SEARCH_SETTINGS -from danswer.configs.constants import POSTGRES_WEB_APP_NAME -from danswer.db.connector import check_connectors_exist -from danswer.db.connector import create_initial_default_connector -from danswer.db.connector_credential_pair import associate_default_cc_pair -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.connector_credential_pair import resync_cc_pair -from danswer.db.credentials import create_initial_public_credential -from danswer.db.document import check_docs_exist -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.engine import init_sqlalchemy_engine -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.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.db.search_settings import update_current_search_settings -from danswer.db.search_settings import update_secondary_search_settings -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.document_index.interfaces import DocumentIndex -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.indexing.models import IndexingSetting -from danswer.llm.llm_initialization import load_llm_providers -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.natural_language_processing.search_nlp_models import warm_up_bi_encoder -from danswer.natural_language_processing.search_nlp_models import warm_up_cross_encoder -from danswer.search.models import SavedSearchSettings -from danswer.search.retrieval.search_runner import download_nltk_data -from danswer.server.auth_check import check_router_auth -from danswer.server.danswer_api.ingestion import router as danswer_api_router -from danswer.server.documents.cc_pair import router as cc_pair_router -from danswer.server.documents.connector import router as connector_router -from danswer.server.documents.credential import router as credential_router -from danswer.server.documents.document import router as document_router -from danswer.server.documents.indexing import router as indexing_router -from danswer.server.features.document_set.api import router as document_set_router -from danswer.server.features.folder.api import router as folder_router -from danswer.server.features.input_prompt.api import ( - admin_router as admin_input_prompt_router, -) -from danswer.server.features.input_prompt.api import basic_router as input_prompt_router -from danswer.server.features.persona.api import admin_router as admin_persona_router -from danswer.server.features.persona.api import basic_router as persona_router -from danswer.server.features.prompt.api import basic_router as prompt_router -from danswer.server.features.tool.api import admin_router as admin_tool_router -from danswer.server.features.tool.api import router as tool_router -from danswer.server.gpts.api import router as gpts_router -from danswer.server.manage.administrative import router as admin_router -from danswer.server.manage.embedding.api import admin_router as embedding_admin_router -from danswer.server.manage.embedding.api import basic_router as embedding_router -from danswer.server.manage.get_state import router as state_router -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.search_settings import router as search_settings_router -from danswer.server.manage.slack_bot import router as slack_bot_management_router -from danswer.server.manage.standard_answer import router as standard_answer_router -from danswer.server.manage.users import router as user_router -from danswer.server.middleware.latency_logging import add_latency_logging_middleware -from danswer.server.query_and_chat.chat_backend import router as chat_router -from danswer.server.query_and_chat.query_backend import ( - admin_router as admin_query_router, -) -from danswer.server.query_and_chat.query_backend import basic_router as query_router -from danswer.server.settings.api import admin_router as settings_admin_router -from danswer.server.settings.api import basic_router as settings_router -from danswer.server.token_rate_limits.api import ( - router as token_rate_limit_settings_router, -) -from danswer.tools.built_in_tools import auto_add_search_tool_to_personas -from danswer.tools.built_in_tools import load_builtin_tools -from danswer.tools.built_in_tools import refresh_built_in_tools_cache -from danswer.utils.logger import setup_logger -from danswer.utils.telemetry import optional_telemetry -from danswer.utils.telemetry import RecordType -from danswer.utils.variable_functionality import fetch_versioned_implementation -from danswer.utils.variable_functionality import global_version -from danswer.utils.variable_functionality import set_is_ee_based_on_env_variable -from shared_configs.configs import MODEL_SERVER_HOST -from shared_configs.configs import MODEL_SERVER_PORT - - -logger = setup_logger() - - -def validation_exception_handler(request: Request, exc: Exception) -> JSONResponse: - if not isinstance(exc, RequestValidationError): - logger.error( - f"Unexpected exception type in validation_exception_handler - {type(exc)}" - ) - raise exc - - exc_str = f"{exc}".replace("\n", " ").replace(" ", " ") - logger.exception(f"{request}: {exc_str}") - content = {"status_code": 422, "message": exc_str, "data": None} - return JSONResponse(content=content, status_code=422) - - -def value_error_handler(_: Request, exc: Exception) -> JSONResponse: - if not isinstance(exc, ValueError): - logger.error(f"Unexpected exception type in value_error_handler - {type(exc)}") - raise exc - - try: - raise (exc) - except Exception: - # log stacktrace - logger.exception("ValueError") - return JSONResponse( - status_code=400, - content={"message": str(exc)}, - ) - - -def include_router_with_global_prefix_prepended( - application: FastAPI, router: APIRouter, **kwargs: Any -) -> None: - """Adds the global prefix to all routes in the router.""" - processed_global_prefix = f"/{APP_API_PREFIX.strip('/')}" if APP_API_PREFIX else "" - - passed_in_prefix = cast(str | None, kwargs.get("prefix")) - if passed_in_prefix: - final_prefix = f"{processed_global_prefix}/{passed_in_prefix.strip('/')}" - else: - final_prefix = f"{processed_global_prefix}" - final_kwargs: dict[str, Any] = { - **kwargs, - "prefix": final_prefix, - } - - application.include_router(router, **final_kwargs) - - -def setup_postgres(db_session: Session) -> None: - logger.notice("Verifying default connector/credential exist.") - create_initial_public_credential(db_session) - create_initial_default_connector(db_session) - associate_default_cc_pair(db_session) - - logger.notice("Verifying default standard answer category exists.") - create_initial_default_standard_answer_category(db_session) - - logger.notice("Loading LLM providers from env variables") - load_llm_providers(db_session) - - logger.notice("Loading default Prompts and Personas") - delete_old_default_personas(db_session) - load_chat_yamls() - - logger.notice("Loading built-in tools") - load_builtin_tools(db_session) - refresh_built_in_tools_cache(db_session) - auto_add_search_tool_to_personas(db_session) - - -def translate_saved_search_settings(db_session: Session) -> None: - kv_store = get_dynamic_config_store() - - try: - search_settings_dict = kv_store.load(KV_SEARCH_SETTINGS) - if isinstance(search_settings_dict, dict): - # Update current search settings - current_settings = get_current_search_settings(db_session) - - # Update non-preserved fields - if current_settings: - current_settings_dict = SavedSearchSettings.from_db_model( - current_settings - ).dict() - - new_current_settings = SavedSearchSettings( - **{**current_settings_dict, **search_settings_dict} - ) - update_current_search_settings(db_session, new_current_settings) - - # Update secondary search settings - secondary_settings = get_secondary_search_settings(db_session) - if secondary_settings: - secondary_settings_dict = SavedSearchSettings.from_db_model( - secondary_settings - ).dict() - - new_secondary_settings = SavedSearchSettings( - **{**secondary_settings_dict, **search_settings_dict} - ) - update_secondary_search_settings( - db_session, - new_secondary_settings, - ) - # Delete the KV store entry after successful update - kv_store.delete(KV_SEARCH_SETTINGS) - logger.notice("Search settings updated and KV store entry deleted.") - else: - logger.notice("KV store search settings is empty.") - except ConfigNotFoundError: - logger.notice("No search config found in KV store.") - - -def mark_reindex_flag(db_session: Session) -> None: - kv_store = get_dynamic_config_store() - try: - value = kv_store.load(KV_REINDEX_KEY) - logger.debug(f"Re-indexing flag has value {value}") - return - except ConfigNotFoundError: - # Only need to update the flag if it hasn't been set - pass - - # If their first deployment is after the changes, it will - # enable this when the other changes go in, need to avoid - # this being set to False, then the user indexes things on the old version - docs_exist = check_docs_exist(db_session) - connectors_exist = check_connectors_exist(db_session) - if docs_exist or connectors_exist: - kv_store.store(KV_REINDEX_KEY, True) - else: - kv_store.store(KV_REINDEX_KEY, False) - - -def setup_vespa( - document_index: DocumentIndex, - index_setting: IndexingSetting, - secondary_index_setting: IndexingSetting | None, -) -> None: - # Vespa startup is a bit slow, so give it a few seconds - wait_time = 5 - for _ in range(5): - try: - document_index.ensure_indices_exist( - index_embedding_dim=index_setting.model_dim, - secondary_index_embedding_dim=secondary_index_setting.model_dim - if secondary_index_setting - else None, - ) - break - except Exception: - logger.notice(f"Waiting on Vespa, retrying in {wait_time} seconds...") - time.sleep(wait_time) - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator: - init_sqlalchemy_engine(POSTGRES_WEB_APP_NAME) - engine = get_sqlalchemy_engine() - - verify_auth = fetch_versioned_implementation( - "danswer.auth.users", "verify_auth_setting" - ) - # Will throw exception if an issue is found - verify_auth() - - if OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET: - logger.notice("Both OAuth Client ID and Secret are configured.") - - if DISABLE_GENERATIVE_AI: - logger.notice("Generative AI Q&A disabled") - - # fill up Postgres connection pools - await warm_up_connections() - - with Session(engine) as db_session: - check_index_swap(db_session=db_session) - search_settings = get_current_search_settings(db_session) - secondary_search_settings = get_secondary_search_settings(db_session) - - # Break bad state for thrashing indexes - if secondary_search_settings and DISABLE_INDEX_UPDATE_ON_SWAP: - expire_index_attempts( - search_settings_id=search_settings.id, db_session=db_session - ) - - for cc_pair in get_connector_credential_pairs(db_session): - resync_cc_pair(cc_pair, db_session=db_session) - - # Expire all old embedding models indexing attempts, technically redundant - cancel_indexing_attempts_past_model(db_session) - - logger.notice(f'Using Embedding model: "{search_settings.model_name}"') - if search_settings.query_prefix or search_settings.passage_prefix: - logger.notice(f'Query embedding prefix: "{search_settings.query_prefix}"') - logger.notice( - f'Passage embedding prefix: "{search_settings.passage_prefix}"' - ) - - if search_settings: - if not search_settings.disable_rerank_for_streaming: - logger.notice("Reranking is enabled.") - - if search_settings.multilingual_expansion: - logger.notice( - f"Multilingual query expansion is enabled with {search_settings.multilingual_expansion}." - ) - - if search_settings.rerank_model_name and not search_settings.provider_type: - warm_up_cross_encoder(search_settings.rerank_model_name) - - logger.notice("Verifying query preprocessing (NLTK) data is downloaded") - download_nltk_data() - - # setup Postgres with default credential, llm providers, etc. - setup_postgres(db_session) - - translate_saved_search_settings(db_session) - - # Does the user need to trigger a reindexing to bring the document index - # into a good state, marked in the kv store - mark_reindex_flag(db_session) - - # ensure Vespa is setup correctly - logger.notice("Verifying Document Index(s) is/are available.") - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, - secondary_index_name=secondary_search_settings.index_name - if secondary_search_settings - else None, - ) - setup_vespa( - document_index, - IndexingSetting.from_db_model(search_settings), - IndexingSetting.from_db_model(secondary_search_settings) - if secondary_search_settings - else None, - ) - - logger.notice(f"Model Server: http://{MODEL_SERVER_HOST}:{MODEL_SERVER_PORT}") - if search_settings.provider_type is None: - warm_up_bi_encoder( - embedding_model=EmbeddingModel.from_db_model( - search_settings=search_settings, - server_host=MODEL_SERVER_HOST, - server_port=MODEL_SERVER_PORT, - ), - ) - - optional_telemetry(record_type=RecordType.VERSION, data={"version": __version__}) - yield - - -def get_application() -> FastAPI: - application = FastAPI( - title="Danswer Backend", version=__version__, lifespan=lifespan - ) - - include_router_with_global_prefix_prepended(application, chat_router) - include_router_with_global_prefix_prepended(application, query_router) - include_router_with_global_prefix_prepended(application, document_router) - include_router_with_global_prefix_prepended(application, admin_query_router) - include_router_with_global_prefix_prepended(application, admin_router) - include_router_with_global_prefix_prepended(application, user_router) - include_router_with_global_prefix_prepended(application, connector_router) - include_router_with_global_prefix_prepended(application, credential_router) - include_router_with_global_prefix_prepended(application, cc_pair_router) - include_router_with_global_prefix_prepended(application, folder_router) - include_router_with_global_prefix_prepended(application, document_set_router) - include_router_with_global_prefix_prepended(application, search_settings_router) - include_router_with_global_prefix_prepended( - application, slack_bot_management_router - ) - include_router_with_global_prefix_prepended(application, standard_answer_router) - include_router_with_global_prefix_prepended(application, persona_router) - include_router_with_global_prefix_prepended(application, admin_persona_router) - include_router_with_global_prefix_prepended(application, input_prompt_router) - include_router_with_global_prefix_prepended(application, admin_input_prompt_router) - include_router_with_global_prefix_prepended(application, prompt_router) - include_router_with_global_prefix_prepended(application, tool_router) - include_router_with_global_prefix_prepended(application, admin_tool_router) - include_router_with_global_prefix_prepended(application, state_router) - include_router_with_global_prefix_prepended(application, danswer_api_router) - include_router_with_global_prefix_prepended(application, gpts_router) - include_router_with_global_prefix_prepended(application, settings_router) - include_router_with_global_prefix_prepended(application, settings_admin_router) - include_router_with_global_prefix_prepended(application, llm_admin_router) - include_router_with_global_prefix_prepended(application, llm_router) - include_router_with_global_prefix_prepended(application, embedding_admin_router) - include_router_with_global_prefix_prepended(application, embedding_router) - include_router_with_global_prefix_prepended( - application, token_rate_limit_settings_router - ) - include_router_with_global_prefix_prepended(application, indexing_router) - - if AUTH_TYPE == AuthType.DISABLED: - # Server logs this during auth setup verification step - pass - - elif AUTH_TYPE == AuthType.BASIC: - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_auth_router(auth_backend), - prefix="/auth", - tags=["auth"], - ) - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_register_router(UserRead, UserCreate), - prefix="/auth", - tags=["auth"], - ) - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_reset_password_router(), - prefix="/auth", - tags=["auth"], - ) - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_verify_router(UserRead), - prefix="/auth", - tags=["auth"], - ) - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_users_router(UserRead, UserUpdate), - prefix="/users", - tags=["users"], - ) - - elif AUTH_TYPE == AuthType.GOOGLE_OAUTH: - oauth_client = GoogleOAuth2(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_oauth_router( - oauth_client, - auth_backend, - USER_AUTH_SECRET, - associate_by_email=True, - is_verified_by_default=True, - # Points the user back to the login page - redirect_url=f"{WEB_DOMAIN}/auth/oauth/callback", - ), - prefix="/auth/oauth", - tags=["auth"], - ) - # Need basic auth router for `logout` endpoint - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_logout_router(auth_backend), - prefix="/auth", - tags=["auth"], - ) - - application.add_exception_handler( - RequestValidationError, validation_exception_handler - ) - - application.add_exception_handler(ValueError, value_error_handler) - - application.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Change this to the list of allowed origins if needed - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - if LOG_ENDPOINT_LATENCY: - add_latency_logging_middleware(application, logger) - - # Ensure all routes have auth enabled or are explicitly marked as public - check_router_auth(application) - - return application - - -# NOTE: needs to be outside of the `if __name__ == "__main__"` block so that the -# app is exportable -set_is_ee_based_on_env_variable() -app = fetch_versioned_implementation(module="danswer.main", attribute="get_application") - - -if __name__ == "__main__": - logger.notice( - f"Starting Danswer Backend version {__version__} on http://{APP_HOST}:{str(APP_PORT)}/" - ) - - if global_version.get_is_ee_version(): - logger.notice("Running Enterprise Edition") - - uvicorn.run(app, host=APP_HOST, port=APP_PORT) diff --git a/backend/danswer/natural_language_processing/__init__.py b/backend/danswer/natural_language_processing/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/natural_language_processing/search_nlp_models.py b/backend/danswer/natural_language_processing/search_nlp_models.py deleted file mode 100644 index b7835c4e906..00000000000 --- a/backend/danswer/natural_language_processing/search_nlp_models.py +++ /dev/null @@ -1,385 +0,0 @@ -import re -import threading -import time -from collections.abc import Callable -from functools import wraps -from typing import Any - -import requests -from httpx import HTTPError -from retry import retry - -from danswer.configs.app_configs import LARGE_CHUNK_RATIO -from danswer.configs.model_configs import BATCH_SIZE_ENCODE_CHUNKS -from danswer.configs.model_configs import ( - BATCH_SIZE_ENCODE_CHUNKS_FOR_API_EMBEDDING_SERVICES, -) -from danswer.configs.model_configs import DOC_EMBEDDING_CONTEXT_SIZE -from danswer.db.models import SearchSettings -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.natural_language_processing.utils import tokenizer_trim_content -from danswer.utils.logger import setup_logger -from shared_configs.configs import MODEL_SERVER_HOST -from shared_configs.configs import MODEL_SERVER_PORT -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import EmbedTextType -from shared_configs.enums import RerankerProvider -from shared_configs.model_server_models import Embedding -from shared_configs.model_server_models import EmbedRequest -from shared_configs.model_server_models import EmbedResponse -from shared_configs.model_server_models import IntentRequest -from shared_configs.model_server_models import IntentResponse -from shared_configs.model_server_models import RerankRequest -from shared_configs.model_server_models import RerankResponse -from shared_configs.utils import batch_list - -logger = setup_logger() - - -WARM_UP_STRINGS = [ - "Danswer is amazing!", - "Check out our easy deployment guide at", - "https://docs.danswer.dev/quickstart", -] - - -def clean_model_name(model_str: str) -> str: - return model_str.replace("/", "_").replace("-", "_").replace(".", "_") - - -_WHITELIST = set( - " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\n\t" -) -_INITIAL_FILTER = re.compile( - "[" - "\U00000080-\U0000FFFF" # All Unicode characters beyond ASCII - "\U00010000-\U0010FFFF" # All Unicode characters in supplementary planes - "]+", - flags=re.UNICODE, -) - - -def clean_openai_text(text: str) -> str: - # First, remove all weird characters - cleaned = _INITIAL_FILTER.sub("", text) - # Then, keep only whitelisted characters - return "".join(char for char in cleaned if char in _WHITELIST) - - -def build_model_server_url( - model_server_host: str, - model_server_port: int, -) -> str: - model_server_url = f"{model_server_host}:{model_server_port}" - - # use protocol if provided - if "http" in model_server_url: - return model_server_url - - # otherwise default to http - return f"http://{model_server_url}" - - -class EmbeddingModel: - def __init__( - self, - server_host: str, # Changes depending on indexing or inference - server_port: int, - model_name: str | None, - normalize: bool, - query_prefix: str | None, - passage_prefix: str | None, - api_key: str | None, - provider_type: EmbeddingProvider | None, - retrim_content: bool = False, - ) -> None: - self.api_key = api_key - self.provider_type = provider_type - self.query_prefix = query_prefix - self.passage_prefix = passage_prefix - self.normalize = normalize - self.model_name = model_name - self.retrim_content = retrim_content - self.tokenizer = get_tokenizer( - model_name=model_name, provider_type=provider_type - ) - - model_server_url = build_model_server_url(server_host, server_port) - self.embed_server_endpoint = f"{model_server_url}/encoder/bi-encoder-embed" - - def _make_model_server_request(self, embed_request: EmbedRequest) -> EmbedResponse: - def _make_request() -> EmbedResponse: - response = requests.post( - self.embed_server_endpoint, json=embed_request.model_dump() - ) - try: - response.raise_for_status() - except requests.HTTPError as e: - try: - error_detail = response.json().get("detail", str(e)) - except Exception: - error_detail = response.text - raise HTTPError(f"HTTP error occurred: {error_detail}") from e - except requests.RequestException as e: - raise HTTPError(f"Request failed: {str(e)}") from e - - return EmbedResponse(**response.json()) - - # only perform retries for the non-realtime embedding of passages (e.g. for indexing) - if embed_request.text_type == EmbedTextType.PASSAGE: - return retry(tries=3, delay=5)(_make_request)() - else: - return _make_request() - - def _batch_encode_texts( - self, - texts: list[str], - text_type: EmbedTextType, - batch_size: int, - max_seq_length: int, - ) -> list[Embedding]: - text_batches = batch_list(texts, batch_size) - - logger.debug( - f"Encoding {len(texts)} texts in {len(text_batches)} batches for local model" - ) - - embeddings: list[Embedding] = [] - for idx, text_batch in enumerate(text_batches, start=1): - logger.debug(f"Encoding batch {idx} of {len(text_batches)}") - embed_request = EmbedRequest( - model_name=self.model_name, - texts=text_batch, - max_context_length=max_seq_length, - normalize_embeddings=self.normalize, - api_key=self.api_key, - provider_type=self.provider_type, - text_type=text_type, - manual_query_prefix=self.query_prefix, - manual_passage_prefix=self.passage_prefix, - ) - - response = self._make_model_server_request(embed_request) - embeddings.extend(response.embeddings) - return embeddings - - def encode( - self, - texts: list[str], - text_type: EmbedTextType, - large_chunks_present: bool = False, - local_embedding_batch_size: int = BATCH_SIZE_ENCODE_CHUNKS, - api_embedding_batch_size: int = BATCH_SIZE_ENCODE_CHUNKS_FOR_API_EMBEDDING_SERVICES, - max_seq_length: int = DOC_EMBEDDING_CONTEXT_SIZE, - ) -> list[Embedding]: - if not texts or not all(texts): - raise ValueError(f"Empty or missing text for embedding: {texts}") - - if large_chunks_present: - max_seq_length *= LARGE_CHUNK_RATIO - - if self.retrim_content: - # This is applied during indexing as a catchall for overly long titles (or other uncapped fields) - # Note that this uses just the default tokenizer which may also lead to very minor miscountings - # However this slight miscounting is very unlikely to have any material impact. - texts = [ - tokenizer_trim_content( - content=text, - desired_length=max_seq_length, - tokenizer=self.tokenizer, - ) - for text in texts - ] - - if self.provider_type == EmbeddingProvider.OPENAI: - # If the provider is openai, we need to clean the text - # as a temporary workaround for the openai API - texts = [clean_openai_text(text) for text in texts] - - batch_size = ( - api_embedding_batch_size - if self.provider_type - else local_embedding_batch_size - ) - - return self._batch_encode_texts( - texts=texts, - text_type=text_type, - batch_size=batch_size, - max_seq_length=max_seq_length, - ) - - @classmethod - def from_db_model( - cls, - search_settings: SearchSettings, - server_host: str, # Changes depending on indexing or inference - server_port: int, - retrim_content: bool = False, - ) -> "EmbeddingModel": - return cls( - server_host=server_host, - server_port=server_port, - model_name=search_settings.model_name, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - api_key=search_settings.api_key, - provider_type=search_settings.provider_type, - retrim_content=retrim_content, - ) - - -class RerankingModel: - def __init__( - self, - model_name: str, - provider_type: RerankerProvider | None, - api_key: str | None, - model_server_host: str = MODEL_SERVER_HOST, - model_server_port: int = MODEL_SERVER_PORT, - ) -> None: - model_server_url = build_model_server_url(model_server_host, model_server_port) - self.rerank_server_endpoint = model_server_url + "/encoder/cross-encoder-scores" - self.model_name = model_name - self.provider_type = provider_type - self.api_key = api_key - - def predict(self, query: str, passages: list[str]) -> list[float]: - rerank_request = RerankRequest( - query=query, - documents=passages, - model_name=self.model_name, - provider_type=self.provider_type, - api_key=self.api_key, - ) - - response = requests.post( - self.rerank_server_endpoint, json=rerank_request.model_dump() - ) - response.raise_for_status() - - return RerankResponse(**response.json()).scores - - -class QueryAnalysisModel: - def __init__( - self, - model_server_host: str = MODEL_SERVER_HOST, - model_server_port: int = MODEL_SERVER_PORT, - # Lean heavily towards not throwing out keywords - keyword_percent_threshold: float = 0.1, - # Lean towards semantic which is the default - semantic_percent_threshold: float = 0.4, - ) -> None: - model_server_url = build_model_server_url(model_server_host, model_server_port) - self.intent_server_endpoint = model_server_url + "/custom/query-analysis" - self.keyword_percent_threshold = keyword_percent_threshold - self.semantic_percent_threshold = semantic_percent_threshold - - def predict( - self, - query: str, - ) -> tuple[bool, list[str]]: - intent_request = IntentRequest( - query=query, - keyword_percent_threshold=self.keyword_percent_threshold, - semantic_percent_threshold=self.semantic_percent_threshold, - ) - - response = requests.post( - self.intent_server_endpoint, json=intent_request.model_dump() - ) - response.raise_for_status() - - response_model = IntentResponse(**response.json()) - - return response_model.is_keyword, response_model.keywords - - -def warm_up_retry( - func: Callable[..., Any], - tries: int = 20, - delay: int = 5, - *args: Any, - **kwargs: Any, -) -> Callable[..., Any]: - @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - exceptions = [] - for attempt in range(tries): - try: - return func(*args, **kwargs) - except Exception as e: - exceptions.append(e) - logger.exception( - f"Attempt {attempt + 1} failed; retrying in {delay} seconds..." - ) - time.sleep(delay) - raise Exception(f"All retries failed: {exceptions}") - - return wrapper - - -def warm_up_bi_encoder( - embedding_model: EmbeddingModel, - non_blocking: bool = False, -) -> None: - warm_up_str = " ".join(WARM_UP_STRINGS) - - logger.debug(f"Warming up encoder model: {embedding_model.model_name}") - get_tokenizer( - model_name=embedding_model.model_name, - provider_type=embedding_model.provider_type, - ).encode(warm_up_str) - - def _warm_up() -> None: - try: - embedding_model.encode(texts=[warm_up_str], text_type=EmbedTextType.QUERY) - logger.debug( - f"Warm-up complete for encoder model: {embedding_model.model_name}" - ) - except Exception as e: - logger.warning( - f"Warm-up request failed for encoder model {embedding_model.model_name}: {e}" - ) - - if non_blocking: - threading.Thread(target=_warm_up, daemon=True).start() - logger.debug( - f"Started non-blocking warm-up for encoder model: {embedding_model.model_name}" - ) - else: - retry_encode = warm_up_retry(embedding_model.encode) - retry_encode(texts=[warm_up_str], text_type=EmbedTextType.QUERY) - - -def warm_up_cross_encoder( - rerank_model_name: str, - non_blocking: bool = False, -) -> None: - logger.debug(f"Warming up reranking model: {rerank_model_name}") - - reranking_model = RerankingModel( - model_name=rerank_model_name, - provider_type=None, - api_key=None, - ) - - def _warm_up() -> None: - try: - reranking_model.predict(WARM_UP_STRINGS[0], WARM_UP_STRINGS[1:]) - logger.debug(f"Warm-up complete for reranking model: {rerank_model_name}") - except Exception as e: - logger.warning( - f"Warm-up request failed for reranking model {rerank_model_name}: {e}" - ) - - if non_blocking: - threading.Thread(target=_warm_up, daemon=True).start() - logger.debug( - f"Started non-blocking warm-up for reranking model: {rerank_model_name}" - ) - else: - retry_rerank = warm_up_retry(reranking_model.predict) - retry_rerank(WARM_UP_STRINGS[0], WARM_UP_STRINGS[1:]) diff --git a/backend/danswer/natural_language_processing/utils.py b/backend/danswer/natural_language_processing/utils.py deleted file mode 100644 index d2b9a7d7f1e..00000000000 --- a/backend/danswer/natural_language_processing/utils.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -from abc import ABC -from abc import abstractmethod -from copy import copy - -from transformers import logging as transformer_logging # type:ignore - -from danswer.configs.model_configs import DOC_EMBEDDING_CONTEXT_SIZE -from danswer.configs.model_configs import DOCUMENT_ENCODER_MODEL -from danswer.search.models import InferenceChunk -from danswer.utils.logger import setup_logger -from shared_configs.enums import EmbeddingProvider - -logger = setup_logger() -transformer_logging.set_verbosity_error() -os.environ["TOKENIZERS_PARALLELISM"] = "false" -os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" -os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1" - - -class BaseTokenizer(ABC): - @abstractmethod - def encode(self, string: str) -> list[int]: - pass - - @abstractmethod - def tokenize(self, string: str) -> list[str]: - pass - - @abstractmethod - def decode(self, tokens: list[int]) -> str: - pass - - -class TiktokenTokenizer(BaseTokenizer): - _instances: dict[str, "TiktokenTokenizer"] = {} - - def __new__(cls, encoding_name: str = "cl100k_base") -> "TiktokenTokenizer": - if encoding_name not in cls._instances: - cls._instances[encoding_name] = super(TiktokenTokenizer, cls).__new__(cls) - return cls._instances[encoding_name] - - def __init__(self, encoding_name: str = "cl100k_base"): - if not hasattr(self, "encoder"): - import tiktoken - - self.encoder = tiktoken.get_encoding(encoding_name) - - def encode(self, string: str) -> list[int]: - # this returns no special tokens - return self.encoder.encode_ordinary(string) - - def tokenize(self, string: str) -> list[str]: - return [self.encoder.decode([token]) for token in self.encode(string)] - - def decode(self, tokens: list[int]) -> str: - return self.encoder.decode(tokens) - - -class HuggingFaceTokenizer(BaseTokenizer): - def __init__(self, model_name: str): - from tokenizers import Tokenizer # type: ignore - - self.encoder = Tokenizer.from_pretrained(model_name) - - def encode(self, string: str) -> list[int]: - # this returns no special tokens - return self.encoder.encode(string, add_special_tokens=False).ids - - def tokenize(self, string: str) -> list[str]: - return self.encoder.encode(string, add_special_tokens=False).tokens - - def decode(self, tokens: list[int]) -> str: - return self.encoder.decode(tokens) - - -_TOKENIZER_CACHE: dict[str, BaseTokenizer] = {} - - -def _check_tokenizer_cache(tokenizer_name: str) -> BaseTokenizer: - global _TOKENIZER_CACHE - - if tokenizer_name not in _TOKENIZER_CACHE: - if tokenizer_name == "openai": - _TOKENIZER_CACHE[tokenizer_name] = TiktokenTokenizer("cl100k_base") - return _TOKENIZER_CACHE[tokenizer_name] - try: - logger.debug(f"Initializing HuggingFaceTokenizer for: {tokenizer_name}") - _TOKENIZER_CACHE[tokenizer_name] = HuggingFaceTokenizer(tokenizer_name) - except Exception as primary_error: - logger.error( - f"Error initializing HuggingFaceTokenizer for {tokenizer_name}: {primary_error}" - ) - logger.warning( - f"Falling back to default embedding model: {DOCUMENT_ENCODER_MODEL}" - ) - - try: - # Cache this tokenizer name to the default so we don't have to try to load it again - # and fail again - _TOKENIZER_CACHE[tokenizer_name] = HuggingFaceTokenizer( - DOCUMENT_ENCODER_MODEL - ) - except Exception as fallback_error: - logger.error( - f"Error initializing fallback HuggingFaceTokenizer: {fallback_error}" - ) - raise ValueError( - f"Failed to initialize tokenizer for {tokenizer_name} and fallback model" - ) from fallback_error - - return _TOKENIZER_CACHE[tokenizer_name] - - -_DEFAULT_TOKENIZER: BaseTokenizer = HuggingFaceTokenizer(DOCUMENT_ENCODER_MODEL) - - -def get_tokenizer( - model_name: str | None, provider_type: EmbeddingProvider | str | None -) -> BaseTokenizer: - # Currently all of the viable models use the same sentencepiece tokenizer - # OpenAI uses a different one but currently it's not supported due to quality issues - # the inconsistent chunking makes using the sentencepiece tokenizer default better for now - # LLM tokenizers are specified by strings - global _DEFAULT_TOKENIZER - return _DEFAULT_TOKENIZER - - -def tokenizer_trim_content( - content: str, desired_length: int, tokenizer: BaseTokenizer -) -> str: - tokens = tokenizer.encode(content) - if len(tokens) > desired_length: - content = tokenizer.decode(tokens[:desired_length]) - return content - - -def tokenizer_trim_chunks( - chunks: list[InferenceChunk], - tokenizer: BaseTokenizer, - max_chunk_toks: int = DOC_EMBEDDING_CONTEXT_SIZE, -) -> list[InferenceChunk]: - new_chunks = copy(chunks) - for ind, chunk in enumerate(new_chunks): - new_content = tokenizer_trim_content(chunk.content, max_chunk_toks, tokenizer) - if len(new_content) != len(chunk.content): - new_chunk = copy(chunk) - new_chunk.content = new_content - new_chunks[ind] = new_chunk - return new_chunks diff --git a/backend/danswer/one_shot_answer/__init__.py b/backend/danswer/one_shot_answer/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/one_shot_answer/answer_question.py b/backend/danswer/one_shot_answer/answer_question.py deleted file mode 100644 index a5a0fe0dad5..00000000000 --- a/backend/danswer/one_shot_answer/answer_question.py +++ /dev/null @@ -1,406 +0,0 @@ -from collections.abc import Callable -from collections.abc import Iterator -from typing import cast - -from sqlalchemy.orm import Session - -from danswer.chat.chat_utils import reorganize_citations -from danswer.chat.models import CitationInfo -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import DanswerContexts -from danswer.chat.models import DanswerQuotes -from danswer.chat.models import DocumentRelevance -from danswer.chat.models import LLMRelevanceFilterResponse -from danswer.chat.models import QADocsResponse -from danswer.chat.models import RelevanceAnalysis -from danswer.chat.models import StreamingError -from danswer.configs.chat_configs import DISABLE_LLM_DOC_RELEVANCE -from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT -from danswer.configs.chat_configs import QA_TIMEOUT -from danswer.configs.constants import MessageType -from danswer.db.chat import create_chat_session -from danswer.db.chat import create_db_search_doc -from danswer.db.chat import create_new_chat_message -from danswer.db.chat import get_or_create_root_message -from danswer.db.chat import translate_db_message_to_chat_message_detail -from danswer.db.chat import translate_db_search_doc_to_server_search_doc -from danswer.db.chat import update_search_docs_table_with_relevance -from danswer.db.engine import get_session_context_manager -from danswer.db.models import User -from danswer.db.persona import get_prompt_by_id -from danswer.llm.answering.answer import Answer -from danswer.llm.answering.models import AnswerStyleConfig -from danswer.llm.answering.models import CitationConfig -from danswer.llm.answering.models import DocumentPruningConfig -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.models import QuotesConfig -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.factory import get_main_llm_from_tuple -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.one_shot_answer.models import DirectQARequest -from danswer.one_shot_answer.models import OneShotQAResponse -from danswer.one_shot_answer.models import QueryRephrase -from danswer.one_shot_answer.qa_utils import combine_message_thread -from danswer.search.enums import LLMEvaluationType -from danswer.search.models import RerankMetricsContainer -from danswer.search.models import RetrievalMetricsContainer -from danswer.search.utils import chunks_or_sections_to_search_docs -from danswer.search.utils import dedupe_documents -from danswer.secondary_llm_flows.answer_validation import get_answer_validity -from danswer.secondary_llm_flows.query_expansion import thread_based_query_rephrase -from danswer.server.query_and_chat.models import ChatMessageDetail -from danswer.server.utils import get_json_line -from danswer.tools.force import ForceUseTool -from danswer.tools.search.search_tool import SEARCH_DOC_CONTENT_ID -from danswer.tools.search.search_tool import SEARCH_RESPONSE_SUMMARY_ID -from danswer.tools.search.search_tool import SearchResponseSummary -from danswer.tools.search.search_tool import SearchTool -from danswer.tools.search.search_tool import SECTION_RELEVANCE_LIST_ID -from danswer.tools.tool import ToolResponse -from danswer.tools.tool_runner import ToolCallKickoff -from danswer.utils.logger import setup_logger -from danswer.utils.timing import log_generator_function_time - - -logger = setup_logger() - -AnswerObjectIterator = Iterator[ - QueryRephrase - | QADocsResponse - | LLMRelevanceFilterResponse - | DanswerAnswerPiece - | DanswerQuotes - | DanswerContexts - | StreamingError - | ChatMessageDetail - | CitationInfo - | ToolCallKickoff - | DocumentRelevance -] - - -def stream_answer_objects( - query_req: DirectQARequest, - user: User | None, - # These need to be passed in because in Web UI one shot flow, - # we can have much more document as there is no history. - # For Slack flow, we need to save more tokens for the thread context - max_document_tokens: int | None, - max_history_tokens: int | None, - db_session: Session, - # Needed to translate persona num_chunks to tokens to the LLM - default_num_chunks: float = MAX_CHUNKS_FED_TO_CHAT, - timeout: int = QA_TIMEOUT, - bypass_acl: bool = False, - use_citations: bool = False, - danswerbot_flow: bool = False, - retrieval_metrics_callback: ( - Callable[[RetrievalMetricsContainer], None] | None - ) = None, - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, -) -> AnswerObjectIterator: - """Streams in order: - 1. [always] Retrieved documents, stops flow if nothing is found - 2. [conditional] LLM selected chunk indices if LLM chunk filtering is turned on - 3. [always] A set of streamed DanswerAnswerPiece and DanswerQuotes at the end - or an error anywhere along the line if something fails - 4. [always] Details on the final AI response message that is created - """ - user_id = user.id if user is not None else None - query_msg = query_req.messages[-1] - history = query_req.messages[:-1] - - chat_session = create_chat_session( - db_session=db_session, - description="", # One shot queries don't need naming as it's never displayed - user_id=user_id, - persona_id=query_req.persona_id, - one_shot=True, - danswerbot_flow=danswerbot_flow, - ) - llm, fast_llm = get_llms_for_persona(persona=chat_session.persona) - - llm_tokenizer = get_tokenizer( - model_name=llm.config.model_name, - provider_type=llm.config.model_provider, - ) - - # Create a chat session which will just store the root message, the query, and the AI response - root_message = get_or_create_root_message( - chat_session_id=chat_session.id, db_session=db_session - ) - - history_str = combine_message_thread( - messages=history, - max_tokens=max_history_tokens, - llm_tokenizer=llm_tokenizer, - ) - - rephrased_query = query_req.query_override or thread_based_query_rephrase( - user_query=query_msg.message, - history_str=history_str, - ) - - # Given back ahead of the documents for latency reasons - # In chat flow it's given back along with the documents - yield QueryRephrase(rephrased_query=rephrased_query) - - prompt = None - if query_req.prompt_id is not None: - # NOTE: let the user access any prompt as long as the Persona is shared - # with them - prompt = get_prompt_by_id( - prompt_id=query_req.prompt_id, user=None, db_session=db_session - ) - if prompt is None: - if not chat_session.persona.prompts: - raise RuntimeError( - "Persona does not have any prompts - this should never happen" - ) - prompt = chat_session.persona.prompts[0] - - # Create the first User query message - new_user_message = create_new_chat_message( - chat_session_id=chat_session.id, - parent_message=root_message, - prompt_id=query_req.prompt_id, - message=query_msg.message, - token_count=len(llm_tokenizer.encode(query_msg.message)), - message_type=MessageType.USER, - db_session=db_session, - commit=True, - ) - - prompt_config = PromptConfig.from_model(prompt) - document_pruning_config = DocumentPruningConfig( - max_chunks=int( - chat_session.persona.num_chunks - if chat_session.persona.num_chunks is not None - else default_num_chunks - ), - max_tokens=max_document_tokens, - ) - - search_tool = SearchTool( - db_session=db_session, - user=user, - evaluation_type=LLMEvaluationType.SKIP - if DISABLE_LLM_DOC_RELEVANCE - else query_req.evaluation_type, - persona=chat_session.persona, - retrieval_options=query_req.retrieval_options, - prompt_config=prompt_config, - llm=llm, - fast_llm=fast_llm, - pruning_config=document_pruning_config, - chunks_above=query_req.chunks_above, - chunks_below=query_req.chunks_below, - full_doc=query_req.full_doc, - bypass_acl=bypass_acl, - ) - - answer_config = AnswerStyleConfig( - citation_config=CitationConfig() if use_citations else None, - quotes_config=QuotesConfig() if not use_citations else None, - document_pruning_config=document_pruning_config, - ) - - answer = Answer( - question=query_msg.message, - answer_style_config=answer_config, - prompt_config=PromptConfig.from_model(prompt), - llm=get_main_llm_from_tuple(get_llms_for_persona(persona=chat_session.persona)), - single_message_history=history_str, - tools=[search_tool], - force_use_tool=ForceUseTool( - force_use=True, - tool_name=search_tool.name, - args={"query": rephrased_query}, - ), - # for now, don't use tool calling for this flow, as we haven't - # tested quotes with tool calling too much yet - skip_explicit_tool_calling=True, - return_contexts=query_req.return_contexts, - skip_gen_ai_answer_generation=query_req.skip_gen_ai_answer_generation, - ) - - # won't be any ImageGenerationDisplay responses since that tool is never passed in - - for packet in cast(AnswerObjectIterator, answer.processed_streamed_output): - # for one-shot flow, don't currently do anything with these - if isinstance(packet, ToolResponse): - # (likely fine that it comes after the initial creation of the search docs) - if packet.id == SEARCH_RESPONSE_SUMMARY_ID: - search_response_summary = cast(SearchResponseSummary, packet.response) - - top_docs = chunks_or_sections_to_search_docs( - search_response_summary.top_sections - ) - - # Deduping happens at the last step to avoid harming quality by dropping content early on - deduped_docs = top_docs - if query_req.retrieval_options.dedupe_docs: - deduped_docs, dropped_inds = dedupe_documents(top_docs) - - reference_db_search_docs = [ - create_db_search_doc(server_search_doc=doc, db_session=db_session) - for doc in deduped_docs - ] - - response_docs = [ - translate_db_search_doc_to_server_search_doc(db_search_doc) - for db_search_doc in reference_db_search_docs - ] - - initial_response = QADocsResponse( - rephrased_query=rephrased_query, - top_documents=response_docs, - predicted_flow=search_response_summary.predicted_flow, - predicted_search=search_response_summary.predicted_search, - applied_source_filters=search_response_summary.final_filters.source_type, - applied_time_cutoff=search_response_summary.final_filters.time_cutoff, - recency_bias_multiplier=search_response_summary.recency_bias_multiplier, - ) - yield initial_response - - elif packet.id == SEARCH_DOC_CONTENT_ID: - yield packet.response - - elif packet.id == SECTION_RELEVANCE_LIST_ID: - document_based_response = {} - - if packet.response is not None: - for evaluation in packet.response: - document_based_response[ - evaluation.document_id - ] = RelevanceAnalysis( - relevant=evaluation.relevant, content=evaluation.content - ) - - evaluation_response = DocumentRelevance( - relevance_summaries=document_based_response - ) - if reference_db_search_docs is not None: - update_search_docs_table_with_relevance( - db_session=db_session, - reference_db_search_docs=reference_db_search_docs, - relevance_summary=evaluation_response, - ) - yield evaluation_response - else: - yield packet - - # Saving Gen AI answer and responding with message info - gen_ai_response_message = create_new_chat_message( - chat_session_id=chat_session.id, - parent_message=new_user_message, - prompt_id=query_req.prompt_id, - message=answer.llm_answer, - token_count=len(llm_tokenizer.encode(answer.llm_answer)), - message_type=MessageType.ASSISTANT, - error=None, - reference_docs=reference_db_search_docs, - db_session=db_session, - commit=True, - ) - - msg_detail_response = translate_db_message_to_chat_message_detail( - gen_ai_response_message - ) - yield msg_detail_response - - -@log_generator_function_time() -def stream_search_answer( - query_req: DirectQARequest, - user: User | None, - max_document_tokens: int | None, - max_history_tokens: int | None, -) -> Iterator[str]: - with get_session_context_manager() as session: - objects = stream_answer_objects( - query_req=query_req, - user=user, - max_document_tokens=max_document_tokens, - max_history_tokens=max_history_tokens, - db_session=session, - ) - for obj in objects: - yield get_json_line(obj.model_dump()) - - -def get_search_answer( - query_req: DirectQARequest, - user: User | None, - max_document_tokens: int | None, - max_history_tokens: int | None, - db_session: Session, - answer_generation_timeout: int = QA_TIMEOUT, - enable_reflexion: bool = False, - bypass_acl: bool = False, - use_citations: bool = False, - danswerbot_flow: bool = False, - retrieval_metrics_callback: ( - Callable[[RetrievalMetricsContainer], None] | None - ) = None, - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, -) -> OneShotQAResponse: - """Collects the streamed one shot answer responses into a single object""" - qa_response = OneShotQAResponse() - - results = stream_answer_objects( - query_req=query_req, - user=user, - max_document_tokens=max_document_tokens, - max_history_tokens=max_history_tokens, - db_session=db_session, - bypass_acl=bypass_acl, - use_citations=use_citations, - danswerbot_flow=danswerbot_flow, - timeout=answer_generation_timeout, - retrieval_metrics_callback=retrieval_metrics_callback, - rerank_metrics_callback=rerank_metrics_callback, - ) - - answer = "" - for packet in results: - if isinstance(packet, QueryRephrase): - qa_response.rephrase = packet.rephrased_query - if isinstance(packet, DanswerAnswerPiece) and packet.answer_piece: - answer += packet.answer_piece - elif isinstance(packet, QADocsResponse): - qa_response.docs = packet - elif isinstance(packet, LLMRelevanceFilterResponse): - qa_response.llm_chunks_indices = packet.relevant_chunk_indices - elif isinstance(packet, DanswerQuotes): - qa_response.quotes = packet - elif isinstance(packet, CitationInfo): - if qa_response.citations: - qa_response.citations.append(packet) - else: - qa_response.citations = [packet] - elif isinstance(packet, DanswerContexts): - qa_response.contexts = packet - elif isinstance(packet, StreamingError): - qa_response.error_msg = packet.error - elif isinstance(packet, ChatMessageDetail): - qa_response.chat_message_id = packet.message_id - - if answer: - qa_response.answer = answer - - if enable_reflexion: - # Because follow up messages are explicitly tagged, we don't need to verify the answer - if len(query_req.messages) == 1: - first_query = query_req.messages[0].message - qa_response.answer_valid = get_answer_validity(first_query, answer) - else: - qa_response.answer_valid = True - - if use_citations and qa_response.answer and qa_response.citations: - # Reorganize citation nums to be in the same order as the answer - qa_response.answer, qa_response.citations = reorganize_citations( - qa_response.answer, qa_response.citations - ) - - return qa_response diff --git a/backend/danswer/one_shot_answer/models.py b/backend/danswer/one_shot_answer/models.py deleted file mode 100644 index d7e81975630..00000000000 --- a/backend/danswer/one_shot_answer/models.py +++ /dev/null @@ -1,69 +0,0 @@ -from pydantic import BaseModel -from pydantic import Field -from pydantic import model_validator - -from danswer.chat.models import CitationInfo -from danswer.chat.models import DanswerContexts -from danswer.chat.models import DanswerQuotes -from danswer.chat.models import QADocsResponse -from danswer.configs.constants import MessageType -from danswer.search.enums import LLMEvaluationType -from danswer.search.models import ChunkContext -from danswer.search.models import RerankingDetails -from danswer.search.models import RetrievalDetails - - -class QueryRephrase(BaseModel): - rephrased_query: str - - -class ThreadMessage(BaseModel): - message: str - sender: str | None = None - role: MessageType = MessageType.USER - - -class DirectQARequest(ChunkContext): - messages: list[ThreadMessage] - prompt_id: int | None - persona_id: int - multilingual_query_expansion: list[str] | None = None - retrieval_options: RetrievalDetails = Field(default_factory=RetrievalDetails) - rerank_settings: RerankingDetails | None = None - evaluation_type: LLMEvaluationType = LLMEvaluationType.UNSPECIFIED - - chain_of_thought: bool = False - return_contexts: bool = False - - # allows the caller to specify the exact search query they want to use - # can be used if the message sent to the LLM / query should not be the same - # will also disable Thread-based Rewording if specified - query_override: str | None = None - - # If True, skips generative an AI response to the search query - skip_gen_ai_answer_generation: bool = False - - @model_validator(mode="after") - def check_chain_of_thought_and_prompt_id(self) -> "DirectQARequest": - if self.chain_of_thought and self.prompt_id is not None: - raise ValueError( - "If chain_of_thought is True, prompt_id must be None" - "The chain of thought prompt is only for question " - "answering and does not accept customizing." - ) - - return self - - -class OneShotQAResponse(BaseModel): - # This is built piece by piece, any of these can be None as the flow could break - answer: str | None = None - rephrase: str | None = None - quotes: DanswerQuotes | None = None - citations: list[CitationInfo] | None = None - docs: QADocsResponse | None = None - llm_chunks_indices: list[int] | None = None - error_msg: str | None = None - answer_valid: bool = True # Reflexion result, default True if Reflexion not run - chat_message_id: int | None = None - contexts: DanswerContexts | None = None diff --git a/backend/danswer/one_shot_answer/qa_utils.py b/backend/danswer/one_shot_answer/qa_utils.py deleted file mode 100644 index 6fbad99eff1..00000000000 --- a/backend/danswer/one_shot_answer/qa_utils.py +++ /dev/null @@ -1,53 +0,0 @@ -from collections.abc import Generator - -from danswer.configs.constants import MessageType -from danswer.natural_language_processing.utils import BaseTokenizer -from danswer.one_shot_answer.models import ThreadMessage -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def simulate_streaming_response(model_out: str) -> Generator[str, None, None]: - """Mock streaming by generating the passed in model output, character by character""" - for token in model_out: - yield token - - -def combine_message_thread( - messages: list[ThreadMessage], - max_tokens: int | None, - llm_tokenizer: BaseTokenizer, -) -> str: - """Used to create a single combined message context from threads""" - if not messages: - return "" - - message_strs: list[str] = [] - total_token_count = 0 - - for message in reversed(messages): - if message.role == MessageType.USER: - role_str = message.role.value.upper() - if message.sender: - role_str += " " + message.sender - else: - # Since other messages might have the user identifying information - # better to use Unknown for symmetry - role_str += " Unknown" - else: - role_str = message.role.value.upper() - - msg_str = f"{role_str}:\n{message.message}" - message_token_count = len(llm_tokenizer.encode(msg_str)) - - if ( - max_tokens is not None - and total_token_count + message_token_count > max_tokens - ): - break - - message_strs.insert(0, msg_str) - total_token_count += message_token_count - - return "\n\n".join(message_strs) diff --git a/backend/danswer/prompts/__init__.py b/backend/danswer/prompts/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/prompts/agentic_evaluation.py b/backend/danswer/prompts/agentic_evaluation.py deleted file mode 100644 index 546f40c7f8e..00000000000 --- a/backend/danswer/prompts/agentic_evaluation.py +++ /dev/null @@ -1,44 +0,0 @@ -AGENTIC_SEARCH_SYSTEM_PROMPT = """ -You are an expert at evaluating the relevance of a document to a search query. -Provided a document and a search query, you determine if the document is relevant to the user query. -You ALWAYS output the 3 sections described below and every section always begins with the same header line. -The "Chain of Thought" is to help you understand the document and query and their relevance to one another. -The "Useful Analysis" is shown to the user to help them understand why the document is or is not useful for them. -The "Final Relevance Determination" is always a single True or False. - -You always output your response following these 3 sections: - -1. Chain of Thought: -Provide a chain of thought analysis considering: -- The main purpose and content of the document -- What the user is searching for -- How the document relates to the query -- Potential uses of the document for the given query -Be thorough, but avoid unnecessary repetition. Think step by step. - -2. Useful Analysis: -Summarize the contents of the document as it relates to the user query. -BE ABSOLUTELY AS CONCISE AS POSSIBLE. -If the document is not useful, briefly mention the what the document is about. -Do NOT say whether this document is useful or not useful, ONLY provide the summary. -If referring to the document, prefer using "this" document over "the" document. - -3. Final Relevance Determination: -True or False -""" - -AGENTIC_SEARCH_USER_PROMPT = """ - -Document Title: {title}{optional_metadata} -``` -{content} -``` - -Query: -{query} - -Be sure to run through the 3 steps of evaluation: -1. Chain of Thought -2. Useful Analysis -3. Final Relevance Determination -""".strip() diff --git a/backend/danswer/prompts/answer_validation.py b/backend/danswer/prompts/answer_validation.py deleted file mode 100644 index 28d184aca78..00000000000 --- a/backend/danswer/prompts/answer_validation.py +++ /dev/null @@ -1,61 +0,0 @@ -# The following prompts are used for verifying the LLM answer after it is already produced. -# Reflexion flow essentially. This feature can be toggled on/off -from danswer.configs.app_configs import CUSTOM_ANSWER_VALIDITY_CONDITIONS -from danswer.prompts.constants import ANSWER_PAT -from danswer.prompts.constants import QUESTION_PAT - -ANSWER_VALIDITY_CONDITIONS = ( - """ -1. Query is asking for information that varies by person or is subjective. If there is not a \ -globally true answer, the language model should not respond, therefore any answer is invalid. -2. Answer addresses a related but different query. To be helpful, the model may provide \ -related information about a query but it won't match what the user is asking, this is invalid. -3. Answer is just some form of "I don\'t know" or "not enough information" without significant \ -additional useful information. Explaining why it does not know or cannot answer is invalid. -""" - if not CUSTOM_ANSWER_VALIDITY_CONDITIONS - else "\n".join( - [ - f"{indice+1}. {condition}" - for indice, condition in enumerate(CUSTOM_ANSWER_VALIDITY_CONDITIONS) - ] - ) -) - -ANSWER_FORMAT = ( - """ -1. True or False -2. True or False -3. True or False -""" - if not CUSTOM_ANSWER_VALIDITY_CONDITIONS - else "\n".join( - [ - f"{indice+1}. True or False" - for indice, _ in enumerate(CUSTOM_ANSWER_VALIDITY_CONDITIONS) - ] - ) -) - -ANSWER_VALIDITY_PROMPT = f""" -You are an assistant to identify invalid query/answer pairs coming from a large language model. -The query/answer pair is invalid if any of the following are True: -{ANSWER_VALIDITY_CONDITIONS} - -{QUESTION_PAT} {{user_query}} -{ANSWER_PAT} {{llm_answer}} - ------------------------- -You MUST answer in EXACTLY the following format: -``` -{ANSWER_FORMAT} -Final Answer: Valid or Invalid -``` - -Hint: Remember, if ANY of the conditions are True, it is Invalid. -""".strip() - - -# Use the following for easy viewing of prompts -if __name__ == "__main__": - print(ANSWER_VALIDITY_PROMPT) diff --git a/backend/danswer/prompts/chat_prompts.py b/backend/danswer/prompts/chat_prompts.py deleted file mode 100644 index a5fa973f37c..00000000000 --- a/backend/danswer/prompts/chat_prompts.py +++ /dev/null @@ -1,217 +0,0 @@ -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.prompts.constants import QUESTION_PAT - -REQUIRE_CITATION_STATEMENT = """ -Cite relevant statements INLINE using the format [1], [2], [3], etc to reference the document number, \ -DO NOT provide a reference section at the end and DO NOT provide any links following the citations. -""".rstrip() - -NO_CITATION_STATEMENT = """ -Do not provide any citations even if there are examples in the chat history. -""".rstrip() - -CITATION_REMINDER = """ -Remember to provide inline citations in the format [1], [2], [3], etc. -""" - -ADDITIONAL_INFO = "\n\nAdditional Information:\n\t- {datetime_info}." - - -CHAT_USER_PROMPT = f""" -Refer to the following context documents when responding to me.{{optional_ignore_statement}} -CONTEXT: -{GENERAL_SEP_PAT} -{{context_docs_str}} -{GENERAL_SEP_PAT} - -{{task_prompt}} - -{QUESTION_PAT.upper()} -{{user_query}} -""".strip() - - -CHAT_USER_CONTEXT_FREE_PROMPT = f""" -{{task_prompt}} - -{QUESTION_PAT.upper()} -{{user_query}} -""".strip() - - -# Design considerations for the below: -# - In case of uncertainty, favor yes search so place the "yes" sections near the start of the -# prompt and after the no section as well to deemphasize the no section -# - Conversation history can be a lot of tokens, make sure the bulk of the prompt is at the start -# or end so the middle history section is relatively less paid attention to than the main task -# - Works worse with just a simple yes/no, seems asking it to produce "search" helps a bit, can -# consider doing COT for this and keep it brief, but likely only small gains. -SKIP_SEARCH = "Skip Search" -YES_SEARCH = "Yes Search" - -AGGRESSIVE_SEARCH_TEMPLATE = f""" -Given the conversation history and a follow up query, determine if the system should call \ -an external search tool to better answer the latest user input. -Your default response is {YES_SEARCH}. - -Respond "{SKIP_SEARCH}" if either: -- There is sufficient information in chat history to FULLY and ACCURATELY answer the query AND \ -additional information or details would provide little or no value. -- The query is some form of request that does not require additional information to handle. - -Conversation History: -{GENERAL_SEP_PAT} -{{chat_history}} -{GENERAL_SEP_PAT} - -If you are at all unsure, respond with {YES_SEARCH}. -Respond with EXACTLY and ONLY "{YES_SEARCH}" or "{SKIP_SEARCH}" - -Follow Up Input: -{{final_query}} -""".strip() - - -# TODO, templatize this so users don't need to make code changes to use this -AGGRESSIVE_SEARCH_TEMPLATE_LLAMA2 = f""" -You are an expert of a critical system. Given the conversation history and a follow up query, \ -determine if the system should call an external search tool to better answer the latest user input. - -Your default response is {YES_SEARCH}. -If you are even slightly unsure, respond with {YES_SEARCH}. - -Respond "{SKIP_SEARCH}" if any of these are true: -- There is sufficient information in chat history to FULLY and ACCURATELY answer the query. -- The query is some form of request that does not require additional information to handle. -- You are absolutely sure about the question and there is no ambiguity in the answer or question. - -Conversation History: -{GENERAL_SEP_PAT} -{{chat_history}} -{GENERAL_SEP_PAT} - -Respond with EXACTLY and ONLY "{YES_SEARCH}" or "{SKIP_SEARCH}" - -Follow Up Input: -{{final_query}} -""".strip() - -REQUIRE_SEARCH_SINGLE_MSG = f""" -Given the conversation history and a follow up query, determine if the system should call \ -an external search tool to better answer the latest user input. - -Respond "{YES_SEARCH}" if: -- Specific details or additional knowledge could lead to a better answer. -- There are new or unknown terms, or there is uncertainty what the user is referring to. -- If reading a document cited or mentioned previously may be useful. - -Respond "{SKIP_SEARCH}" if: -- There is sufficient information in chat history to FULLY and ACCURATELY answer the query -and additional information or details would provide little or no value. -- The query is some task that does not require additional information to handle. - -{GENERAL_SEP_PAT} -Conversation History: -{{chat_history}} -{GENERAL_SEP_PAT} - -Even if the topic has been addressed, if more specific details could be useful, \ -respond with "{YES_SEARCH}". -If you are unsure, respond with "{YES_SEARCH}". - -Respond with EXACTLY and ONLY "{YES_SEARCH}" or "{SKIP_SEARCH}" - -Follow Up Input: -{{final_query}} -""".strip() - - -HISTORY_QUERY_REPHRASE = f""" -Given the following conversation and a follow up input, rephrase the follow up into a SHORT, \ -standalone query (which captures any relevant context from previous messages) for a vectorstore. -IMPORTANT: EDIT THE QUERY TO BE AS CONCISE AS POSSIBLE. Respond with a short, compressed phrase \ -with mainly keywords instead of a complete sentence. -If there is a clear change in topic, disregard the previous messages. -Strip out any information that is not relevant for the retrieval task. -If the follow up message is an error or code snippet, repeat the same input back EXACTLY. - -{GENERAL_SEP_PAT} -Chat History: -{{chat_history}} -{GENERAL_SEP_PAT} - -Follow Up Input: {{question}} -Standalone question (Respond with only the short combined query): -""".strip() - -INTERNET_SEARCH_QUERY_REPHRASE = f""" -Given the following conversation and a follow up input, rephrase the follow up into a SHORT, \ -standalone query suitable for an internet search engine. -IMPORTANT: If a specific query might limit results, keep it broad. \ -If a broad query might yield too many results, make it detailed. -If there is a clear change in topic, ensure the query reflects the new topic accurately. -Strip out any information that is not relevant for the internet search. - -{GENERAL_SEP_PAT} -Chat History: -{{chat_history}} -{GENERAL_SEP_PAT} - -Follow Up Input: {{question}} -Internet Search Query (Respond with a detailed and specific query): -""".strip() - - -# The below prompts are retired -NO_SEARCH = "No Search" -REQUIRE_SEARCH_SYSTEM_MSG = f""" -You are a large language model whose only job is to determine if the system should call an \ -external search tool to be able to answer the user's last message. - -Respond with "{NO_SEARCH}" if: -- there is sufficient information in chat history to fully answer the user query -- there is enough knowledge in the LLM to fully answer the user query -- the user query does not rely on any specific knowledge - -Respond with "{YES_SEARCH}" if: -- additional knowledge about entities, processes, problems, or anything else could lead to a better answer. -- there is some uncertainty what the user is referring to - -Respond with EXACTLY and ONLY "{YES_SEARCH}" or "{NO_SEARCH}" -""" - - -REQUIRE_SEARCH_HINT = f""" -Hint: respond with EXACTLY {YES_SEARCH} or {NO_SEARCH}" -""".strip() - - -QUERY_REPHRASE_SYSTEM_MSG = """ -Given a conversation (between Human and Assistant) and a final message from Human, \ -rewrite the last message to be a concise standalone query which captures required/relevant \ -context from previous messages. This question must be useful for a semantic (natural language) \ -search engine. -""".strip() - -QUERY_REPHRASE_USER_MSG = """ -Help me rewrite this final message into a standalone query that takes into consideration the \ -past messages of the conversation IF relevant. This query is used with a semantic search engine to \ -retrieve documents. You must ONLY return the rewritten query and NOTHING ELSE. \ -IMPORTANT, the search engine does not have access to the conversation history! - -Query: -{final_query} -""".strip() - - -CHAT_NAMING = f""" -Given the following conversation, provide a SHORT name for the conversation.{{language_hint_or_empty}} -IMPORTANT: TRY NOT TO USE MORE THAN 5 WORDS, MAKE IT AS CONCISE AS POSSIBLE. -Focus the name on the important keywords to convey the topic of the conversation. - -Chat History: -{{chat_history}} -{GENERAL_SEP_PAT} - -Based on the above, what is a short name to convey the topic of the conversation? -""".strip() diff --git a/backend/danswer/prompts/chat_tools.py b/backend/danswer/prompts/chat_tools.py deleted file mode 100644 index a33bf2037b0..00000000000 --- a/backend/danswer/prompts/chat_tools.py +++ /dev/null @@ -1,100 +0,0 @@ -# These prompts are to support tool calling. Currently not used in the main flow or via any configs -# The current generation of LLM is too unreliable for this task. -# Danswer retrieval call as a tool option -DANSWER_TOOL_NAME = "Current Search" -DANSWER_TOOL_DESCRIPTION = ( - "A search tool that can find information on any topic " - "including up to date and proprietary knowledge." -) - - -# Tool calling format inspired from LangChain -TOOL_TEMPLATE = """ -TOOLS ------- -You can use tools to look up information that may be helpful in answering the user's \ -original question. The available tools are: - -{tool_overviews} - -RESPONSE FORMAT INSTRUCTIONS ----------------------------- -When responding to me, please output a response in one of two formats: - -**Option 1:** -Use this if you want to use a tool. Markdown code snippet formatted in the following schema: - -```json -{{ - "action": string, \\ The action to take. {tool_names} - "action_input": string \\ The input to the action -}} -``` - -**Option #2:** -Use this if you want to respond directly to the user. Markdown code snippet formatted in the following schema: - -```json -{{ - "action": "Final Answer", - "action_input": string \\ You should put what you want to return to use here -}} -``` -""" - -# For the case where the user has not configured any tools to call, but still using the tool-flow -# expected format -TOOL_LESS_PROMPT = """ -Respond with a markdown code snippet in the following schema: - -```json -{{ - "action": "Final Answer", - "action_input": string \\ You should put what you want to return to use here -}} -``` -""" - - -# Second part of the prompt to include the user query -USER_INPUT = """ -USER'S INPUT --------------------- -Here is the user's input \ -(remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else): - -{user_input} -""" - - -# After the tool call, this is the following message to get a final answer -# Tools are not chained currently, the system must provide an answer after calling a tool -TOOL_FOLLOWUP = """ -TOOL RESPONSE: ---------------------- -{tool_output} - -USER'S INPUT --------------------- -Okay, so what is the response to my last comment? If using information obtained from the tools you must \ -mention it explicitly without mentioning the tool names - I have forgotten all TOOL RESPONSES! -If the tool response is not useful, ignore it completely. -{optional_reminder}{hint} -IMPORTANT! You MUST respond with a markdown code snippet of a json blob with a single action, and NOTHING else. -""" - - -# If no tools were used, but retrieval is enabled, then follow up with this message to get the final answer -TOOL_LESS_FOLLOWUP = """ -Refer to the following documents when responding to my final query. Ignore any documents that are not relevant. - -CONTEXT DOCUMENTS: ---------------------- -{context_str} - -FINAL QUERY: --------------------- -{user_query} - -{hint_text} -""" diff --git a/backend/danswer/prompts/constants.py b/backend/danswer/prompts/constants.py deleted file mode 100644 index d5734908537..00000000000 --- a/backend/danswer/prompts/constants.py +++ /dev/null @@ -1,15 +0,0 @@ -GENERAL_SEP_PAT = "--------------" # Same length as Langchain's separator -CODE_BLOCK_PAT = "```\n{}\n```" -TRIPLE_BACKTICK = "```" -QUESTION_PAT = "Query:" -FINAL_QUERY_PAT = "Final Query:" -THOUGHT_PAT = "Thought:" -ANSWER_PAT = "Answer:" -ANSWERABLE_PAT = "Answerable:" -FINAL_ANSWER_PAT = "Final Answer:" -QUOTE_PAT = "Quote:" -QUOTES_PAT_PLURAL = "Quotes:" -INVALID_PAT = "Invalid:" -SOURCES_KEY = "sources" - -DEFAULT_IGNORE_STATEMENT = " Ignore any context documents that are not relevant." diff --git a/backend/danswer/prompts/direct_qa_prompts.py b/backend/danswer/prompts/direct_qa_prompts.py deleted file mode 100644 index 16768963931..00000000000 --- a/backend/danswer/prompts/direct_qa_prompts.py +++ /dev/null @@ -1,184 +0,0 @@ -# The following prompts are used for the initial response before a chat history exists -# It is used also for the one shot direct QA flow -import json - -from danswer.prompts.constants import DEFAULT_IGNORE_STATEMENT -from danswer.prompts.constants import FINAL_QUERY_PAT -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.prompts.constants import QUESTION_PAT -from danswer.prompts.constants import THOUGHT_PAT - - -ONE_SHOT_SYSTEM_PROMPT = """ -You are a question answering system that is constantly learning and improving. -You can process and comprehend vast amounts of text and utilize this knowledge to provide \ -accurate and detailed answers to diverse queries. -""".strip() - -ONE_SHOT_TASK_PROMPT = """ -Answer the final query below taking into account the context above where relevant. \ -Ignore any provided context that is not relevant to the query. -""".strip() - - -WEAK_MODEL_SYSTEM_PROMPT = """ -Respond to the user query using the following reference document. -""".lstrip() - -WEAK_MODEL_TASK_PROMPT = """ -Answer the user query below based on the reference document above. -""" - - -REQUIRE_JSON = """ -You ALWAYS responds with ONLY a JSON containing an answer and quotes that support the answer. -""".strip() - - -JSON_HELPFUL_HINT = """ -Hint: Make the answer as DETAILED as possible and respond in JSON format! \ -Quotes MUST be EXACT substrings from provided documents! -""".strip() - -CONTEXT_BLOCK = f""" -REFERENCE DOCUMENTS: -{GENERAL_SEP_PAT} -{{context_docs_str}} -{GENERAL_SEP_PAT} -""" - -HISTORY_BLOCK = f""" -CONVERSATION HISTORY: -{GENERAL_SEP_PAT} -{{history_str}} -{GENERAL_SEP_PAT} -""" - - -# This has to be doubly escaped due to json containing { } which are also used for format strings -EMPTY_SAMPLE_JSON = { - "answer": "Place your final answer here. It should be as DETAILED and INFORMATIVE as possible.", - "quotes": [ - "each quote must be UNEDITED and EXACTLY as shown in the context documents!", - "HINT, quotes are not shown to the user!", - ], -} - - -# Default json prompt which can reference multiple docs and provide answer + quotes -# system_like_header is similar to system message, can be user provided or defaults to QA_HEADER -# context/history blocks are for context documents and conversation history, they can be blank -# task prompt is the task message of the prompt, can be blank, there is no default -JSON_PROMPT = f""" -{{system_prompt}} -{REQUIRE_JSON} -{{context_block}}{{history_block}}{{task_prompt}} - -SAMPLE RESPONSE: -``` -{{{json.dumps(EMPTY_SAMPLE_JSON)}}} -``` - -{FINAL_QUERY_PAT.upper()} -{{user_query}} - -{JSON_HELPFUL_HINT} -{{language_hint_or_none}} -""".strip() - - -# similar to the chat flow, but with the option of including a -# "conversation history" block -CITATIONS_PROMPT = f""" -Refer to the following context documents when responding to me.{DEFAULT_IGNORE_STATEMENT} -CONTEXT: -{GENERAL_SEP_PAT} -{{context_docs_str}} -{GENERAL_SEP_PAT} - -{{history_block}}{{task_prompt}} - -{QUESTION_PAT.upper()} -{{user_query}} -""" - -# with tool calling, the documents are in a separate "tool" message -# NOTE: need to add the extra line about "getting right to the point" since the -# tool calling models from OpenAI tend to be more verbose -CITATIONS_PROMPT_FOR_TOOL_CALLING = f""" -Refer to the provided context documents when responding to me.{DEFAULT_IGNORE_STATEMENT} \ -You should always get right to the point, and never use extraneous language. - -{{task_prompt}} - -{QUESTION_PAT.upper()} -{{user_query}} -""" - - -# For weak LLM which only takes one chunk and cannot output json -# Also not requiring quotes as it tends to not work -WEAK_LLM_PROMPT = f""" -{{system_prompt}} -{{context_block}} -{{task_prompt}} - -{QUESTION_PAT.upper()} -{{user_query}} -""".strip() - - -# This is only for visualization for the users to specify their own prompts -# The actual flow does not work like this -PARAMATERIZED_PROMPT = f""" -{{system_prompt}} - -CONTEXT: -{GENERAL_SEP_PAT} -{{context_docs_str}} -{GENERAL_SEP_PAT} - -{{task_prompt}} - -{QUESTION_PAT.upper()} {{user_query}} -RESPONSE: -""".strip() - -PARAMATERIZED_PROMPT_WITHOUT_CONTEXT = f""" -{{system_prompt}} - -{{task_prompt}} - -{QUESTION_PAT.upper()} {{user_query}} -RESPONSE: -""".strip() - - -# CURRENTLY DISABLED, CANNOT USE THIS ONE -# Default chain-of-thought style json prompt which uses multiple docs -# This one has a section for the LLM to output some non-answer "thoughts" -# COT (chain-of-thought) flow basically -COT_PROMPT = f""" -{ONE_SHOT_SYSTEM_PROMPT} - -CONTEXT: -{GENERAL_SEP_PAT} -{{context_docs_str}} -{GENERAL_SEP_PAT} - -You MUST respond in the following format: -``` -{THOUGHT_PAT} Use this section as a scratchpad to reason through the answer. - -{{{json.dumps(EMPTY_SAMPLE_JSON)}}} -``` - -{QUESTION_PAT.upper()} {{user_query}} -{JSON_HELPFUL_HINT} -{{language_hint_or_none}} -""".strip() - - -# User the following for easy viewing of prompts -if __name__ == "__main__": - print(JSON_PROMPT) # Default prompt used in the Danswer UI flow diff --git a/backend/danswer/prompts/filter_extration.py b/backend/danswer/prompts/filter_extration.py deleted file mode 100644 index 3c5e879ebe0..00000000000 --- a/backend/danswer/prompts/filter_extration.py +++ /dev/null @@ -1,66 +0,0 @@ -# The following prompts are used for extracting filters to apply along with the query in the -# document index. For example, a filter for dates or a filter by source type such as GitHub -# or Slack -from danswer.prompts.constants import SOURCES_KEY - - -# Smaller followup prompts in time_filter.py -TIME_FILTER_PROMPT = """ -You are a tool to identify time filters to apply to a user query for a downstream search \ -application. The downstream application is able to use a recency bias or apply a hard cutoff to \ -remove all documents before the cutoff. Identify the correct filters to apply for the user query. - -The current day and time is {current_day_time_str}. - -Always answer with ONLY a json which contains the keys "filter_type", "filter_value", \ -"value_multiple" and "date". - -The valid values for "filter_type" are "hard cutoff", "favors recent", or "not time sensitive". -The valid values for "filter_value" are "day", "week", "month", "quarter", "half", or "year". -The valid values for "value_multiple" is any number. -The valid values for "date" is a date in format MM/DD/YYYY, ALWAYS follow this format. -""".strip() - - -# Smaller followup prompts in source_filter.py -# Known issue: LLMs like GPT-3.5 try to generalize. If the valid sources contains "web" but not -# "confluence" and the user asks for confluence related things, the LLM will select "web" since -# confluence is accessed as a website. This cannot be fixed without also reducing the capability -# to match things like repository->github, website->web, etc. -# This is generally not a big issue though as if the company has confluence, hopefully they add -# a connector for it or the user is aware that confluence has not been added. -SOURCE_FILTER_PROMPT = f""" -Given a user query, extract relevant source filters for use in a downstream search tool. -Respond with a json containing the source filters or null if no specific sources are referenced. -ONLY extract sources when the user is explicitly limiting the scope of where information is \ -coming from. -The user may provide invalid source filters, ignore those. - -The valid sources are: -{{valid_sources}} -{{web_source_warning}} -{{file_source_warning}} - - -ALWAYS answer with ONLY a json with the key "{SOURCES_KEY}". \ -The value for "{SOURCES_KEY}" must be null or a list of valid sources. - -Sample Response: -{{sample_response}} -""".strip() - -WEB_SOURCE_WARNING = """ -Note: The "web" source only applies to when the user specifies "website" in the query. \ -It does not apply to tools such as Confluence, GitHub, etc. that have a website. -""".strip() - -FILE_SOURCE_WARNING = """ -Note: The "file" source only applies to when the user refers to uploaded files in the query. -""".strip() - - -# Use the following for easy viewing of prompts -if __name__ == "__main__": - print(TIME_FILTER_PROMPT) - print("------------------") - print(SOURCE_FILTER_PROMPT) diff --git a/backend/danswer/prompts/llm_chunk_filter.py b/backend/danswer/prompts/llm_chunk_filter.py deleted file mode 100644 index 2783ac11e22..00000000000 --- a/backend/danswer/prompts/llm_chunk_filter.py +++ /dev/null @@ -1,33 +0,0 @@ -# The following prompts are used to pass each chunk to the LLM (the cheap/fast one) -# to determine if the chunk is useful towards the user query. This is used as part -# of the reranking flow - -USEFUL_PAT = "Yes useful" -NONUSEFUL_PAT = "Not useful" -SECTION_FILTER_PROMPT = f""" -Determine if the following section is USEFUL for answering the user query. -It is NOT enough for the section to be related to the query, \ -it must contain information that is USEFUL for answering the query. -If the section contains ANY useful information, that is good enough, \ -it does not need to fully answer the every part of the user query. - - -Title: {{title}} -{{optional_metadata}} -Reference Section: -``` -{{chunk_text}} -``` - -User Query: -``` -{{user_query}} -``` - -Respond with EXACTLY AND ONLY: "{USEFUL_PAT}" or "{NONUSEFUL_PAT}" -""".strip() - - -# Use the following for easy viewing of prompts -if __name__ == "__main__": - print(SECTION_FILTER_PROMPT) diff --git a/backend/danswer/prompts/miscellaneous_prompts.py b/backend/danswer/prompts/miscellaneous_prompts.py deleted file mode 100644 index 81ae5164329..00000000000 --- a/backend/danswer/prompts/miscellaneous_prompts.py +++ /dev/null @@ -1,29 +0,0 @@ -# Prompts that aren't part of a particular configurable feature - -LANGUAGE_REPHRASE_PROMPT = """ -Translate query to {target_language}. -If the query at the end is already in {target_language}, simply repeat the ORIGINAL query back to me, EXACTLY as is with no edits. -If the query below is not in {target_language}, translate it into {target_language}. - -Query: -{query} -""".strip() - -SLACK_LANGUAGE_REPHRASE_PROMPT = """ -As an AI assistant employed by an organization, \ -your role is to transform user messages into concise \ -inquiries suitable for a Large Language Model (LLM) that \ -retrieves pertinent materials within a Retrieval-Augmented \ -Generation (RAG) framework. Ensure to reply in the identical \ -language as the original request. When faced with multiple \ -questions within a single query, distill them into a singular, \ -unified question, disregarding any direct mentions. - -Query: -{query} -""".strip() - - -# Use the following for easy viewing of prompts -if __name__ == "__main__": - print(LANGUAGE_REPHRASE_PROMPT) diff --git a/backend/danswer/prompts/prompt_utils.py b/backend/danswer/prompts/prompt_utils.py deleted file mode 100644 index cd59e97061f..00000000000 --- a/backend/danswer/prompts/prompt_utils.py +++ /dev/null @@ -1,190 +0,0 @@ -from collections.abc import Sequence -from datetime import datetime -from typing import cast - -from langchain_core.messages import BaseMessage - -from danswer.chat.models import LlmDoc -from danswer.configs.chat_configs import LANGUAGE_HINT -from danswer.configs.constants import DocumentSource -from danswer.db.models import Prompt -from danswer.llm.answering.models import PromptConfig -from danswer.prompts.chat_prompts import ADDITIONAL_INFO -from danswer.prompts.chat_prompts import CITATION_REMINDER -from danswer.prompts.constants import CODE_BLOCK_PAT -from danswer.search.models import InferenceChunk - - -MOST_BASIC_PROMPT = "You are a helpful AI assistant." -DANSWER_DATETIME_REPLACEMENT = "DANSWER_DATETIME_REPLACEMENT" -BASIC_TIME_STR = "The current date is {datetime_info}." - - -def get_current_llm_day_time( - include_day_of_week: bool = True, full_sentence: bool = True -) -> str: - current_datetime = datetime.now() - # Format looks like: "October 16, 2023 14:30" - formatted_datetime = current_datetime.strftime("%B %d, %Y %H:%M") - day_of_week = current_datetime.strftime("%A") - if full_sentence: - return f"The current day and time is {day_of_week} {formatted_datetime}" - if include_day_of_week: - return f"{day_of_week} {formatted_datetime}" - return f"{formatted_datetime}" - - -def add_date_time_to_prompt(prompt_str: str) -> str: - if DANSWER_DATETIME_REPLACEMENT in prompt_str: - return prompt_str.replace( - DANSWER_DATETIME_REPLACEMENT, - get_current_llm_day_time(full_sentence=False, include_day_of_week=True), - ) - - if prompt_str: - return prompt_str + ADDITIONAL_INFO.format( - datetime_info=get_current_llm_day_time() - ) - else: - return ( - MOST_BASIC_PROMPT - + " " - + BASIC_TIME_STR.format(datetime_info=get_current_llm_day_time()) - ) - - -def build_task_prompt_reminders( - prompt: Prompt | PromptConfig, - use_language_hint: bool, - citation_str: str = CITATION_REMINDER, - language_hint_str: str = LANGUAGE_HINT, -) -> str: - base_task = prompt.task_prompt - citation_or_nothing = citation_str if prompt.include_citations else "" - language_hint_or_nothing = language_hint_str.lstrip() if use_language_hint else "" - return base_task + citation_or_nothing + language_hint_or_nothing - - -# Maps connector enum string to a more natural language representation for the LLM -# If not on the list, uses the original but slightly cleaned up, see below -CONNECTOR_NAME_MAP = { - "web": "Website", - "requesttracker": "Request Tracker", - "github": "GitHub", - "file": "File Upload", -} - - -def clean_up_source(source_str: str) -> str: - if source_str in CONNECTOR_NAME_MAP: - return CONNECTOR_NAME_MAP[source_str] - return source_str.replace("_", " ").title() - - -def build_doc_context_str( - semantic_identifier: str, - source_type: DocumentSource, - content: str, - metadata_dict: dict[str, str | list[str]], - updated_at: datetime | None, - ind: int, - include_metadata: bool = True, -) -> str: - context_str = "" - if include_metadata: - context_str += f"DOCUMENT {ind}: {semantic_identifier}\n" - context_str += f"Source: {clean_up_source(source_type)}\n" - - for k, v in metadata_dict.items(): - if isinstance(v, list): - v_str = ", ".join(v) - context_str += f"{k.capitalize()}: {v_str}\n" - else: - context_str += f"{k.capitalize()}: {v}\n" - - if updated_at: - update_str = updated_at.strftime("%B %d, %Y %H:%M") - context_str += f"Updated: {update_str}\n" - context_str += f"{CODE_BLOCK_PAT.format(content.strip())}\n\n\n" - return context_str - - -def build_complete_context_str( - context_docs: Sequence[LlmDoc | InferenceChunk], - include_metadata: bool = True, -) -> str: - context_str = "" - for ind, doc in enumerate(context_docs, start=1): - context_str += build_doc_context_str( - semantic_identifier=doc.semantic_identifier, - source_type=doc.source_type, - content=doc.content, - metadata_dict=doc.metadata, - updated_at=doc.updated_at, - ind=ind, - include_metadata=include_metadata, - ) - - return context_str.strip() - - -_PER_MESSAGE_TOKEN_BUFFER = 7 - - -def find_last_index(lst: list[int], max_prompt_tokens: int) -> int: - """From the back, find the index of the last element to include - before the list exceeds the maximum""" - running_sum = 0 - - last_ind = 0 - for i in range(len(lst) - 1, -1, -1): - running_sum += lst[i] + _PER_MESSAGE_TOKEN_BUFFER - if running_sum > max_prompt_tokens: - last_ind = i + 1 - break - if last_ind >= len(lst): - raise ValueError("Last message alone is too large!") - return last_ind - - -def drop_messages_history_overflow( - messages_with_token_cnts: list[tuple[BaseMessage, int]], - max_allowed_tokens: int, -) -> list[BaseMessage]: - """As message history grows, messages need to be dropped starting from the furthest in the past. - The System message should be kept if at all possible and the latest user input which is inserted in the - prompt template must be included""" - - final_messages: list[BaseMessage] = [] - messages, token_counts = cast( - tuple[list[BaseMessage], list[int]], zip(*messages_with_token_cnts) - ) - system_msg = ( - final_messages[0] - if final_messages and final_messages[0].type == "system" - else None - ) - - history_msgs = messages[:-1] - final_msg = messages[-1] - if final_msg.type != "human": - if final_msg.type != "tool": - raise ValueError("Last message must be user input OR a tool result") - else: - final_msgs = messages[-3:] - history_msgs = messages[:-3] - else: - final_msgs = [final_msg] - - # Start dropping from the history if necessary - ind_prev_msg_start = find_last_index( - token_counts, max_prompt_tokens=max_allowed_tokens - ) - - if system_msg and ind_prev_msg_start <= len(history_msgs): - final_messages.append(system_msg) - - final_messages.extend(history_msgs[ind_prev_msg_start:]) - final_messages.extend(final_msgs) - - return final_messages diff --git a/backend/danswer/prompts/query_validation.py b/backend/danswer/prompts/query_validation.py deleted file mode 100644 index 68163229925..00000000000 --- a/backend/danswer/prompts/query_validation.py +++ /dev/null @@ -1,58 +0,0 @@ -# The following prompts are used for verifying if the user's query can be answered by the current -# system. Many new users do not understand the design/capabilities of the system and will ask -# questions that are unanswerable such as aggregations or user specific questions that the system -# cannot handle, this is used to identify those cases -from danswer.prompts.constants import ANSWERABLE_PAT -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.prompts.constants import QUESTION_PAT -from danswer.prompts.constants import THOUGHT_PAT - - -ANSWERABLE_PROMPT = f""" -You are a helper tool to determine if a query is answerable using retrieval augmented generation. -The main system will try to answer the user query based on ONLY the top 5 most relevant \ -documents found from search. -Sources contain both up to date and proprietary information for the specific team. -For named or unknown entities, assume the search will find relevant and consistent knowledge \ -about the entity. -The system is not tuned for writing code. -The system is not tuned for interfacing with structured data via query languages like SQL. -If the question might not require code or query language, then assume it can be answered without \ -code or query language. -Determine if that system should attempt to answer. -"ANSWERABLE" must be exactly "True" or "False" - -{GENERAL_SEP_PAT} - -{QUESTION_PAT.upper()} What is this Slack channel about? -``` -{THOUGHT_PAT.upper()} First the system must determine which Slack channel is being referred to. \ -By fetching 5 documents related to Slack channel contents, it is not possible to determine which \ -Slack channel the user is referring to. -{ANSWERABLE_PAT.upper()} False -``` - -{QUESTION_PAT.upper()} Danswer is unreachable. -``` -{THOUGHT_PAT.upper()} The system searches documents related to Danswer being unreachable. \ -Assuming the documents from search contains situations where Danswer is not reachable and \ -contains a fix, the query may be answerable. -{ANSWERABLE_PAT.upper()} True -``` - -{QUESTION_PAT.upper()} How many customers do we have -``` -{THOUGHT_PAT.upper()} Assuming the retrieved documents contain up to date customer acquisition \ -information including a list of customers, the query can be answered. It is important to note \ -that if the information only exists in a SQL database, the system is unable to execute SQL and \ -won't find an answer. -{ANSWERABLE_PAT.upper()} True -``` - -{QUESTION_PAT.upper()} {{user_query}} -""".strip() - - -# Use the following for easy viewing of prompts -if __name__ == "__main__": - print(ANSWERABLE_PROMPT) diff --git a/backend/danswer/prompts/token_counts.py b/backend/danswer/prompts/token_counts.py deleted file mode 100644 index c5bb7b2881e..00000000000 --- a/backend/danswer/prompts/token_counts.py +++ /dev/null @@ -1,29 +0,0 @@ -from danswer.configs.chat_configs import LANGUAGE_HINT -from danswer.llm.utils import check_number_of_tokens -from danswer.prompts.chat_prompts import ADDITIONAL_INFO -from danswer.prompts.chat_prompts import CHAT_USER_PROMPT -from danswer.prompts.chat_prompts import CITATION_REMINDER -from danswer.prompts.chat_prompts import REQUIRE_CITATION_STATEMENT -from danswer.prompts.constants import DEFAULT_IGNORE_STATEMENT -from danswer.prompts.prompt_utils import get_current_llm_day_time - -# tokens outside of the actual persona's "user_prompt" that make up the end user message -CHAT_USER_PROMPT_WITH_CONTEXT_OVERHEAD_TOKEN_CNT = check_number_of_tokens( - CHAT_USER_PROMPT.format( - context_docs_str="", - task_prompt="", - user_query="", - optional_ignore_statement=DEFAULT_IGNORE_STATEMENT, - ) -) - -CITATION_STATEMENT_TOKEN_CNT = check_number_of_tokens(REQUIRE_CITATION_STATEMENT) - -CITATION_REMINDER_TOKEN_CNT = check_number_of_tokens(CITATION_REMINDER) - -LANGUAGE_HINT_TOKEN_CNT = check_number_of_tokens(LANGUAGE_HINT) - -# If the date/time is inserted directly as a replacement in the prompt, this is a slight over count -ADDITIONAL_INFO_TOKEN_CNT = check_number_of_tokens( - ADDITIONAL_INFO.format(datetime_info=get_current_llm_day_time()) -) diff --git a/backend/danswer/search/__init__.py b/backend/danswer/search/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/search/enums.py b/backend/danswer/search/enums.py deleted file mode 100644 index 28f81704789..00000000000 --- a/backend/danswer/search/enums.py +++ /dev/null @@ -1,36 +0,0 @@ -"""NOTE: this needs to be separate from models.py because of circular imports. -Both search/models.py and db/models.py import enums from this file AND -search/models.py imports from db/models.py.""" -from enum import Enum - - -class RecencyBiasSetting(str, Enum): - FAVOR_RECENT = "favor_recent" # 2x decay rate - BASE_DECAY = "base_decay" - NO_DECAY = "no_decay" - # Determine based on query if to use base_decay or favor_recent - AUTO = "auto" - - -class OptionalSearchSetting(str, Enum): - ALWAYS = "always" - NEVER = "never" - # Determine whether to run search based on history and latest query - AUTO = "auto" - - -class SearchType(str, Enum): - KEYWORD = "keyword" - SEMANTIC = "semantic" - - -class LLMEvaluationType(str, Enum): - AGENTIC = "agentic" # applies agentic evaluation - BASIC = "basic" # applies boolean evaluation - SKIP = "skip" # skips evaluation - UNSPECIFIED = "unspecified" # reverts to default - - -class QueryFlow(str, Enum): - SEARCH = "search" - QUESTION_ANSWER = "question-answer" diff --git a/backend/danswer/search/models.py b/backend/danswer/search/models.py deleted file mode 100644 index 15387e6c63e..00000000000 --- a/backend/danswer/search/models.py +++ /dev/null @@ -1,365 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import field_validator - -from danswer.configs.chat_configs import NUM_RETURNED_HITS -from danswer.configs.constants import DocumentSource -from danswer.db.models import Persona -from danswer.db.models import SearchSettings -from danswer.indexing.models import BaseChunk -from danswer.indexing.models import IndexingSetting -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import OptionalSearchSetting -from danswer.search.enums import SearchType -from shared_configs.enums import RerankerProvider - - -MAX_METRICS_CONTENT = ( - 200 # Just need enough characters to identify where in the doc the chunk is -) - - -class RerankingDetails(BaseModel): - # If model is None (or num_rerank is 0), then reranking is turned off - rerank_model_name: str | None - rerank_provider_type: RerankerProvider | None - rerank_api_key: str | None = None - - num_rerank: int - - # For faster flows where the results should start immediately - # this more time intensive step can be skipped - disable_rerank_for_streaming: bool = False - - @classmethod - def from_db_model(cls, search_settings: SearchSettings) -> "RerankingDetails": - return cls( - rerank_model_name=search_settings.rerank_model_name, - rerank_provider_type=search_settings.rerank_provider_type, - rerank_api_key=search_settings.rerank_api_key, - num_rerank=search_settings.num_rerank, - ) - - -class InferenceSettings(RerankingDetails): - # Empty for no additional expansion - multilingual_expansion: list[str] - - -class SearchSettingsCreationRequest(InferenceSettings, IndexingSetting): - @classmethod - def from_db_model( - cls, search_settings: SearchSettings - ) -> "SearchSettingsCreationRequest": - inference_settings = InferenceSettings.from_db_model(search_settings) - indexing_setting = IndexingSetting.from_db_model(search_settings) - - return cls(**inference_settings.dict(), **indexing_setting.dict()) - - -class SavedSearchSettings(InferenceSettings, IndexingSetting): - @classmethod - def from_db_model(cls, search_settings: SearchSettings) -> "SavedSearchSettings": - return cls( - # Indexing Setting - model_name=search_settings.model_name, - model_dim=search_settings.model_dim, - normalize=search_settings.normalize, - query_prefix=search_settings.query_prefix, - passage_prefix=search_settings.passage_prefix, - provider_type=search_settings.provider_type, - index_name=search_settings.index_name, - multipass_indexing=search_settings.multipass_indexing, - # Reranking Details - rerank_model_name=search_settings.rerank_model_name, - rerank_provider_type=search_settings.rerank_provider_type, - rerank_api_key=search_settings.rerank_api_key, - num_rerank=search_settings.num_rerank, - # Multilingual Expansion - multilingual_expansion=search_settings.multilingual_expansion, - ) - - -class Tag(BaseModel): - tag_key: str - tag_value: str - - -class BaseFilters(BaseModel): - source_type: list[DocumentSource] | None = None - document_set: list[str] | None = None - time_cutoff: datetime | None = None - tags: list[Tag] | None = None - - -class IndexFilters(BaseFilters): - access_control_list: list[str] | None - - -class ChunkMetric(BaseModel): - document_id: str - chunk_content_start: str - first_link: str | None - score: float - - -class ChunkContext(BaseModel): - # If not specified (None), picked up from Persona settings if there is space - # if specified (even if 0), it always uses the specified number of chunks above and below - chunks_above: int | None = None - chunks_below: int | None = None - full_doc: bool = False - - @field_validator("chunks_above", "chunks_below") - @classmethod - def check_non_negative(cls, value: int, field: Any) -> int: - if value is not None and value < 0: - raise ValueError(f"{field.name} must be non-negative") - return value - - -class SearchRequest(ChunkContext): - query: str - - search_type: SearchType = SearchType.SEMANTIC - - human_selected_filters: BaseFilters | None = None - enable_auto_detect_filters: bool | None = None - persona: Persona | None = None - - # if None, no offset / limit - offset: int | None = None - limit: int | None = None - - multilingual_expansion: list[str] | None = None - recency_bias_multiplier: float = 1.0 - hybrid_alpha: float | None = None - rerank_settings: RerankingDetails | None = None - evaluation_type: LLMEvaluationType = LLMEvaluationType.UNSPECIFIED - model_config = ConfigDict(arbitrary_types_allowed=True) - - -class SearchQuery(ChunkContext): - "Processed Request that is directly passed to the SearchPipeline" - query: str - processed_keywords: list[str] - search_type: SearchType - evaluation_type: LLMEvaluationType - filters: IndexFilters - - # by this point, the chunks_above and chunks_below must be set - chunks_above: int - chunks_below: int - - rerank_settings: RerankingDetails | None - hybrid_alpha: float - recency_bias_multiplier: float - - # Only used if LLM evaluation type is not skip, None to use default settings - max_llm_filter_sections: int - - num_hits: int = NUM_RETURNED_HITS - offset: int = 0 - model_config = ConfigDict(frozen=True) - - -class RetrievalDetails(ChunkContext): - # Use LLM to determine whether to do a retrieval or only rely on existing history - # If the Persona is configured to not run search (0 chunks), this is bypassed - # If no Prompt is configured, the only search results are shown, this is bypassed - run_search: OptionalSearchSetting = OptionalSearchSetting.ALWAYS - # Is this a real-time/streaming call or a question where Danswer can take more time? - # Used to determine reranking flow - real_time: bool = True - # The following have defaults in the Persona settings which can be overridden via - # the query, if None, then use Persona settings - filters: BaseFilters | None = None - enable_auto_detect_filters: bool | None = None - # if None, no offset / limit - offset: int | None = None - limit: int | None = None - - # If this is set, only the highest matching chunk (or merged chunks) is returned - dedupe_docs: bool = False - - -class InferenceChunk(BaseChunk): - document_id: str - source_type: DocumentSource - semantic_identifier: str - title: str | None # Separate from Semantic Identifier though often same - boost: int - recency_bias: float - score: float | None - hidden: bool - is_relevant: bool | None = None - relevance_explanation: str | None = None - metadata: dict[str, str | list[str]] - # Matched sections in the chunk. Uses Vespa syntax e.g. TEXT - # to specify that a set of words should be highlighted. For example: - # ["the answer is 42", "he couldn't find an answer"] - match_highlights: list[str] - - # when the doc was last updated - updated_at: datetime | None - primary_owners: list[str] | None = None - secondary_owners: list[str] | None = None - large_chunk_reference_ids: list[int] = Field(default_factory=list) - - @property - def unique_id(self) -> str: - return f"{self.document_id}__{self.chunk_id}" - - def __repr__(self) -> str: - blurb_words = self.blurb.split() - short_blurb = "" - for word in blurb_words: - if not short_blurb: - short_blurb = word - continue - if len(short_blurb) > 25: - break - short_blurb += " " + word - return f"Inference Chunk: {self.document_id} - {short_blurb}..." - - def __eq__(self, other: Any) -> bool: - if not isinstance(other, InferenceChunk): - return False - return (self.document_id, self.chunk_id) == (other.document_id, other.chunk_id) - - def __hash__(self) -> int: - return hash((self.document_id, self.chunk_id)) - - def __lt__(self, other: Any) -> bool: - if not isinstance(other, InferenceChunk): - return NotImplemented - if self.score is None: - if other.score is None: - return self.chunk_id > other.chunk_id - return True - if other.score is None: - return False - if self.score == other.score: - return self.chunk_id > other.chunk_id - return self.score < other.score - - def __gt__(self, other: Any) -> bool: - if not isinstance(other, InferenceChunk): - return NotImplemented - if self.score is None: - return False - if other.score is None: - return True - if self.score == other.score: - return self.chunk_id < other.chunk_id - return self.score > other.score - - -class InferenceChunkUncleaned(InferenceChunk): - metadata_suffix: str | None - - def to_inference_chunk(self) -> InferenceChunk: - # Create a dict of all fields except 'metadata_suffix' - # Assumes the cleaning has already been applied and just needs to translate to the right type - inference_chunk_data = { - k: v - for k, v in self.model_dump().items() - if k - not in ["metadata_suffix"] # May be other fields to throw out in the future - } - return InferenceChunk(**inference_chunk_data) - - -class InferenceSection(BaseModel): - """Section list of chunks with a combined content. A section could be a single chunk, several - chunks from the same document or the entire document.""" - - center_chunk: InferenceChunk - chunks: list[InferenceChunk] - combined_content: str - - -class SearchDoc(BaseModel): - document_id: str - chunk_ind: int - semantic_identifier: str - link: str | None = None - blurb: str - source_type: DocumentSource - boost: int - # Whether the document is hidden when doing a standard search - # since a standard search will never find a hidden doc, this can only ever - # be `True` when doing an admin search - hidden: bool - metadata: dict[str, str | list[str]] - score: float | None = None - is_relevant: bool | None = None - relevance_explanation: str | None = None - # Matched sections in the doc. Uses Vespa syntax e.g. TEXT - # to specify that a set of words should be highlighted. For example: - # ["the answer is 42", "the answer is 42""] - match_highlights: list[str] - # when the doc was last updated - updated_at: datetime | None = None - primary_owners: list[str] | None = None - secondary_owners: list[str] | None = None - is_internet: bool = False - - def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore - initial_dict = super().model_dump(*args, **kwargs) # type: ignore - initial_dict["updated_at"] = ( - self.updated_at.isoformat() if self.updated_at else None - ) - return initial_dict - - -class SavedSearchDoc(SearchDoc): - db_doc_id: int - score: float = 0.0 - - @classmethod - def from_search_doc( - cls, search_doc: SearchDoc, db_doc_id: int = 0 - ) -> "SavedSearchDoc": - """IMPORTANT: careful using this and not providing a db_doc_id If db_doc_id is not - provided, it won't be able to actually fetch the saved doc and info later on. So only skip - providing this if the SavedSearchDoc will not be used in the future""" - search_doc_data = search_doc.model_dump() - search_doc_data["score"] = search_doc_data.get("score") or 0.0 - return cls(**search_doc_data, db_doc_id=db_doc_id) - - def __lt__(self, other: Any) -> bool: - if not isinstance(other, SavedSearchDoc): - return NotImplemented - return self.score < other.score - - -class SavedSearchDocWithContent(SavedSearchDoc): - """Used for endpoints that need to return the actual contents of the retrieved - section in addition to the match_highlights.""" - - content: str - - -class RetrievalDocs(BaseModel): - top_documents: list[SavedSearchDoc] - - -class SearchResponse(RetrievalDocs): - llm_indices: list[int] - - -class RetrievalMetricsContainer(BaseModel): - search_type: SearchType - metrics: list[ChunkMetric] # This contains the scores for retrieval as well - - -class RerankMetricsContainer(BaseModel): - """The score held by this is the un-boosted, averaged score of the ensemble cross-encoders""" - - metrics: list[ChunkMetric] - raw_similarity_scores: list[float] diff --git a/backend/danswer/search/pipeline.py b/backend/danswer/search/pipeline.py deleted file mode 100644 index ad3e19e149d..00000000000 --- a/backend/danswer/search/pipeline.py +++ /dev/null @@ -1,394 +0,0 @@ -from collections import defaultdict -from collections.abc import Callable -from collections.abc import Iterator -from typing import cast - -from sqlalchemy.orm import Session - -from danswer.chat.models import SectionRelevancePiece -from danswer.configs.chat_configs import DISABLE_LLM_DOC_RELEVANCE -from danswer.db.models import User -from danswer.db.search_settings import get_current_search_settings -from danswer.document_index.factory import get_default_document_index -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.prune_and_merge import _merge_sections -from danswer.llm.answering.prune_and_merge import ChunkRange -from danswer.llm.answering.prune_and_merge import merge_chunk_intervals -from danswer.llm.interfaces import LLM -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import QueryFlow -from danswer.search.enums import SearchType -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceSection -from danswer.search.models import RerankMetricsContainer -from danswer.search.models import RetrievalMetricsContainer -from danswer.search.models import SearchQuery -from danswer.search.models import SearchRequest -from danswer.search.postprocessing.postprocessing import cleanup_chunks -from danswer.search.postprocessing.postprocessing import search_postprocessing -from danswer.search.preprocessing.preprocessing import retrieval_preprocessing -from danswer.search.retrieval.search_runner import retrieve_chunks -from danswer.search.utils import inference_section_from_chunks -from danswer.search.utils import relevant_sections_to_indices -from danswer.secondary_llm_flows.agentic_evaluation import evaluate_inference_section -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import FunctionCall -from danswer.utils.threadpool_concurrency import run_functions_in_parallel -from danswer.utils.timing import log_function_time - -logger = setup_logger() - - -class SearchPipeline: - def __init__( - self, - search_request: SearchRequest, - user: User | None, - llm: LLM, - fast_llm: LLM, - db_session: Session, - bypass_acl: bool = False, # NOTE: VERY DANGEROUS, USE WITH CAUTION - retrieval_metrics_callback: ( - Callable[[RetrievalMetricsContainer], None] | None - ) = None, - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, - prompt_config: PromptConfig | None = None, - ): - self.search_request = search_request - self.user = user - self.llm = llm - self.fast_llm = fast_llm - self.db_session = db_session - self.bypass_acl = bypass_acl - self.retrieval_metrics_callback = retrieval_metrics_callback - self.rerank_metrics_callback = rerank_metrics_callback - - self.search_settings = get_current_search_settings(db_session) - self.document_index = get_default_document_index( - primary_index_name=self.search_settings.index_name, - secondary_index_name=None, - ) - self.prompt_config: PromptConfig | None = prompt_config - - # Preprocessing steps generate this - self._search_query: SearchQuery | None = None - self._predicted_search_type: SearchType | None = None - - # Initial document index retrieval chunks - self._retrieved_chunks: list[InferenceChunk] | None = None - # Another call made to the document index to get surrounding sections - self._retrieved_sections: list[InferenceSection] | None = None - # Reranking and LLM section selection can be run together - # If only LLM selection is on, the reranked chunks are yielded immediatly - self._reranked_sections: list[InferenceSection] | None = None - self._final_context_sections: list[InferenceSection] | None = None - - self._section_relevance: list[SectionRelevancePiece] | None = None - - # Generates reranked chunks and LLM selections - self._postprocessing_generator: ( - Iterator[list[InferenceSection] | list[SectionRelevancePiece]] | None - ) = None - - # No longer computed but keeping around in case it's reintroduced later - self._predicted_flow: QueryFlow | None = QueryFlow.QUESTION_ANSWER - - """Pre-processing""" - - def _run_preprocessing(self) -> None: - final_search_query = retrieval_preprocessing( - search_request=self.search_request, - user=self.user, - llm=self.llm, - db_session=self.db_session, - bypass_acl=self.bypass_acl, - ) - self._search_query = final_search_query - self._predicted_search_type = final_search_query.search_type - - @property - def search_query(self) -> SearchQuery: - if self._search_query is not None: - return self._search_query - - self._run_preprocessing() - - return cast(SearchQuery, self._search_query) - - @property - def predicted_search_type(self) -> SearchType: - if self._predicted_search_type is not None: - return self._predicted_search_type - - self._run_preprocessing() - return cast(SearchType, self._predicted_search_type) - - @property - def predicted_flow(self) -> QueryFlow: - if self._predicted_flow is not None: - return self._predicted_flow - - self._run_preprocessing() - return cast(QueryFlow, self._predicted_flow) - - """Retrieval and Postprocessing""" - - def _get_chunks(self) -> list[InferenceChunk]: - if self._retrieved_chunks is not None: - return self._retrieved_chunks - - # These chunks do not include large chunks and have been deduped - self._retrieved_chunks = retrieve_chunks( - query=self.search_query, - document_index=self.document_index, - db_session=self.db_session, - retrieval_metrics_callback=self.retrieval_metrics_callback, - ) - - return cast(list[InferenceChunk], self._retrieved_chunks) - - @log_function_time(print_only=True) - def _get_sections(self) -> list[InferenceSection]: - """Returns an expanded section from each of the chunks. - If whole docs (instead of above/below context) is specified then it will give back all of the whole docs - that have a corresponding chunk. - - This step should be fast for any document index implementation. - """ - if self._retrieved_sections is not None: - return self._retrieved_sections - - # These chunks are ordered, deduped, and contain no large chunks - retrieved_chunks = self._get_chunks() - - above = self.search_query.chunks_above - below = self.search_query.chunks_below - - expanded_inference_sections = [] - inference_chunks: list[InferenceChunk] = [] - chunk_requests: list[VespaChunkRequest] = [] - - # Full doc setting takes priority - if self.search_query.full_doc: - seen_document_ids = set() - - # This preserves the ordering since the chunks are retrieved in score order - for chunk in retrieved_chunks: - if chunk.document_id not in seen_document_ids: - seen_document_ids.add(chunk.document_id) - chunk_requests.append( - VespaChunkRequest( - document_id=chunk.document_id, - ) - ) - - inference_chunks.extend( - cleanup_chunks( - self.document_index.id_based_retrieval( - chunk_requests=chunk_requests, - filters=IndexFilters(access_control_list=None), - ) - ) - ) - - # Create a dictionary to group chunks by document_id - grouped_inference_chunks: dict[str, list[InferenceChunk]] = {} - for chunk in inference_chunks: - if chunk.document_id not in grouped_inference_chunks: - grouped_inference_chunks[chunk.document_id] = [] - grouped_inference_chunks[chunk.document_id].append(chunk) - - for chunk_group in grouped_inference_chunks.values(): - inference_section = inference_section_from_chunks( - center_chunk=chunk_group[0], - chunks=chunk_group, - ) - - if inference_section is not None: - expanded_inference_sections.append(inference_section) - else: - logger.warning("Skipped creation of section, no chunks found") - - self._retrieved_sections = expanded_inference_sections - return expanded_inference_sections - - # General flow: - # - Combine chunks into lists by document_id - # - For each document, run merge-intervals to get combined ranges - # - This allows for less queries to the document index - # - Fetch all of the new chunks with contents for the combined ranges - # - Reiterate the chunks again and map to the results above based on the chunk. - # This maintains the original chunks ordering. Note, we cannot simply sort by score here - # as reranking flow may wipe the scores for a lot of the chunks. - doc_chunk_ranges_map = defaultdict(list) - for chunk in retrieved_chunks: - # The list of ranges for each document is ordered by score - doc_chunk_ranges_map[chunk.document_id].append( - ChunkRange( - chunks=[chunk], - start=max(0, chunk.chunk_id - above), - # No max known ahead of time, filter will handle this anyway - end=chunk.chunk_id + below, - ) - ) - - # List of ranges, outside list represents documents, inner list represents ranges - merged_ranges = [ - merge_chunk_intervals(ranges) for ranges in doc_chunk_ranges_map.values() - ] - - flat_ranges: list[ChunkRange] = [r for ranges in merged_ranges for r in ranges] - - for chunk_range in flat_ranges: - # Don't need to fetch chunks within range for merging if chunk_above / below are 0. - if above == below == 0: - inference_chunks.extend(chunk_range.chunks) - - else: - chunk_requests.append( - VespaChunkRequest( - document_id=chunk_range.chunks[0].document_id, - min_chunk_ind=chunk_range.start, - max_chunk_ind=chunk_range.end, - ) - ) - - if chunk_requests: - inference_chunks.extend( - cleanup_chunks( - self.document_index.id_based_retrieval( - chunk_requests=chunk_requests, - filters=IndexFilters(access_control_list=None), - batch_retrieval=True, - ) - ) - ) - - doc_chunk_ind_to_chunk = { - (chunk.document_id, chunk.chunk_id): chunk for chunk in inference_chunks - } - - # Build the surroundings for all of the initial retrieved chunks - for chunk in retrieved_chunks: - start_ind = max(0, chunk.chunk_id - above) - end_ind = chunk.chunk_id + below - - # Since the index of the max_chunk is unknown, just allow it to be None and filter after - surrounding_chunks_or_none = [ - doc_chunk_ind_to_chunk.get((chunk.document_id, chunk_ind)) - for chunk_ind in range(start_ind, end_ind + 1) # end_ind is inclusive - ] - # The None will apply to the would be "chunks" that are larger than the index of the last chunk - # of the document - surrounding_chunks = [ - chunk for chunk in surrounding_chunks_or_none if chunk is not None - ] - - inference_section = inference_section_from_chunks( - center_chunk=chunk, - chunks=surrounding_chunks, - ) - if inference_section is not None: - expanded_inference_sections.append(inference_section) - else: - logger.warning("Skipped creation of section, no chunks found") - - self._retrieved_sections = expanded_inference_sections - return expanded_inference_sections - - @property - def reranked_sections(self) -> list[InferenceSection]: - """Reranking is always done at the chunk level since section merging could create arbitrarily - long sections which could be: - 1. Longer than the maximum context limit of even large rerankers - 2. Slow to calculate due to the quadratic scaling laws of Transformers - - See implementation in search_postprocessing for details - """ - if self._reranked_sections is not None: - return self._reranked_sections - - self._postprocessing_generator = search_postprocessing( - search_query=self.search_query, - retrieved_sections=self._get_sections(), - llm=self.fast_llm, - rerank_metrics_callback=self.rerank_metrics_callback, - ) - - self._reranked_sections = cast( - list[InferenceSection], next(self._postprocessing_generator) - ) - - return self._reranked_sections - - @property - def final_context_sections(self) -> list[InferenceSection]: - if self._final_context_sections is not None: - return self._final_context_sections - - self._final_context_sections = _merge_sections(sections=self.reranked_sections) - return self._final_context_sections - - @property - def section_relevance(self) -> list[SectionRelevancePiece] | None: - if self._section_relevance is not None: - return self._section_relevance - - if ( - self.search_query.evaluation_type == LLMEvaluationType.SKIP - or DISABLE_LLM_DOC_RELEVANCE - ): - return None - - if self.search_query.evaluation_type == LLMEvaluationType.UNSPECIFIED: - raise ValueError( - "Attempted to access section relevance scores on search query with evaluation type `UNSPECIFIED`." - + "The search query evaluation type should have been specified." - ) - - if self.search_query.evaluation_type == LLMEvaluationType.AGENTIC: - sections = self.final_context_sections - functions = [ - FunctionCall( - evaluate_inference_section, - (section, self.search_query.query, self.llm), - ) - for section in sections - ] - try: - results = run_functions_in_parallel(function_calls=functions) - self._section_relevance = list(results.values()) - except Exception: - raise ValueError( - "An issue occured during the agentic evaluation proecss." - ) - - elif self.search_query.evaluation_type == LLMEvaluationType.BASIC: - if DISABLE_LLM_DOC_RELEVANCE: - raise ValueError( - "Basic search evaluation operation called while DISABLE_LLM_DOC_RELEVANCE is enabled." - ) - self._section_relevance = next( - cast( - Iterator[list[SectionRelevancePiece]], - self._postprocessing_generator, - ) - ) - - else: - # All other cases should have been handled above - raise ValueError( - f"Unexpected evaluation type: {self.search_query.evaluation_type}" - ) - - return self._section_relevance - - @property - def section_relevance_list(self) -> list[bool]: - llm_indices = relevant_sections_to_indices( - relevance_sections=self.section_relevance, - items=self.final_context_sections, - ) - return [ind in llm_indices for ind in range(len(self.final_context_sections))] diff --git a/backend/danswer/search/postprocessing/postprocessing.py b/backend/danswer/search/postprocessing/postprocessing.py deleted file mode 100644 index 6a3d2dc2dcd..00000000000 --- a/backend/danswer/search/postprocessing/postprocessing.py +++ /dev/null @@ -1,338 +0,0 @@ -from collections.abc import Callable -from collections.abc import Iterator -from typing import cast - -import numpy - -from danswer.chat.models import SectionRelevancePiece -from danswer.configs.app_configs import BLURB_SIZE -from danswer.configs.constants import RETURN_SEPARATOR -from danswer.configs.model_configs import CROSS_ENCODER_RANGE_MAX -from danswer.configs.model_configs import CROSS_ENCODER_RANGE_MIN -from danswer.document_index.document_index_utils import ( - translate_boost_count_to_multiplier, -) -from danswer.llm.interfaces import LLM -from danswer.natural_language_processing.search_nlp_models import RerankingModel -from danswer.search.enums import LLMEvaluationType -from danswer.search.models import ChunkMetric -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceChunkUncleaned -from danswer.search.models import InferenceSection -from danswer.search.models import MAX_METRICS_CONTENT -from danswer.search.models import RerankMetricsContainer -from danswer.search.models import SearchQuery -from danswer.secondary_llm_flows.chunk_usefulness import llm_batch_eval_sections -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import FunctionCall -from danswer.utils.threadpool_concurrency import run_functions_in_parallel -from danswer.utils.timing import log_function_time - - -logger = setup_logger() - - -def _log_top_section_links(search_flow: str, sections: list[InferenceSection]) -> None: - top_links = [ - section.center_chunk.source_links[0] - if section.center_chunk.source_links is not None - else "No Link" - for section in sections - ] - logger.debug(f"Top links from {search_flow} search: {', '.join(top_links)}") - - -def cleanup_chunks(chunks: list[InferenceChunkUncleaned]) -> list[InferenceChunk]: - def _remove_title(chunk: InferenceChunkUncleaned) -> str: - if not chunk.title or not chunk.content: - return chunk.content - - if chunk.content.startswith(chunk.title): - return chunk.content[len(chunk.title) :].lstrip() - - # BLURB SIZE is by token instead of char but each token is at least 1 char - # If this prefix matches the content, it's assumed the title was prepended - if chunk.content.startswith(chunk.title[:BLURB_SIZE]): - return ( - chunk.content.split(RETURN_SEPARATOR, 1)[-1] - if RETURN_SEPARATOR in chunk.content - else chunk.content - ) - - return chunk.content - - def _remove_metadata_suffix(chunk: InferenceChunkUncleaned) -> str: - if not chunk.metadata_suffix: - return chunk.content - return chunk.content.removesuffix(chunk.metadata_suffix).rstrip( - RETURN_SEPARATOR - ) - - for chunk in chunks: - chunk.content = _remove_title(chunk) - chunk.content = _remove_metadata_suffix(chunk) - - return [chunk.to_inference_chunk() for chunk in chunks] - - -@log_function_time(print_only=True) -def semantic_reranking( - query: SearchQuery, - chunks: list[InferenceChunk], - model_min: int = CROSS_ENCODER_RANGE_MIN, - model_max: int = CROSS_ENCODER_RANGE_MAX, - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, -) -> tuple[list[InferenceChunk], list[int]]: - """Reranks chunks based on cross-encoder models. Additionally provides the original indices - of the chunks in their new sorted order. - - Note: this updates the chunks in place, it updates the chunk scores which came from retrieval - """ - rerank_settings = query.rerank_settings - - if not rerank_settings or not rerank_settings.rerank_model_name: - # Should never reach this part of the flow without reranking settings - raise RuntimeError("Reranking flow should not be running") - - chunks_to_rerank = chunks[: rerank_settings.num_rerank] - - cross_encoder = RerankingModel( - model_name=rerank_settings.rerank_model_name, - provider_type=rerank_settings.rerank_provider_type, - api_key=rerank_settings.rerank_api_key, - ) - - passages = [ - f"{chunk.semantic_identifier or chunk.title or ''}\n{chunk.content}" - for chunk in chunks_to_rerank - ] - sim_scores_floats = cross_encoder.predict(query=query.query, passages=passages) - - # Old logic to handle multiple cross-encoders preserved but not used - sim_scores = [numpy.array(sim_scores_floats)] - - raw_sim_scores = cast(numpy.ndarray, sum(sim_scores) / len(sim_scores)) - - cross_models_min = numpy.min(sim_scores) - - shifted_sim_scores = sum( - [enc_n_scores - cross_models_min for enc_n_scores in sim_scores] - ) / len(sim_scores) - - boosts = [ - translate_boost_count_to_multiplier(chunk.boost) for chunk in chunks_to_rerank - ] - recency_multiplier = [chunk.recency_bias for chunk in chunks_to_rerank] - boosted_sim_scores = shifted_sim_scores * boosts * recency_multiplier - normalized_b_s_scores = (boosted_sim_scores + cross_models_min - model_min) / ( - model_max - model_min - ) - orig_indices = [i for i in range(len(normalized_b_s_scores))] - scored_results = list( - zip(normalized_b_s_scores, raw_sim_scores, chunks_to_rerank, orig_indices) - ) - scored_results.sort(key=lambda x: x[0], reverse=True) - ranked_sim_scores, ranked_raw_scores, ranked_chunks, ranked_indices = zip( - *scored_results - ) - - logger.debug( - f"Reranked (Boosted + Time Weighted) similarity scores: {ranked_sim_scores}" - ) - - # Assign new chunk scores based on reranking - for ind, chunk in enumerate(ranked_chunks): - chunk.score = ranked_sim_scores[ind] - - if rerank_metrics_callback is not None: - chunk_metrics = [ - ChunkMetric( - document_id=chunk.document_id, - chunk_content_start=chunk.content[:MAX_METRICS_CONTENT], - first_link=chunk.source_links[0] if chunk.source_links else None, - score=chunk.score if chunk.score is not None else 0, - ) - for chunk in ranked_chunks - ] - - rerank_metrics_callback( - RerankMetricsContainer( - metrics=chunk_metrics, raw_similarity_scores=ranked_raw_scores # type: ignore - ) - ) - - return list(ranked_chunks), list(ranked_indices) - - -def rerank_sections( - query: SearchQuery, - sections_to_rerank: list[InferenceSection], - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, -) -> list[InferenceSection]: - """Chunks are reranked rather than the containing sections, this is because of speed - implications, if reranking models have lower latency for long inputs in the future - we may rerank on the combined context of the section instead - - Making the assumption here that often times we want larger Sections to provide context - for the LLM to determine if a section is useful but for reranking, we don't need to be - as stringent. If the Section is relevant, we assume that the chunk rerank score will - also be high. - """ - chunks_to_rerank = [section.center_chunk for section in sections_to_rerank] - - if not query.rerank_settings: - # Should never reach this part of the flow without reranking settings - raise RuntimeError("Reranking settings not found") - - ranked_chunks, _ = semantic_reranking( - query=query, - chunks=chunks_to_rerank, - rerank_metrics_callback=rerank_metrics_callback, - ) - lower_chunks = chunks_to_rerank[query.rerank_settings.num_rerank :] - - # Scores from rerank cannot be meaningfully combined with scores without rerank - # However the ordering is still important - for lower_chunk in lower_chunks: - lower_chunk.score = None - ranked_chunks.extend(lower_chunks) - - chunk_id_to_section = { - section.center_chunk.unique_id: section for section in sections_to_rerank - } - ordered_sections = [chunk_id_to_section[chunk.unique_id] for chunk in ranked_chunks] - return ordered_sections - - -@log_function_time(print_only=True) -def filter_sections( - query: SearchQuery, - sections_to_filter: list[InferenceSection], - llm: LLM, - # For cost saving, we may turn this on - use_chunk: bool = False, -) -> list[InferenceSection]: - """Filters sections based on whether the LLM thought they were relevant to the query. - This applies on the section which has more context than the chunk. Hopefully this yields more accurate LLM evaluations. - - Returns a list of the unique chunk IDs that were marked as relevant - """ - sections_to_filter = sections_to_filter[: query.max_llm_filter_sections] - - contents = [ - section.center_chunk.content if use_chunk else section.combined_content - for section in sections_to_filter - ] - metadata_list = [section.center_chunk.metadata for section in sections_to_filter] - titles = [ - section.center_chunk.semantic_identifier for section in sections_to_filter - ] - - llm_chunk_selection = llm_batch_eval_sections( - query=query.query, - section_contents=contents, - llm=llm, - titles=titles, - metadata_list=metadata_list, - ) - - return [ - section - for ind, section in enumerate(sections_to_filter) - if llm_chunk_selection[ind] - ] - - -def search_postprocessing( - search_query: SearchQuery, - retrieved_sections: list[InferenceSection], - llm: LLM, - rerank_metrics_callback: Callable[[RerankMetricsContainer], None] | None = None, -) -> Iterator[list[InferenceSection] | list[SectionRelevancePiece]]: - post_processing_tasks: list[FunctionCall] = [] - - if not retrieved_sections: - # Avoids trying to rerank an empty list which throws an error - yield [] - yield [] - return - - rerank_task_id = None - sections_yielded = False - if ( - search_query.rerank_settings - and search_query.rerank_settings.rerank_model_name - and search_query.rerank_settings.num_rerank > 0 - ): - post_processing_tasks.append( - FunctionCall( - rerank_sections, - ( - search_query, - retrieved_sections, - rerank_metrics_callback, - ), - ) - ) - rerank_task_id = post_processing_tasks[-1].result_id - else: - # NOTE: if we don't rerank, we can return the chunks immediately - # since we know this is the final order. - # This way the user experience isn't delayed by the LLM step - _log_top_section_links(search_query.search_type.value, retrieved_sections) - yield retrieved_sections - sections_yielded = True - - llm_filter_task_id = None - if search_query.evaluation_type in [ - LLMEvaluationType.BASIC, - LLMEvaluationType.UNSPECIFIED, - ]: - post_processing_tasks.append( - FunctionCall( - filter_sections, - ( - search_query, - retrieved_sections[: search_query.max_llm_filter_sections], - llm, - ), - ) - ) - llm_filter_task_id = post_processing_tasks[-1].result_id - - post_processing_results = ( - run_functions_in_parallel(post_processing_tasks) - if post_processing_tasks - else {} - ) - reranked_sections = cast( - list[InferenceSection] | None, - post_processing_results.get(str(rerank_task_id)) if rerank_task_id else None, - ) - if reranked_sections: - if sections_yielded: - logger.error( - "Trying to yield re-ranked sections, but sections were already yielded. This should never happen." - ) - else: - _log_top_section_links(search_query.search_type.value, reranked_sections) - yield reranked_sections - - llm_selected_section_ids = ( - [ - section.center_chunk.unique_id - for section in post_processing_results.get(str(llm_filter_task_id), []) - ] - if llm_filter_task_id - else [] - ) - - yield [ - SectionRelevancePiece( - document_id=section.center_chunk.document_id, - chunk_id=section.center_chunk.chunk_id, - relevant=section.center_chunk.unique_id in llm_selected_section_ids, - content="", - ) - for section in (reranked_sections or retrieved_sections) - ] diff --git a/backend/danswer/search/postprocessing/reranker.py b/backend/danswer/search/postprocessing/reranker.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/search/preprocessing/access_filters.py b/backend/danswer/search/preprocessing/access_filters.py deleted file mode 100644 index e8141864d11..00000000000 --- a/backend/danswer/search/preprocessing/access_filters.py +++ /dev/null @@ -1,21 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.access.access import get_acl_for_user -from danswer.db.models import User -from danswer.search.models import IndexFilters - - -def build_access_filters_for_user(user: User | None, session: Session) -> list[str]: - user_acl = get_acl_for_user(user, session) - return list(user_acl) - - -def build_user_only_filters(user: User | None, db_session: Session) -> IndexFilters: - user_acl_filters = build_access_filters_for_user(user, db_session) - return IndexFilters( - source_type=None, - document_set=None, - time_cutoff=None, - tags=None, - access_control_list=user_acl_filters, - ) diff --git a/backend/danswer/search/preprocessing/preprocessing.py b/backend/danswer/search/preprocessing/preprocessing.py deleted file mode 100644 index 43a6a43ce88..00000000000 --- a/backend/danswer/search/preprocessing/preprocessing.py +++ /dev/null @@ -1,240 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.configs.chat_configs import BASE_RECENCY_DECAY -from danswer.configs.chat_configs import CONTEXT_CHUNKS_ABOVE -from danswer.configs.chat_configs import CONTEXT_CHUNKS_BELOW -from danswer.configs.chat_configs import DISABLE_LLM_DOC_RELEVANCE -from danswer.configs.chat_configs import FAVOR_RECENT_DECAY_MULTIPLIER -from danswer.configs.chat_configs import HYBRID_ALPHA -from danswer.configs.chat_configs import HYBRID_ALPHA_KEYWORD -from danswer.configs.chat_configs import NUM_POSTPROCESSED_RESULTS -from danswer.configs.chat_configs import NUM_RETURNED_HITS -from danswer.db.models import User -from danswer.db.search_settings import get_current_search_settings -from danswer.llm.interfaces import LLM -from danswer.natural_language_processing.search_nlp_models import QueryAnalysisModel -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import RecencyBiasSetting -from danswer.search.enums import SearchType -from danswer.search.models import BaseFilters -from danswer.search.models import IndexFilters -from danswer.search.models import RerankingDetails -from danswer.search.models import SearchQuery -from danswer.search.models import SearchRequest -from danswer.search.preprocessing.access_filters import build_access_filters_for_user -from danswer.search.retrieval.search_runner import remove_stop_words_and_punctuation -from danswer.secondary_llm_flows.source_filter import extract_source_filter -from danswer.secondary_llm_flows.time_filter import extract_time_filter -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import FunctionCall -from danswer.utils.threadpool_concurrency import run_functions_in_parallel -from danswer.utils.timing import log_function_time - - -logger = setup_logger() - - -def query_analysis(query: str) -> tuple[bool, list[str]]: - analysis_model = QueryAnalysisModel() - return analysis_model.predict(query) - - -@log_function_time(print_only=True) -def retrieval_preprocessing( - search_request: SearchRequest, - user: User | None, - llm: LLM, - db_session: Session, - bypass_acl: bool = False, - skip_query_analysis: bool = False, - base_recency_decay: float = BASE_RECENCY_DECAY, - favor_recent_decay_multiplier: float = FAVOR_RECENT_DECAY_MULTIPLIER, -) -> SearchQuery: - """Logic is as follows: - Any global disables apply first - Then any filters or settings as part of the query are used - Then defaults to Persona settings if not specified by the query - """ - query = search_request.query - limit = search_request.limit - offset = search_request.offset - persona = search_request.persona - - preset_filters = search_request.human_selected_filters or BaseFilters() - if persona and persona.document_sets and preset_filters.document_set is None: - preset_filters.document_set = [ - document_set.name for document_set in persona.document_sets - ] - - time_filter = preset_filters.time_cutoff - source_filter = preset_filters.source_type - - auto_detect_time_filter = True - auto_detect_source_filter = True - if not search_request.enable_auto_detect_filters: - logger.debug("Retrieval details disables auto detect filters") - auto_detect_time_filter = False - auto_detect_source_filter = False - elif persona and persona.llm_filter_extraction is False: - logger.debug("Persona disables auto detect filters") - auto_detect_time_filter = False - auto_detect_source_filter = False - else: - logger.debug("Auto detect filters enabled") - - if ( - time_filter is not None - and persona - and persona.recency_bias != RecencyBiasSetting.AUTO - ): - auto_detect_time_filter = False - logger.debug("Not extract time filter - already provided") - if source_filter is not None: - logger.debug("Not extract source filter - already provided") - auto_detect_source_filter = False - - # Based on the query figure out if we should apply any hard time filters / - # if we should bias more recent docs even more strongly - run_time_filters = ( - FunctionCall(extract_time_filter, (query, llm), {}) - if auto_detect_time_filter - else None - ) - - # Based on the query, figure out if we should apply any source filters - run_source_filters = ( - FunctionCall(extract_source_filter, (query, llm, db_session), {}) - if auto_detect_source_filter - else None - ) - - run_query_analysis = ( - None if skip_query_analysis else FunctionCall(query_analysis, (query,), {}) - ) - - functions_to_run = [ - filter_fn - for filter_fn in [ - run_time_filters, - run_source_filters, - run_query_analysis, - ] - if filter_fn - ] - parallel_results = run_functions_in_parallel(functions_to_run) - - predicted_time_cutoff, predicted_favor_recent = ( - parallel_results[run_time_filters.result_id] - if run_time_filters - else (None, None) - ) - predicted_source_filters = ( - parallel_results[run_source_filters.result_id] if run_source_filters else None - ) - - # The extracted keywords right now are not very reliable, not using for now - # Can maybe use for highlighting - is_keyword, extracted_keywords = ( - parallel_results[run_query_analysis.result_id] - if run_query_analysis - else (None, None) - ) - - all_query_terms = query.split() - processed_keywords = ( - remove_stop_words_and_punctuation(all_query_terms) - # If the user is using a different language, don't edit the query or remove english stopwords - if not search_request.multilingual_expansion - else all_query_terms - ) - - user_acl_filters = ( - None if bypass_acl else build_access_filters_for_user(user, db_session) - ) - final_filters = IndexFilters( - source_type=preset_filters.source_type or predicted_source_filters, - document_set=preset_filters.document_set, - time_cutoff=preset_filters.time_cutoff or predicted_time_cutoff, - tags=preset_filters.tags, # Tags are never auto-extracted - access_control_list=user_acl_filters, - ) - - llm_evaluation_type = LLMEvaluationType.BASIC - if search_request.evaluation_type is not LLMEvaluationType.UNSPECIFIED: - llm_evaluation_type = search_request.evaluation_type - - elif persona: - llm_evaluation_type = ( - LLMEvaluationType.BASIC - if persona.llm_relevance_filter - else LLMEvaluationType.SKIP - ) - - if DISABLE_LLM_DOC_RELEVANCE: - if llm_evaluation_type: - logger.info( - "LLM chunk filtering would have run but has been globally disabled" - ) - llm_evaluation_type = LLMEvaluationType.SKIP - - rerank_settings = search_request.rerank_settings - # If not explicitly specified by the query, use the current settings - if rerank_settings is None: - search_settings = get_current_search_settings(db_session) - - # For non-streaming flows, the rerank settings are applied at the search_request level - if not search_settings.disable_rerank_for_streaming: - rerank_settings = RerankingDetails.from_db_model(search_settings) - - # Decays at 1 / (1 + (multiplier * num years)) - if persona and persona.recency_bias == RecencyBiasSetting.NO_DECAY: - recency_bias_multiplier = 0.0 - elif persona and persona.recency_bias == RecencyBiasSetting.BASE_DECAY: - recency_bias_multiplier = base_recency_decay - elif persona and persona.recency_bias == RecencyBiasSetting.FAVOR_RECENT: - recency_bias_multiplier = base_recency_decay * favor_recent_decay_multiplier - else: - if predicted_favor_recent: - recency_bias_multiplier = base_recency_decay * favor_recent_decay_multiplier - else: - recency_bias_multiplier = base_recency_decay - - hybrid_alpha = HYBRID_ALPHA_KEYWORD if is_keyword else HYBRID_ALPHA - if search_request.hybrid_alpha: - hybrid_alpha = search_request.hybrid_alpha - - # Search request overrides anything else as it's explicitly set by the request - # If not explicitly specified, use the persona settings if they exist - # Otherwise, use the global defaults - chunks_above = ( - search_request.chunks_above - if search_request.chunks_above is not None - else (persona.chunks_above if persona else CONTEXT_CHUNKS_ABOVE) - ) - chunks_below = ( - search_request.chunks_below - if search_request.chunks_below is not None - else (persona.chunks_below if persona else CONTEXT_CHUNKS_BELOW) - ) - - return SearchQuery( - query=query, - processed_keywords=processed_keywords, - search_type=SearchType.KEYWORD if is_keyword else SearchType.SEMANTIC, - evaluation_type=llm_evaluation_type, - filters=final_filters, - hybrid_alpha=hybrid_alpha, - recency_bias_multiplier=recency_bias_multiplier, - num_hits=limit if limit is not None else NUM_RETURNED_HITS, - offset=offset or 0, - rerank_settings=rerank_settings, - # Should match the LLM filtering to the same as the reranked, it's understood as this is the number of results - # the user wants to do heavier processing on, so do the same for the LLM if reranking is on - # if no reranking settings are set, then use the global default - max_llm_filter_sections=rerank_settings.num_rerank - if rerank_settings - else NUM_POSTPROCESSED_RESULTS, - chunks_above=chunks_above, - chunks_below=chunks_below, - full_doc=search_request.full_doc, - ) diff --git a/backend/danswer/search/retrieval/search_runner.py b/backend/danswer/search/retrieval/search_runner.py deleted file mode 100644 index 31582f90819..00000000000 --- a/backend/danswer/search/retrieval/search_runner.py +++ /dev/null @@ -1,331 +0,0 @@ -import string -from collections.abc import Callable - -import nltk # type:ignore -from nltk.corpus import stopwords # type:ignore -from nltk.stem import WordNetLemmatizer # type:ignore -from nltk.tokenize import word_tokenize # type:ignore -from sqlalchemy.orm import Session - -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_multilingual_expansion -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.document_index.vespa.shared_utils.utils import ( - replace_invalid_doc_id_characters, -) -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.search.models import ChunkMetric -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceChunkUncleaned -from danswer.search.models import InferenceSection -from danswer.search.models import MAX_METRICS_CONTENT -from danswer.search.models import RetrievalMetricsContainer -from danswer.search.models import SearchQuery -from danswer.search.postprocessing.postprocessing import cleanup_chunks -from danswer.search.utils import inference_section_from_chunks -from danswer.secondary_llm_flows.query_expansion import multilingual_query_expansion -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel -from danswer.utils.timing import log_function_time -from shared_configs.configs import MODEL_SERVER_HOST -from shared_configs.configs import MODEL_SERVER_PORT -from shared_configs.enums import EmbedTextType - - -logger = setup_logger() - - -def download_nltk_data() -> None: - resources = { - "stopwords": "corpora/stopwords", - "wordnet": "corpora/wordnet", - "punkt": "tokenizers/punkt", - } - - for resource_name, resource_path in resources.items(): - try: - nltk.data.find(resource_path) - logger.info(f"{resource_name} is already downloaded.") - except LookupError: - try: - logger.info(f"Downloading {resource_name}...") - nltk.download(resource_name, quiet=True) - logger.info(f"{resource_name} downloaded successfully.") - except Exception as e: - logger.error(f"Failed to download {resource_name}. Error: {e}") - - -def lemmatize_text(keywords: list[str]) -> list[str]: - try: - query = " ".join(keywords) - lemmatizer = WordNetLemmatizer() - word_tokens = word_tokenize(query) - lemmatized_words = [lemmatizer.lemmatize(word) for word in word_tokens] - combined_keywords = list(set(keywords + lemmatized_words)) - return combined_keywords - except Exception: - return keywords - - -def remove_stop_words_and_punctuation(keywords: list[str]) -> list[str]: - try: - # Re-tokenize using the NLTK tokenizer for better matching - query = " ".join(keywords) - stop_words = set(stopwords.words("english")) - word_tokens = word_tokenize(query) - text_trimmed = [ - word - for word in word_tokens - if (word.casefold() not in stop_words and word not in string.punctuation) - ] - return text_trimmed or word_tokens - except Exception: - return keywords - - -def combine_retrieval_results( - chunk_sets: list[list[InferenceChunk]], -) -> list[InferenceChunk]: - all_chunks = [chunk for chunk_set in chunk_sets for chunk in chunk_set] - - unique_chunks: dict[tuple[str, int], InferenceChunk] = {} - for chunk in all_chunks: - key = (chunk.document_id, chunk.chunk_id) - if key not in unique_chunks: - unique_chunks[key] = chunk - continue - - stored_chunk_score = unique_chunks[key].score or 0 - this_chunk_score = chunk.score or 0 - if stored_chunk_score < this_chunk_score: - unique_chunks[key] = chunk - - sorted_chunks = sorted( - unique_chunks.values(), key=lambda x: x.score or 0, reverse=True - ) - - return sorted_chunks - - -@log_function_time(print_only=True) -def doc_index_retrieval( - query: SearchQuery, - document_index: DocumentIndex, - db_session: Session, -) -> list[InferenceChunk]: - """ - This function performs the search to retrieve the chunks, - extracts chunks from the large chunks, persists the scores - from the large chunks to the referenced chunks, - dedupes the chunks, and cleans the chunks. - """ - search_settings = get_current_search_settings(db_session) - - model = EmbeddingModel.from_db_model( - search_settings=search_settings, - # The below are globally set, this flow always uses the indexing one - server_host=MODEL_SERVER_HOST, - server_port=MODEL_SERVER_PORT, - ) - - query_embedding = model.encode([query.query], text_type=EmbedTextType.QUERY)[0] - - top_chunks = document_index.hybrid_retrieval( - query=query.query, - query_embedding=query_embedding, - final_keywords=query.processed_keywords, - filters=query.filters, - hybrid_alpha=query.hybrid_alpha, - time_decay_multiplier=query.recency_bias_multiplier, - num_to_retrieve=query.num_hits, - offset=query.offset, - ) - - retrieval_requests: list[VespaChunkRequest] = [] - normal_chunks: list[InferenceChunkUncleaned] = [] - referenced_chunk_scores: dict[tuple[str, int], float] = {} - for chunk in top_chunks: - if chunk.large_chunk_reference_ids: - retrieval_requests.append( - VespaChunkRequest( - document_id=replace_invalid_doc_id_characters(chunk.document_id), - min_chunk_ind=chunk.large_chunk_reference_ids[0], - max_chunk_ind=chunk.large_chunk_reference_ids[-1], - ) - ) - # for each referenced chunk, persist the - # highest score to the referenced chunk - for chunk_id in chunk.large_chunk_reference_ids: - key = (chunk.document_id, chunk_id) - referenced_chunk_scores[key] = max( - referenced_chunk_scores.get(key, 0), chunk.score or 0 - ) - else: - normal_chunks.append(chunk) - - # If there are no large chunks, just return the normal chunks - if not retrieval_requests: - return cleanup_chunks(normal_chunks) - - # Retrieve and return the referenced normal chunks from the large chunks - retrieved_inference_chunks = document_index.id_based_retrieval( - chunk_requests=retrieval_requests, - filters=query.filters, - batch_retrieval=True, - ) - - # Apply the scores from the large chunks to the chunks referenced - # by each large chunk - for chunk in retrieved_inference_chunks: - if (chunk.document_id, chunk.chunk_id) in referenced_chunk_scores: - chunk.score = referenced_chunk_scores[(chunk.document_id, chunk.chunk_id)] - referenced_chunk_scores.pop((chunk.document_id, chunk.chunk_id)) - else: - logger.error( - f"Chunk {chunk.document_id} {chunk.chunk_id} not found in referenced chunk scores" - ) - - # Log any chunks that were not found in the retrieved chunks - for reference in referenced_chunk_scores.keys(): - logger.error(f"Chunk {reference} not found in retrieved chunks") - - unique_chunks: dict[tuple[str, int], InferenceChunkUncleaned] = { - (chunk.document_id, chunk.chunk_id): chunk for chunk in normal_chunks - } - - # persist the highest score of each deduped chunk - for chunk in retrieved_inference_chunks: - key = (chunk.document_id, chunk.chunk_id) - # For duplicates, keep the highest score - if key not in unique_chunks or (chunk.score or 0) > ( - unique_chunks[key].score or 0 - ): - unique_chunks[key] = chunk - - # Deduplicate the chunks - deduped_chunks = list(unique_chunks.values()) - deduped_chunks.sort(key=lambda chunk: chunk.score or 0, reverse=True) - return cleanup_chunks(deduped_chunks) - - -def _simplify_text(text: str) -> str: - return "".join( - char for char in text if char not in string.punctuation and not char.isspace() - ).lower() - - -def retrieve_chunks( - query: SearchQuery, - document_index: DocumentIndex, - db_session: Session, - retrieval_metrics_callback: Callable[[RetrievalMetricsContainer], None] - | None = None, -) -> list[InferenceChunk]: - """Returns a list of the best chunks from an initial keyword/semantic/ hybrid search.""" - - multilingual_expansion = get_multilingual_expansion(db_session) - # Don't do query expansion on complex queries, rephrasings likely would not work well - if not multilingual_expansion or "\n" in query.query or "\r" in query.query: - top_chunks = doc_index_retrieval( - query=query, document_index=document_index, db_session=db_session - ) - else: - simplified_queries = set() - run_queries: list[tuple[Callable, tuple]] = [] - - # Currently only uses query expansion on multilingual use cases - query_rephrases = multilingual_query_expansion( - query.query, multilingual_expansion - ) - # Just to be extra sure, add the original query. - query_rephrases.append(query.query) - for rephrase in set(query_rephrases): - # Sometimes the model rephrases the query in the same language with minor changes - # Avoid doing an extra search with the minor changes as this biases the results - simplified_rephrase = _simplify_text(rephrase) - if simplified_rephrase in simplified_queries: - continue - simplified_queries.add(simplified_rephrase) - - q_copy = query.copy(update={"query": rephrase}, deep=True) - run_queries.append( - ( - doc_index_retrieval, - (q_copy, document_index, db_session), - ) - ) - parallel_search_results = run_functions_tuples_in_parallel(run_queries) - top_chunks = combine_retrieval_results(parallel_search_results) - - if not top_chunks: - logger.warning( - f"Hybrid ({query.search_type.value.capitalize()}) search returned no results " - f"with filters: {query.filters}" - ) - return [] - - if retrieval_metrics_callback is not None: - chunk_metrics = [ - ChunkMetric( - document_id=chunk.document_id, - chunk_content_start=chunk.content[:MAX_METRICS_CONTENT], - first_link=chunk.source_links[0] if chunk.source_links else None, - score=chunk.score if chunk.score is not None else 0, - ) - for chunk in top_chunks - ] - retrieval_metrics_callback( - RetrievalMetricsContainer( - search_type=query.search_type, metrics=chunk_metrics - ) - ) - - return top_chunks - - -def inference_sections_from_ids( - doc_identifiers: list[tuple[str, int]], - document_index: DocumentIndex, -) -> list[InferenceSection]: - # Currently only fetches whole docs - doc_ids_set = set(doc_id for doc_id, _ in doc_identifiers) - - chunk_requests: list[VespaChunkRequest] = [ - VespaChunkRequest(document_id=doc_id) for doc_id in doc_ids_set - ] - - # No need for ACL here because the doc ids were validated beforehand - filters = IndexFilters(access_control_list=None) - - retrieved_chunks = document_index.id_based_retrieval( - chunk_requests=chunk_requests, - filters=filters, - ) - - cleaned_chunks = cleanup_chunks(retrieved_chunks) - if not cleaned_chunks: - return [] - - # Group chunks by document ID - chunks_by_doc_id: dict[str, list[InferenceChunk]] = {} - for chunk in cleaned_chunks: - chunks_by_doc_id.setdefault(chunk.document_id, []).append(chunk) - - inference_sections = [ - section - for chunks in chunks_by_doc_id.values() - if chunks - and ( - section := inference_section_from_chunks( - # The scores will always be 0 because the fetching by id gives back - # no search scores. This is not needed though if the user is explicitly - # selecting a document. - center_chunk=chunks[0], - chunks=chunks, - ) - ) - ] - - return inference_sections diff --git a/backend/danswer/search/search_settings.py b/backend/danswer/search/search_settings.py deleted file mode 100644 index d502205dfe7..00000000000 --- a/backend/danswer/search/search_settings.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import cast - -from danswer.configs.constants import KV_SEARCH_SETTINGS -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.search.models import SavedSearchSettings -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_kv_search_settings() -> SavedSearchSettings | None: - """Get all user configured search settings which affect the search pipeline - Note: KV store is used in this case since there is no need to rollback the value or any need to audit past values - - Note: for now we can't cache this value because if the API server is scaled, the cache could be out of sync - if the value is updated by another process/instance of the API server. If this reads from an in memory cache like - reddis then it will be ok. Until then this has some performance implications (though minor) - """ - kv_store = get_dynamic_config_store() - try: - return SavedSearchSettings(**cast(dict, kv_store.load(KV_SEARCH_SETTINGS))) - except ConfigNotFoundError: - return None - except Exception as e: - logger.error(f"Error loading search settings: {e}") - # Wiping it so that next server startup, it can load the defaults - # or the user can set it via the API/UI - kv_store.delete(KV_SEARCH_SETTINGS) - return None diff --git a/backend/danswer/search/utils.py b/backend/danswer/search/utils.py deleted file mode 100644 index 21a95320ef5..00000000000 --- a/backend/danswer/search/utils.py +++ /dev/null @@ -1,138 +0,0 @@ -from collections.abc import Sequence -from typing import TypeVar - -from danswer.chat.models import SectionRelevancePiece -from danswer.db.models import SearchDoc as DBSearchDoc -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceSection -from danswer.search.models import SavedSearchDoc -from danswer.search.models import SavedSearchDocWithContent -from danswer.search.models import SearchDoc - - -T = TypeVar( - "T", - InferenceSection, - InferenceChunk, - SearchDoc, - SavedSearchDoc, - SavedSearchDocWithContent, -) - -TSection = TypeVar( - "TSection", - InferenceSection, - SearchDoc, - SavedSearchDoc, - SavedSearchDocWithContent, -) - - -def dedupe_documents(items: list[T]) -> tuple[list[T], list[int]]: - seen_ids = set() - deduped_items = [] - dropped_indices = [] - for index, item in enumerate(items): - if isinstance(item, InferenceSection): - document_id = item.center_chunk.document_id - else: - document_id = item.document_id - - if document_id not in seen_ids: - seen_ids.add(document_id) - deduped_items.append(item) - else: - dropped_indices.append(index) - return deduped_items, dropped_indices - - -def relevant_sections_to_indices( - relevance_sections: list[SectionRelevancePiece] | None, items: list[TSection] -) -> list[int]: - if not relevance_sections: - return [] - - relevant_set = { - (chunk.document_id, chunk.chunk_id) - for chunk in relevance_sections - if chunk.relevant - } - - return [ - index - for index, item in enumerate(items) - if ( - ( - isinstance(item, InferenceSection) - and (item.center_chunk.document_id, item.center_chunk.chunk_id) - in relevant_set - ) - or ( - not isinstance(item, (InferenceSection)) - and (item.document_id, item.chunk_ind) in relevant_set - ) - ) - ] - - -def drop_llm_indices( - llm_indices: list[int], - search_docs: Sequence[DBSearchDoc | SavedSearchDoc], - dropped_indices: list[int], -) -> list[int]: - llm_bools = [True if i in llm_indices else False for i in range(len(search_docs))] - if dropped_indices: - llm_bools = [ - val for ind, val in enumerate(llm_bools) if ind not in dropped_indices - ] - return [i for i, val in enumerate(llm_bools) if val] - - -def inference_section_from_chunks( - center_chunk: InferenceChunk, - chunks: list[InferenceChunk], -) -> InferenceSection | None: - if not chunks: - return None - - combined_content = "\n".join([chunk.content for chunk in chunks]) - - return InferenceSection( - center_chunk=center_chunk, - chunks=chunks, - combined_content=combined_content, - ) - - -def chunks_or_sections_to_search_docs( - items: Sequence[InferenceChunk | InferenceSection] | None, -) -> list[SearchDoc]: - if not items: - return [] - - search_docs = [ - SearchDoc( - document_id=( - chunk := item.center_chunk - if isinstance(item, InferenceSection) - else item - ).document_id, - chunk_ind=chunk.chunk_id, - semantic_identifier=chunk.semantic_identifier or "Unknown", - link=chunk.source_links[0] if chunk.source_links else None, - blurb=chunk.blurb, - source_type=chunk.source_type, - boost=chunk.boost, - hidden=chunk.hidden, - metadata=chunk.metadata, - score=chunk.score, - match_highlights=chunk.match_highlights, - updated_at=chunk.updated_at, - primary_owners=chunk.primary_owners, - secondary_owners=chunk.secondary_owners, - is_internet=False, - ) - for item in items - ] - - return search_docs diff --git a/backend/danswer/secondary_llm_flows/__init__.py b/backend/danswer/secondary_llm_flows/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/secondary_llm_flows/agentic_evaluation.py b/backend/danswer/secondary_llm_flows/agentic_evaluation.py deleted file mode 100644 index 3de9db00be6..00000000000 --- a/backend/danswer/secondary_llm_flows/agentic_evaluation.py +++ /dev/null @@ -1,86 +0,0 @@ -import re - -from danswer.chat.models import SectionRelevancePiece -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.agentic_evaluation import AGENTIC_SEARCH_SYSTEM_PROMPT -from danswer.prompts.agentic_evaluation import AGENTIC_SEARCH_USER_PROMPT -from danswer.search.models import InferenceSection -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _get_agent_eval_messages( - title: str, content: str, query: str, center_metadata: str -) -> list[dict[str, str]]: - messages = [ - { - "role": "system", - "content": AGENTIC_SEARCH_SYSTEM_PROMPT, - }, - { - "role": "user", - "content": AGENTIC_SEARCH_USER_PROMPT.format( - title=title, - content=content, - query=query, - optional_metadata=center_metadata, - ), - }, - ] - return messages - - -def evaluate_inference_section( - document: InferenceSection, query: str, llm: LLM -) -> SectionRelevancePiece: - def _get_metadata_str(metadata: dict[str, str | list[str]]) -> str: - metadata_str = "\n\nMetadata:\n" - for key, value in metadata.items(): - value_str = ", ".join(value) if isinstance(value, list) else value - metadata_str += f"{key} - {value_str}\n" - - # Since there is now multiple sections, add this prefix for clarity - return metadata_str + "\nContent:" - - document_id = document.center_chunk.document_id - semantic_id = document.center_chunk.semantic_identifier - contents = document.combined_content - center_metadata = document.center_chunk.metadata - center_metadata_str = _get_metadata_str(center_metadata) if center_metadata else "" - - messages = _get_agent_eval_messages( - title=semantic_id, - content=contents, - query=query, - center_metadata=center_metadata_str, - ) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - - # Search for the "Useful Analysis" section in the model output - # This regex looks for "2. Useful Analysis" (case-insensitive) followed by an optional colon, - # then any text up to "3. Final Relevance" - # The (?i) flag makes it case-insensitive, and re.DOTALL allows the dot to match newlines - # If no match is found, the entire model output is used as the analysis - analysis_match = re.search( - r"(?i)2\.\s*useful analysis:?\s*(.+?)\n\n3\.\s*final relevance", - model_output, - re.DOTALL, - ) - analysis = analysis_match.group(1).strip() if analysis_match else model_output - - # Get the last non-empty line - last_line = next( - (line for line in reversed(model_output.split("\n")) if line.strip()), "" - ) - relevant = last_line.strip().lower().startswith("true") - - return SectionRelevancePiece( - document_id=document_id, - chunk_id=document.center_chunk.chunk_id, - relevant=relevant, - content=analysis, - ) diff --git a/backend/danswer/secondary_llm_flows/answer_validation.py b/backend/danswer/secondary_llm_flows/answer_validation.py deleted file mode 100644 index 68587109538..00000000000 --- a/backend/danswer/secondary_llm_flows/answer_validation.py +++ /dev/null @@ -1,61 +0,0 @@ -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_default_llms -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.answer_validation import ANSWER_VALIDITY_PROMPT -from danswer.utils.logger import setup_logger -from danswer.utils.timing import log_function_time - -logger = setup_logger() - - -@log_function_time() -def get_answer_validity( - query: str, - answer: str, -) -> bool: - def _get_answer_validation_messages( - query: str, answer: str - ) -> list[dict[str, str]]: - # Below COT block is unused, keeping for reference. Chain of Thought here significantly increases the time to - # answer, we can get most of the way there but just having the model evaluate each individual condition with - # a single True/False. - # cot_block = ( - # f"{THOUGHT_PAT} Use this as a scratchpad to write out in a step by step manner your reasoning " - # f"about EACH criterion to ensure that your conclusion is correct. " - # f"Be brief when evaluating each condition.\n" - # f"{FINAL_ANSWER_PAT} Valid or Invalid" - # ) - - messages = [ - { - "role": "user", - "content": ANSWER_VALIDITY_PROMPT.format( - user_query=query, llm_answer=answer - ), - }, - ] - - return messages - - def _extract_validity(model_output: str) -> bool: - if model_output.strip().strip("```").strip().split()[-1].lower() == "invalid": - return False - return True # If something is wrong, let's not toss away the answer - - try: - llm, _ = get_default_llms() - except GenAIDisabledException: - return True - - if not answer: - return False - - messages = _get_answer_validation_messages(query, answer) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - validity = _extract_validity(model_output) - - return validity diff --git a/backend/danswer/secondary_llm_flows/chat_session_naming.py b/backend/danswer/secondary_llm_flows/chat_session_naming.py deleted file mode 100644 index 9ca5f34a62f..00000000000 --- a/backend/danswer/secondary_llm_flows/chat_session_naming.py +++ /dev/null @@ -1,45 +0,0 @@ -from danswer.chat.chat_utils import combine_message_chain -from danswer.configs.chat_configs import LANGUAGE_CHAT_NAMING_HINT -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.db.models import ChatMessage -from danswer.db.search_settings import get_multilingual_expansion -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.chat_prompts import CHAT_NAMING -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_renamed_conversation_name( - full_history: list[ChatMessage], - llm: LLM, -) -> str: - history_str = combine_message_chain( - messages=full_history, token_limit=GEN_AI_HISTORY_CUTOFF - ) - - language_hint = ( - f"\n{LANGUAGE_CHAT_NAMING_HINT.strip()}" - if bool(get_multilingual_expansion()) - else "" - ) - - prompt_msgs = [ - { - "role": "user", - "content": CHAT_NAMING.format( - language_hint_or_empty=language_hint, chat_history=history_str - ), - }, - ] - - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(prompt_msgs) - new_name_raw = message_to_string(llm.invoke(filled_llm_prompt)) - - new_name = new_name_raw.strip().strip(' "') - - logger.debug(f"New Session Name: {new_name}") - - return new_name diff --git a/backend/danswer/secondary_llm_flows/choose_search.py b/backend/danswer/secondary_llm_flows/choose_search.py deleted file mode 100644 index 5016cf055bc..00000000000 --- a/backend/danswer/secondary_llm_flows/choose_search.py +++ /dev/null @@ -1,87 +0,0 @@ -from langchain.schema import BaseMessage -from langchain.schema import HumanMessage -from langchain.schema import SystemMessage - -from danswer.chat.chat_utils import combine_message_chain -from danswer.configs.chat_configs import DISABLE_LLM_CHOOSE_SEARCH -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.db.models import ChatMessage -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.llm.utils import translate_danswer_msg_to_langchain -from danswer.prompts.chat_prompts import AGGRESSIVE_SEARCH_TEMPLATE -from danswer.prompts.chat_prompts import NO_SEARCH -from danswer.prompts.chat_prompts import REQUIRE_SEARCH_HINT -from danswer.prompts.chat_prompts import REQUIRE_SEARCH_SYSTEM_MSG -from danswer.prompts.chat_prompts import SKIP_SEARCH -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def check_if_need_search_multi_message( - query_message: ChatMessage, - history: list[ChatMessage], - llm: LLM, -) -> bool: - # Retrieve on start or when choosing is globally disabled - if not history or DISABLE_LLM_CHOOSE_SEARCH: - return True - - prompt_msgs: list[BaseMessage] = [SystemMessage(content=REQUIRE_SEARCH_SYSTEM_MSG)] - prompt_msgs.extend([translate_danswer_msg_to_langchain(msg) for msg in history]) - - last_query = query_message.message - - prompt_msgs.append(HumanMessage(content=f"{last_query}\n\n{REQUIRE_SEARCH_HINT}")) - - model_out = message_to_string(llm.invoke(prompt_msgs)) - - if (NO_SEARCH.split()[0] + " ").lower() in model_out.lower(): - return False - - return True - - -def check_if_need_search( - query: str, - history: list[PreviousMessage], - llm: LLM, -) -> bool: - def _get_search_messages( - question: str, - history_str: str, - ) -> list[dict[str, str]]: - messages = [ - { - "role": "user", - "content": AGGRESSIVE_SEARCH_TEMPLATE.format( - final_query=question, chat_history=history_str - ).strip(), - }, - ] - - return messages - - # Choosing is globally disabled, use search - if DISABLE_LLM_CHOOSE_SEARCH: - return True - - history_str = combine_message_chain( - messages=history, token_limit=GEN_AI_HISTORY_CUTOFF - ) - - prompt_msgs = _get_search_messages(question=query, history_str=history_str) - - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(prompt_msgs) - require_search_output = message_to_string(llm.invoke(filled_llm_prompt)) - - logger.debug(f"Run search prediction: {require_search_output}") - - if (SKIP_SEARCH.split()[0]).lower() in require_search_output.lower(): - return False - - return True diff --git a/backend/danswer/secondary_llm_flows/chunk_usefulness.py b/backend/danswer/secondary_llm_flows/chunk_usefulness.py deleted file mode 100644 index b978244028f..00000000000 --- a/backend/danswer/secondary_llm_flows/chunk_usefulness.py +++ /dev/null @@ -1,97 +0,0 @@ -from collections.abc import Callable - -from danswer.configs.chat_configs import DISABLE_LLM_DOC_RELEVANCE -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.llm_chunk_filter import NONUSEFUL_PAT -from danswer.prompts.llm_chunk_filter import SECTION_FILTER_PROMPT -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - -logger = setup_logger() - - -def llm_eval_section( - query: str, - section_content: str, - llm: LLM, - title: str, - metadata: dict[str, str | list[str]], -) -> bool: - def _get_metadata_str(metadata: dict[str, str | list[str]]) -> str: - metadata_str = "\nMetadata:\n" - for key, value in metadata.items(): - value_str = ", ".join(value) if isinstance(value, list) else value - metadata_str += f"{key} - {value_str}\n" - return metadata_str - - def _get_usefulness_messages() -> list[dict[str, str]]: - metadata_str = _get_metadata_str(metadata) if metadata else "" - messages = [ - { - "role": "user", - "content": SECTION_FILTER_PROMPT.format( - title=title.replace("\n", " "), - chunk_text=section_content, - user_query=query, - optional_metadata=metadata_str, - ), - }, - ] - return messages - - def _extract_usefulness(model_output: str) -> bool: - """Default useful if the LLM doesn't match pattern exactly - This is because it's better to trust the (re)ranking if LLM fails""" - if model_output.strip().strip('"').lower() == NONUSEFUL_PAT.lower(): - return False - return True - - messages = _get_usefulness_messages() - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - return _extract_usefulness(model_output) - - -def llm_batch_eval_sections( - query: str, - section_contents: list[str], - llm: LLM, - titles: list[str], - metadata_list: list[dict[str, str | list[str]]], - use_threads: bool = True, -) -> list[bool]: - if DISABLE_LLM_DOC_RELEVANCE: - raise RuntimeError( - "LLM Doc Relevance is globally disabled, " - "this should have been caught upstream." - ) - - if use_threads: - functions_with_args: list[tuple[Callable, tuple]] = [ - (llm_eval_section, (query, section_content, llm, title, metadata)) - for section_content, title, metadata in zip( - section_contents, titles, metadata_list - ) - ] - - logger.debug( - "Running LLM usefulness eval in parallel (following logging may be out of order)" - ) - parallel_results = run_functions_tuples_in_parallel( - functions_with_args, allow_failures=True - ) - - # In case of failure/timeout, don't throw out the section - return [True if item is None else item for item in parallel_results] - - else: - return [ - llm_eval_section(query, section_content, llm, title, metadata) - for section_content, title, metadata in zip( - section_contents, titles, metadata_list - ) - ] diff --git a/backend/danswer/secondary_llm_flows/query_expansion.py b/backend/danswer/secondary_llm_flows/query_expansion.py deleted file mode 100644 index 585af00bdc1..00000000000 --- a/backend/danswer/secondary_llm_flows/query_expansion.py +++ /dev/null @@ -1,166 +0,0 @@ -from collections.abc import Callable - -from danswer.chat.chat_utils import combine_message_chain -from danswer.configs.chat_configs import DISABLE_LLM_QUERY_REPHRASE -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.db.models import ChatMessage -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_default_llms -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.chat_prompts import HISTORY_QUERY_REPHRASE -from danswer.prompts.miscellaneous_prompts import LANGUAGE_REPHRASE_PROMPT -from danswer.utils.logger import setup_logger -from danswer.utils.text_processing import count_punctuation -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - -logger = setup_logger() - - -def llm_multilingual_query_expansion(query: str, language: str) -> str: - def _get_rephrase_messages() -> list[dict[str, str]]: - messages = [ - { - "role": "user", - "content": LANGUAGE_REPHRASE_PROMPT.format( - query=query, target_language=language - ), - }, - ] - - return messages - - try: - _, fast_llm = get_default_llms(timeout=5) - except GenAIDisabledException: - logger.warning( - "Unable to perform multilingual query expansion, Gen AI disabled" - ) - return query - - messages = _get_rephrase_messages() - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(fast_llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - return model_output - - -def multilingual_query_expansion( - query: str, - expansion_languages: list[str], - use_threads: bool = True, -) -> list[str]: - languages = [language.strip() for language in expansion_languages] - if use_threads: - functions_with_args: list[tuple[Callable, tuple]] = [ - (llm_multilingual_query_expansion, (query, language)) - for language in languages - ] - - query_rephrases = run_functions_tuples_in_parallel(functions_with_args) - return query_rephrases - - else: - query_rephrases = [ - llm_multilingual_query_expansion(query, language) for language in languages - ] - return query_rephrases - - -def get_contextual_rephrase_messages( - question: str, - history_str: str, - prompt_template: str = HISTORY_QUERY_REPHRASE, -) -> list[dict[str, str]]: - messages = [ - { - "role": "user", - "content": prompt_template.format( - question=question, chat_history=history_str - ), - }, - ] - - return messages - - -def history_based_query_rephrase( - query: str, - history: list[ChatMessage] | list[PreviousMessage], - llm: LLM, - size_heuristic: int = 200, - punctuation_heuristic: int = 10, - skip_first_rephrase: bool = True, - prompt_template: str = HISTORY_QUERY_REPHRASE, -) -> str: - # Globally disabled, just use the exact user query - if DISABLE_LLM_QUERY_REPHRASE: - return query - - # For some use cases, the first query should be untouched. Later queries must be rephrased - # due to needing context but the first query has no context. - if skip_first_rephrase and not history: - return query - - # If it's a very large query, assume it's a copy paste which we may want to find exactly - # or at least very closely, so don't rephrase it - if len(query) >= size_heuristic: - return query - - # If there is an unusually high number of punctuations, it's probably not natural language - # so don't rephrase it - if count_punctuation(query) >= punctuation_heuristic: - return query - - history_str = combine_message_chain( - messages=history, token_limit=GEN_AI_HISTORY_CUTOFF - ) - - prompt_msgs = get_contextual_rephrase_messages( - question=query, history_str=history_str, prompt_template=prompt_template - ) - - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(prompt_msgs) - rephrased_query = message_to_string(llm.invoke(filled_llm_prompt)) - - logger.debug(f"Rephrased combined query: {rephrased_query}") - - return rephrased_query - - -def thread_based_query_rephrase( - user_query: str, - history_str: str, - llm: LLM | None = None, - size_heuristic: int = 200, - punctuation_heuristic: int = 10, -) -> str: - if not history_str: - return user_query - - if len(user_query) >= size_heuristic: - return user_query - - if count_punctuation(user_query) >= punctuation_heuristic: - return user_query - - if llm is None: - try: - llm, _ = get_default_llms() - except GenAIDisabledException: - # If Generative AI is turned off, just return the original query - return user_query - - prompt_msgs = get_contextual_rephrase_messages( - question=user_query, history_str=history_str - ) - - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(prompt_msgs) - rephrased_query = message_to_string(llm.invoke(filled_llm_prompt)) - - logger.debug(f"Rephrased combined query: {rephrased_query}") - - return rephrased_query diff --git a/backend/danswer/secondary_llm_flows/query_validation.py b/backend/danswer/secondary_llm_flows/query_validation.py deleted file mode 100644 index 2ee428f0090..00000000000 --- a/backend/danswer/secondary_llm_flows/query_validation.py +++ /dev/null @@ -1,136 +0,0 @@ -import re -from collections.abc import Iterator - -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import StreamingError -from danswer.configs.chat_configs import DISABLE_LLM_QUERY_ANSWERABILITY -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_default_llms -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_generator_to_string_generator -from danswer.llm.utils import message_to_string -from danswer.prompts.constants import ANSWERABLE_PAT -from danswer.prompts.constants import THOUGHT_PAT -from danswer.prompts.query_validation import ANSWERABLE_PROMPT -from danswer.server.query_and_chat.models import QueryValidationResponse -from danswer.server.utils import get_json_line -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def get_query_validation_messages(user_query: str) -> list[dict[str, str]]: - messages = [ - { - "role": "user", - "content": ANSWERABLE_PROMPT.format(user_query=user_query), - }, - ] - - return messages - - -def extract_answerability_reasoning(model_raw: str) -> str: - reasoning_match = re.search( - f"{THOUGHT_PAT.upper()}(.*?){ANSWERABLE_PAT.upper()}", model_raw, re.DOTALL - ) - reasoning_text = reasoning_match.group(1).strip() if reasoning_match else "" - return reasoning_text - - -def extract_answerability_bool(model_raw: str) -> bool: - answerable_match = re.search(f"{ANSWERABLE_PAT.upper()}(.+)", model_raw) - answerable_text = answerable_match.group(1).strip() if answerable_match else "" - answerable = True if answerable_text.strip().lower() in ["true", "yes"] else False - return answerable - - -def get_query_answerability( - user_query: str, skip_check: bool = DISABLE_LLM_QUERY_ANSWERABILITY -) -> tuple[str, bool]: - if skip_check: - return "Query Answerability Evaluation feature is turned off", True - - try: - llm, _ = get_default_llms() - except GenAIDisabledException: - return "Generative AI is turned off - skipping check", True - - messages = get_query_validation_messages(user_query) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - - reasoning = extract_answerability_reasoning(model_output) - answerable = extract_answerability_bool(model_output) - - return reasoning, answerable - - -def stream_query_answerability( - user_query: str, skip_check: bool = DISABLE_LLM_QUERY_ANSWERABILITY -) -> Iterator[str]: - if skip_check: - yield get_json_line( - QueryValidationResponse( - reasoning="Query Answerability Evaluation feature is turned off", - answerable=True, - ).model_dump() - ) - return - - try: - llm, _ = get_default_llms() - except GenAIDisabledException: - yield get_json_line( - QueryValidationResponse( - reasoning="Generative AI is turned off - skipping check", - answerable=True, - ).model_dump() - ) - return - messages = get_query_validation_messages(user_query) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - try: - tokens = message_generator_to_string_generator(llm.stream(filled_llm_prompt)) - reasoning_pat_found = False - model_output = "" - hold_answerable = "" - for token in tokens: - model_output = model_output + token - - if ANSWERABLE_PAT.upper() in model_output: - continue - - if not reasoning_pat_found and THOUGHT_PAT.upper() in model_output: - reasoning_pat_found = True - reason_ind = model_output.find(THOUGHT_PAT.upper()) - remaining = model_output[reason_ind + len(THOUGHT_PAT.upper()) :] - if remaining: - yield get_json_line( - DanswerAnswerPiece(answer_piece=remaining).model_dump() - ) - continue - - if reasoning_pat_found: - hold_answerable = hold_answerable + token - if hold_answerable == ANSWERABLE_PAT.upper()[: len(hold_answerable)]: - continue - yield get_json_line( - DanswerAnswerPiece(answer_piece=hold_answerable).model_dump() - ) - hold_answerable = "" - - reasoning = extract_answerability_reasoning(model_output) - answerable = extract_answerability_bool(model_output) - - yield get_json_line( - QueryValidationResponse( - reasoning=reasoning, answerable=answerable - ).model_dump() - ) - except Exception as e: - # exception is logged in the answer_question method, no need to re-log - error = StreamingError(error=str(e)) - yield get_json_line(error.model_dump()) - logger.exception("Failed to validate Query") - return diff --git a/backend/danswer/secondary_llm_flows/source_filter.py b/backend/danswer/secondary_llm_flows/source_filter.py deleted file mode 100644 index 802a14f42fa..00000000000 --- a/backend/danswer/secondary_llm_flows/source_filter.py +++ /dev/null @@ -1,171 +0,0 @@ -import json -import random - -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.connector import fetch_unique_document_sources -from danswer.db.engine import get_sqlalchemy_engine -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.constants import SOURCES_KEY -from danswer.prompts.filter_extration import FILE_SOURCE_WARNING -from danswer.prompts.filter_extration import SOURCE_FILTER_PROMPT -from danswer.prompts.filter_extration import WEB_SOURCE_WARNING -from danswer.utils.logger import setup_logger -from danswer.utils.text_processing import extract_embedded_json - -logger = setup_logger() - - -def strings_to_document_sources(source_strs: list[str]) -> list[DocumentSource]: - sources = [] - for s in source_strs: - try: - sources.append(DocumentSource(s)) - except ValueError: - logger.warning(f"Failed to translate {s} to a DocumentSource") - return sources - - -def _sample_document_sources( - valid_sources: list[DocumentSource], - num_sample: int, - allow_less: bool = True, -) -> list[DocumentSource]: - if len(valid_sources) < num_sample: - if not allow_less: - raise RuntimeError("Not enough sample Document Sources") - return random.sample(valid_sources, len(valid_sources)) - else: - return random.sample(valid_sources, num_sample) - - -def extract_source_filter( - query: str, llm: LLM, db_session: Session -) -> list[DocumentSource] | None: - """Returns a list of valid sources for search or None if no specific sources were detected""" - - def _get_source_filter_messages( - query: str, - valid_sources: list[DocumentSource], - # Seems the LLM performs similarly without examples - show_samples: bool = False, - ) -> list[dict[str, str]]: - sample_json = { - SOURCES_KEY: [ - s.value - for s in _sample_document_sources( - valid_sources=valid_sources, num_sample=2 - ) - ] - } - - web_warning = WEB_SOURCE_WARNING if DocumentSource.WEB in valid_sources else "" - file_warning = ( - FILE_SOURCE_WARNING if DocumentSource.FILE in valid_sources else "" - ) - - msg_1_sources = _sample_document_sources( - valid_sources=valid_sources, num_sample=2 - ) - msg_1_source_str = " and ".join([s.capitalize() for s in msg_1_sources]) - - msg_2_sources = _sample_document_sources( - valid_sources=valid_sources, num_sample=2 - ) - - msg_2_real_source = msg_2_sources[0] - msg_2_fake_source_str = ( - msg_2_sources[1].value.capitalize() - if len(msg_2_sources) > 1 - else "Confluence" - ) - - messages = [ - { - "role": "system", - "content": SOURCE_FILTER_PROMPT.format( - valid_sources=[s.value for s in valid_sources], - web_source_warning=web_warning, - file_source_warning=file_warning, - sample_response=json.dumps(sample_json), - ), - }, - { - "role": "user", - "content": f"What documents in {msg_1_source_str} cover engineer onboarding", - }, - { - "role": "assistant", - "content": json.dumps({SOURCES_KEY: msg_1_sources}), - }, - {"role": "user", "content": "What's the latest on project Corgies?"}, - { - "role": "assistant", - "content": json.dumps({SOURCES_KEY: None}), - }, - { - "role": "user", - "content": f"What information from {msg_2_real_source.value.capitalize()} " - f"mentions {msg_2_fake_source_str}?", - }, - { - "role": "assistant", - "content": json.dumps({SOURCES_KEY: [msg_2_real_source]}), - }, - { - "role": "user", - "content": "What page from Danswer contains debugging instruction on segfault", - }, - { - "role": "assistant", - "content": json.dumps({SOURCES_KEY: None}), - }, - {"role": "user", "content": query}, - ] - - if show_samples: - return messages - - # Only system prompt and latest user query - return [messages[0], messages[-1]] - - def _extract_source_filters_from_llm_out( - model_out: str, - ) -> list[DocumentSource] | None: - try: - sources_dict = extract_embedded_json(model_out) - sources_list = sources_dict.get(SOURCES_KEY) - if not sources_list: - return None - - return strings_to_document_sources(sources_list) - except ValueError: - logger.warning("LLM failed to provide a valid Source Filter output") - return None - - valid_sources = fetch_unique_document_sources(db_session) - if not valid_sources: - return None - - messages = _get_source_filter_messages(query=query, valid_sources=valid_sources) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - return _extract_source_filters_from_llm_out(model_output) - - -if __name__ == "__main__": - from danswer.llm.factory import get_default_llms, get_main_llm_from_tuple - - # Just for testing purposes - with Session(get_sqlalchemy_engine()) as db_session: - while True: - user_input = input("Query to Extract Sources: ") - sources = extract_source_filter( - user_input, get_main_llm_from_tuple(get_default_llms()), db_session - ) - print(sources) diff --git a/backend/danswer/secondary_llm_flows/time_filter.py b/backend/danswer/secondary_llm_flows/time_filter.py deleted file mode 100644 index aef32d7bdb2..00000000000 --- a/backend/danswer/secondary_llm_flows/time_filter.py +++ /dev/null @@ -1,167 +0,0 @@ -import json -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -from dateutil.parser import parse - -from danswer.llm.interfaces import LLM -from danswer.llm.utils import dict_based_prompt_to_langchain_prompt -from danswer.llm.utils import message_to_string -from danswer.prompts.filter_extration import TIME_FILTER_PROMPT -from danswer.prompts.prompt_utils import get_current_llm_day_time -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def best_match_time(time_str: str) -> datetime | None: - preferred_formats = ["%m/%d/%Y", "%m-%d-%Y"] - - for fmt in preferred_formats: - try: - # As we don't know if the user is interacting with the API server from - # the same timezone as the API server, just assume the queries are UTC time - # the few hours offset (if any) shouldn't make any significant difference - dt = datetime.strptime(time_str, fmt) - return dt.replace(tzinfo=timezone.utc) - except ValueError: - continue - - # If the above formats don't match, try using dateutil's parser - try: - dt = parse(time_str) - return ( - dt.astimezone(timezone.utc) - if dt.tzinfo - else dt.replace(tzinfo=timezone.utc) - ) - except ValueError: - return None - - -def extract_time_filter(query: str, llm: LLM) -> tuple[datetime | None, bool]: - """Returns a datetime if a hard time filter should be applied for the given query - Additionally returns a bool, True if more recently updated Documents should be - heavily favored""" - - def _get_time_filter_messages(query: str) -> list[dict[str, str]]: - messages = [ - { - "role": "system", - "content": TIME_FILTER_PROMPT.format( - current_day_time_str=get_current_llm_day_time() - ), - }, - { - "role": "user", - "content": "What documents in Confluence were written in the last two quarters", - }, - { - "role": "assistant", - "content": json.dumps( - { - "filter_type": "hard cutoff", - "filter_value": "quarter", - "value_multiple": 2, - } - ), - }, - {"role": "user", "content": "What's the latest on project Corgies?"}, - { - "role": "assistant", - "content": json.dumps({"filter_type": "favor recent"}), - }, - { - "role": "user", - "content": "Which customer asked about security features in February of 2022?", - }, - { - "role": "assistant", - "content": json.dumps( - {"filter_type": "hard cutoff", "date": "02/01/2022"} - ), - }, - {"role": "user", "content": query}, - ] - return messages - - def _extract_time_filter_from_llm_out( - model_out: str, - ) -> tuple[datetime | None, bool]: - """Returns a datetime for a hard cutoff and a bool for if the""" - try: - model_json = json.loads(model_out, strict=False) - except json.JSONDecodeError: - return None, False - - # If filter type is not present, just assume something has gone wrong - # Potentially model has identified a date and just returned that but - # better to be conservative and not identify the wrong filter. - if "filter_type" not in model_json: - return None, False - - if "hard" in model_json["filter_type"] or "recent" in model_json["filter_type"]: - favor_recent = "recent" in model_json["filter_type"] - - if "date" in model_json: - extracted_time = best_match_time(model_json["date"]) - if extracted_time is not None: - # LLM struggles to understand the concept of not sensitive within a time range - # So if a time is extracted, just go with that alone - return extracted_time, False - - time_diff = None - multiplier = 1.0 - - if "value_multiple" in model_json: - try: - multiplier = float(model_json["value_multiple"]) - except ValueError: - pass - - if "filter_value" in model_json: - filter_value = model_json["filter_value"] - if "day" in filter_value: - time_diff = timedelta(days=multiplier) - elif "week" in filter_value: - time_diff = timedelta(weeks=multiplier) - elif "month" in filter_value: - # Have to just use the average here, too complicated to calculate exact day - # based on current day etc. - time_diff = timedelta(days=multiplier * 30.437) - elif "quarter" in filter_value: - time_diff = timedelta(days=multiplier * 91.25) - elif "year" in filter_value: - time_diff = timedelta(days=multiplier * 365) - - if time_diff is not None: - current = datetime.now(timezone.utc) - # LLM struggles to understand the concept of not sensitive within a time range - # So if a time is extracted, just go with that alone - return current - time_diff, False - - # If we failed to extract a hard filter, just pass back the value of favor recent - return None, favor_recent - - return None, False - - messages = _get_time_filter_messages(query) - filled_llm_prompt = dict_based_prompt_to_langchain_prompt(messages) - model_output = message_to_string(llm.invoke(filled_llm_prompt)) - logger.debug(model_output) - - return _extract_time_filter_from_llm_out(model_output) - - -if __name__ == "__main__": - # Just for testing purposes, too tedious to unit test as it relies on an LLM - from danswer.llm.factory import get_default_llms, get_main_llm_from_tuple - - while True: - user_input = input("Query to Extract Time: ") - cutoff, recency_bias = extract_time_filter( - user_input, get_main_llm_from_tuple(get_default_llms()) - ) - print(f"Time Cutoff: {cutoff}") - print(f"Favor Recent: {recency_bias}") diff --git a/backend/danswer/server/__init__.py b/backend/danswer/server/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/auth_check.py b/backend/danswer/server/auth_check.py deleted file mode 100644 index 12258eba29b..00000000000 --- a/backend/danswer/server/auth_check.py +++ /dev/null @@ -1,109 +0,0 @@ -from typing import cast - -from fastapi import FastAPI -from fastapi.dependencies.models import Dependant -from starlette.routing import BaseRoute - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.configs.app_configs import APP_API_PREFIX -from danswer.server.danswer_api.ingestion import api_key_dep - - -PUBLIC_ENDPOINT_SPECS = [ - # built-in documentation functions - ("/openapi.json", {"GET", "HEAD"}), - ("/docs", {"GET", "HEAD"}), - ("/docs/oauth2-redirect", {"GET", "HEAD"}), - ("/redoc", {"GET", "HEAD"}), - # should always be callable, will just return 401 if not authenticated - ("/me", {"GET"}), - # just returns 200 to validate that the server is up - ("/health", {"GET"}), - # just returns auth type, needs to be accessible before the user is logged - # in to determine what flow to give the user - ("/auth/type", {"GET"}), - # just gets the version of Danswer (e.g. 0.3.11) - ("/version", {"GET"}), - # stuff related to basic auth - ("/auth/register", {"POST"}), - ("/auth/login", {"POST"}), - ("/auth/logout", {"POST"}), - ("/auth/forgot-password", {"POST"}), - ("/auth/reset-password", {"POST"}), - ("/auth/request-verify-token", {"POST"}), - ("/auth/verify", {"POST"}), - ("/users/me", {"GET"}), - ("/users/me", {"PATCH"}), - ("/users/{id}", {"GET"}), - ("/users/{id}", {"PATCH"}), - ("/users/{id}", {"DELETE"}), - # oauth - ("/auth/oauth/authorize", {"GET"}), - ("/auth/oauth/callback", {"GET"}), -] - - -def is_route_in_spec_list( - route: BaseRoute, public_endpoint_specs: list[tuple[str, set[str]]] -) -> bool: - if not hasattr(route, "path") or not hasattr(route, "methods"): - return False - - # try adding the prefix AND not adding the prefix, since some endpoints - # are not prefixed (e.g. /openapi.json) - if (route.path, route.methods) in public_endpoint_specs: - return True - - processed_global_prefix = f"/{APP_API_PREFIX.strip('/')}" if APP_API_PREFIX else "" - if not processed_global_prefix: - return False - - for endpoint_spec in public_endpoint_specs: - base_path, methods = endpoint_spec - prefixed_path = f"{processed_global_prefix}/{base_path.strip('/')}" - - if prefixed_path == route.path and route.methods == methods: - return True - - return False - - -def check_router_auth( - application: FastAPI, - public_endpoint_specs: list[tuple[str, set[str]]] = PUBLIC_ENDPOINT_SPECS, -) -> None: - """Ensures that all endpoints on the passed in application either - (1) have auth enabled OR - (2) are explicitly marked as a public endpoint - """ - for route in application.routes: - # explicitly marked as public - if is_route_in_spec_list(route, public_endpoint_specs): - continue - - # check for auth - found_auth = False - route_dependant_obj = cast( - Dependant | None, route.dependant if hasattr(route, "dependant") else None - ) - if route_dependant_obj: - for dependency in route_dependant_obj.dependencies: - depends_fn = dependency.cache_key[0] - if ( - depends_fn == current_user - or depends_fn == current_admin_user - or depends_fn == current_curator_or_admin_user - or depends_fn == api_key_dep - ): - found_auth = True - break - - if not found_auth: - # uncomment to print out all route(s) that are missing auth - # print(f"(\"{route.path}\", {set(route.methods)}),") - - raise RuntimeError( - f"Did not find current_user or current_admin_user dependency in route - {route}" - ) diff --git a/backend/danswer/server/danswer_api/__init__.py b/backend/danswer/server/danswer_api/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/danswer_api/ingestion.py b/backend/danswer/server/danswer_api/ingestion.py deleted file mode 100644 index cea3ec86575..00000000000 --- a/backend/danswer/server/danswer_api/ingestion.py +++ /dev/null @@ -1,147 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.connectors.models import Document -from danswer.connectors.models import IndexAttemptMetadata -from danswer.db.connector_credential_pair import get_connector_credential_pair_from_id -from danswer.db.document import get_documents_by_cc_pair -from danswer.db.document import get_ingestion_documents -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.document_index.factory import get_default_document_index -from danswer.indexing.embedder import DefaultIndexingEmbedder -from danswer.indexing.indexing_pipeline import build_indexing_pipeline -from danswer.server.danswer_api.models import DocMinimalInfo -from danswer.server.danswer_api.models import IngestionDocument -from danswer.server.danswer_api.models import IngestionResult -from danswer.utils.logger import setup_logger -from ee.danswer.auth.users import api_key_dep - -logger = setup_logger() - -# not using /api to avoid confusion with nginx api path routing -router = APIRouter(prefix="/danswer-api") - - -@router.get("/connector-docs/{cc_pair_id}") -def get_docs_by_connector_credential_pair( - cc_pair_id: int, - _: User | None = Depends(api_key_dep), - db_session: Session = Depends(get_session), -) -> list[DocMinimalInfo]: - db_docs = get_documents_by_cc_pair(cc_pair_id=cc_pair_id, db_session=db_session) - return [ - DocMinimalInfo( - document_id=doc.id, - semantic_id=doc.semantic_id, - link=doc.link, - ) - for doc in db_docs - ] - - -@router.get("/ingestion") -def get_ingestion_docs( - _: User | None = Depends(api_key_dep), - db_session: Session = Depends(get_session), -) -> list[DocMinimalInfo]: - db_docs = get_ingestion_documents(db_session) - return [ - DocMinimalInfo( - document_id=doc.id, - semantic_id=doc.semantic_id, - link=doc.link, - ) - for doc in db_docs - ] - - -@router.post("/ingestion") -def upsert_ingestion_doc( - doc_info: IngestionDocument, - _: User | None = Depends(api_key_dep), - db_session: Session = Depends(get_session), -) -> IngestionResult: - doc_info.document.from_ingestion_api = True - - document = Document.from_base(doc_info.document) - - # TODO once the frontend is updated with this enum, remove this logic - if document.source == DocumentSource.INGESTION_API: - document.source = DocumentSource.FILE - - cc_pair = get_connector_credential_pair_from_id( - cc_pair_id=doc_info.cc_pair_id or 0, db_session=db_session - ) - if cc_pair is None: - raise HTTPException( - status_code=400, detail="Connector-Credential Pair specified does not exist" - ) - - # Need to index for both the primary and secondary index if possible - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - curr_doc_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=None - ) - - search_settings = get_current_search_settings(db_session) - - index_embedding_model = DefaultIndexingEmbedder.from_db_search_settings( - search_settings=search_settings - ) - - indexing_pipeline = build_indexing_pipeline( - embedder=index_embedding_model, - document_index=curr_doc_index, - ignore_time_skip=True, - db_session=db_session, - ) - - new_doc, __chunk_count = indexing_pipeline( - document_batch=[document], - index_attempt_metadata=IndexAttemptMetadata( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - ), - ) - - # If there's a secondary index being built, index the doc but don't use it for return here - if sec_ind_name: - sec_doc_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=None - ) - - sec_search_settings = get_secondary_search_settings(db_session) - - if sec_search_settings is None: - # Should not ever happen - raise RuntimeError( - "Secondary index exists but no search settings configured" - ) - - new_index_embedding_model = DefaultIndexingEmbedder.from_db_search_settings( - search_settings=sec_search_settings - ) - - sec_ind_pipeline = build_indexing_pipeline( - embedder=new_index_embedding_model, - document_index=sec_doc_index, - ignore_time_skip=True, - db_session=db_session, - ) - - sec_ind_pipeline( - document_batch=[document], - index_attempt_metadata=IndexAttemptMetadata( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - ), - ) - - return IngestionResult(document_id=document.id, already_existed=not bool(new_doc)) diff --git a/backend/danswer/server/danswer_api/models.py b/backend/danswer/server/danswer_api/models.py deleted file mode 100644 index 17d6a32c05f..00000000000 --- a/backend/danswer/server/danswer_api/models.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import BaseModel - -from danswer.connectors.models import DocumentBase - - -class IngestionDocument(BaseModel): - document: DocumentBase - cc_pair_id: int | None = None - - -class IngestionResult(BaseModel): - document_id: str - already_existed: bool - - -class DocMinimalInfo(BaseModel): - document_id: str - semantic_id: str - link: str | None = None diff --git a/backend/danswer/server/documents/__init__.py b/backend/danswer/server/documents/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/documents/cc_pair.py b/backend/danswer/server/documents/cc_pair.py deleted file mode 100644 index 69ae9916348..00000000000 --- a/backend/danswer/server/documents/cc_pair.py +++ /dev/null @@ -1,191 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from pydantic import BaseModel -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.background.celery.celery_utils import get_deletion_attempt_snapshot -from danswer.db.connector_credential_pair import add_credential_to_connector -from danswer.db.connector_credential_pair import get_connector_credential_pair_from_id -from danswer.db.connector_credential_pair import remove_credential_from_connector -from danswer.db.connector_credential_pair import ( - update_connector_credential_pair_from_id, -) -from danswer.db.document import get_document_cnts_for_cc_pairs -from danswer.db.engine import get_session -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.index_attempt import cancel_indexing_attempts_for_ccpair -from danswer.db.index_attempt import cancel_indexing_attempts_past_model -from danswer.db.index_attempt import get_index_attempts_for_connector -from danswer.db.models import User -from danswer.db.models import UserRole -from danswer.server.documents.models import CCPairFullInfo -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.server.documents.models import ConnectorCredentialPairMetadata -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -router = APIRouter(prefix="/manage") - - -@router.get("/admin/cc-pair/{cc_pair_id}") -def get_cc_pair_full_info( - cc_pair_id: int, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> CCPairFullInfo: - cc_pair = get_connector_credential_pair_from_id( - cc_pair_id, db_session, user, get_editable=False - ) - if not cc_pair: - raise HTTPException( - status_code=404, detail="CC Pair not found for current user permissions" - ) - editable_cc_pair = get_connector_credential_pair_from_id( - cc_pair_id, db_session, user, get_editable=True - ) - is_editable_for_current_user = editable_cc_pair is not None - - cc_pair_identifier = ConnectorCredentialPairIdentifier( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - ) - - index_attempts = get_index_attempts_for_connector( - db_session, - cc_pair.connector_id, - ) - - document_count_info_list = list( - get_document_cnts_for_cc_pairs( - db_session=db_session, - cc_pair_identifiers=[cc_pair_identifier], - ) - ) - documents_indexed = ( - document_count_info_list[0][-1] if document_count_info_list else 0 - ) - - return CCPairFullInfo.from_models( - cc_pair_model=cc_pair, - index_attempt_models=list(index_attempts), - latest_deletion_attempt=get_deletion_attempt_snapshot( - connector_id=cc_pair.connector_id, - credential_id=cc_pair.credential_id, - db_session=db_session, - ), - num_docs_indexed=documents_indexed, - is_editable_for_current_user=is_editable_for_current_user, - ) - - -class CCStatusUpdateRequest(BaseModel): - status: ConnectorCredentialPairStatus - - -@router.put("/admin/cc-pair/{cc_pair_id}/status") -def update_cc_pair_status( - cc_pair_id: int, - status_update_request: CCStatusUpdateRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> None: - cc_pair = get_connector_credential_pair_from_id( - cc_pair_id=cc_pair_id, - db_session=db_session, - user=user, - get_editable=True, - ) - if not cc_pair: - raise HTTPException( - status_code=400, - detail="Connection not found for current user's permissions", - ) - - if status_update_request.status == ConnectorCredentialPairStatus.PAUSED: - cancel_indexing_attempts_for_ccpair(cc_pair_id, db_session) - - # Just for good measure - cancel_indexing_attempts_past_model(db_session) - - update_connector_credential_pair_from_id( - db_session=db_session, - cc_pair_id=cc_pair_id, - status=status_update_request.status, - ) - - -@router.put("/admin/cc-pair/{cc_pair_id}/name") -def update_cc_pair_name( - cc_pair_id: int, - new_name: str, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse[int]: - cc_pair = get_connector_credential_pair_from_id( - cc_pair_id=cc_pair_id, - db_session=db_session, - user=user, - get_editable=True, - ) - if not cc_pair: - raise HTTPException( - status_code=400, detail="CC Pair not found for current user's permissions" - ) - - try: - cc_pair.name = new_name - db_session.commit() - return StatusResponse( - success=True, message="Name updated successfully", data=cc_pair_id - ) - except IntegrityError: - db_session.rollback() - raise HTTPException(status_code=400, detail="Name must be unique") - - -@router.put("/connector/{connector_id}/credential/{credential_id}") -def associate_credential_to_connector( - connector_id: int, - credential_id: int, - metadata: ConnectorCredentialPairMetadata, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse[int]: - if user and user.role != UserRole.ADMIN and metadata.is_public: - raise HTTPException( - status_code=400, - detail="Public connections cannot be created by non-admin users", - ) - - try: - response = add_credential_to_connector( - db_session=db_session, - user=user, - connector_id=connector_id, - credential_id=credential_id, - cc_pair_name=metadata.name, - is_public=metadata.is_public or True, - groups=metadata.groups, - ) - - return response - except IntegrityError: - raise HTTPException(status_code=400, detail="Name must be unique") - - -@router.delete("/connector/{connector_id}/credential/{credential_id}") -def dissociate_credential_from_connector( - connector_id: int, - credential_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse[int]: - return remove_credential_from_connector( - connector_id, credential_id, user, db_session - ) diff --git a/backend/danswer/server/documents/connector.py b/backend/danswer/server/documents/connector.py deleted file mode 100644 index 8d6b0ffc773..00000000000 --- a/backend/danswer/server/documents/connector.py +++ /dev/null @@ -1,904 +0,0 @@ -import os -import uuid -from typing import cast - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Query -from fastapi import Request -from fastapi import Response -from fastapi import UploadFile -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.background.celery.celery_utils import get_deletion_attempt_snapshot -from danswer.configs.app_configs import ENABLED_CONNECTOR_TYPES -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import FileOrigin -from danswer.connectors.gmail.connector_auth import delete_gmail_service_account_key -from danswer.connectors.gmail.connector_auth import delete_google_app_gmail_cred -from danswer.connectors.gmail.connector_auth import get_gmail_auth_url -from danswer.connectors.gmail.connector_auth import get_gmail_service_account_key -from danswer.connectors.gmail.connector_auth import get_google_app_gmail_cred -from danswer.connectors.gmail.connector_auth import ( - update_gmail_credential_access_tokens, -) -from danswer.connectors.gmail.connector_auth import ( - upsert_gmail_service_account_key, -) -from danswer.connectors.gmail.connector_auth import upsert_google_app_gmail_cred -from danswer.connectors.google_drive.connector_auth import build_service_account_creds -from danswer.connectors.google_drive.connector_auth import delete_google_app_cred -from danswer.connectors.google_drive.connector_auth import delete_service_account_key -from danswer.connectors.google_drive.connector_auth import get_auth_url -from danswer.connectors.google_drive.connector_auth import get_google_app_cred -from danswer.connectors.google_drive.connector_auth import ( - get_google_drive_creds_for_authorized_user, -) -from danswer.connectors.google_drive.connector_auth import get_service_account_key -from danswer.connectors.google_drive.connector_auth import ( - update_credential_access_tokens, -) -from danswer.connectors.google_drive.connector_auth import upsert_google_app_cred -from danswer.connectors.google_drive.connector_auth import upsert_service_account_key -from danswer.connectors.google_drive.connector_auth import verify_csrf -from danswer.connectors.google_drive.constants import DB_CREDENTIALS_DICT_TOKEN_KEY -from danswer.db.connector import create_connector -from danswer.db.connector import delete_connector -from danswer.db.connector import fetch_connector_by_id -from danswer.db.connector import fetch_connectors -from danswer.db.connector import get_connector_credential_ids -from danswer.db.connector import update_connector -from danswer.db.connector_credential_pair import add_credential_to_connector -from danswer.db.connector_credential_pair import get_cc_pair_groups_for_ids -from danswer.db.connector_credential_pair import get_connector_credential_pair -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.credentials import create_credential -from danswer.db.credentials import delete_gmail_service_account_credentials -from danswer.db.credentials import delete_google_drive_service_account_credentials -from danswer.db.credentials import fetch_credential_by_id -from danswer.db.deletion_attempt import check_deletion_attempt_is_allowed -from danswer.db.document import get_document_cnts_for_cc_pairs -from danswer.db.engine import get_session -from danswer.db.index_attempt import create_index_attempt -from danswer.db.index_attempt import get_index_attempts_for_cc_pair -from danswer.db.index_attempt import get_latest_finished_index_attempt_for_cc_pair -from danswer.db.index_attempt import get_latest_index_attempts -from danswer.db.models import User -from danswer.db.models import UserRole -from danswer.db.search_settings import get_current_search_settings -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.file_store.file_store import get_default_file_store -from danswer.server.documents.models import AuthStatus -from danswer.server.documents.models import AuthUrl -from danswer.server.documents.models import ConnectorBase -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.server.documents.models import ConnectorIndexingStatus -from danswer.server.documents.models import ConnectorSnapshot -from danswer.server.documents.models import ConnectorUpdateRequest -from danswer.server.documents.models import CredentialBase -from danswer.server.documents.models import CredentialSnapshot -from danswer.server.documents.models import FileUploadResponse -from danswer.server.documents.models import GDriveCallback -from danswer.server.documents.models import GmailCallback -from danswer.server.documents.models import GoogleAppCredentials -from danswer.server.documents.models import GoogleServiceAccountCredentialRequest -from danswer.server.documents.models import GoogleServiceAccountKey -from danswer.server.documents.models import IndexAttemptSnapshot -from danswer.server.documents.models import ObjectCreationIdResponse -from danswer.server.documents.models import RunConnectorRequest -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -_GMAIL_CREDENTIAL_ID_COOKIE_NAME = "gmail_credential_id" -_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME = "google_drive_credential_id" - - -router = APIRouter(prefix="/manage") - - -"""Admin only API endpoints""" - - -@router.get("/admin/connector/gmail/app-credential") -def check_google_app_gmail_credentials_exist( - _: User = Depends(current_curator_or_admin_user), -) -> dict[str, str]: - try: - return {"client_id": get_google_app_gmail_cred().web.client_id} - except ConfigNotFoundError: - raise HTTPException(status_code=404, detail="Google App Credentials not found") - - -@router.put("/admin/connector/gmail/app-credential") -def upsert_google_app_gmail_credentials( - app_credentials: GoogleAppCredentials, _: User = Depends(current_admin_user) -) -> StatusResponse: - try: - upsert_google_app_gmail_cred(app_credentials) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully saved Google App Credentials" - ) - - -@router.delete("/admin/connector/gmail/app-credential") -def delete_google_app_gmail_credentials( - _: User = Depends(current_admin_user), -) -> StatusResponse: - try: - delete_google_app_gmail_cred() - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully deleted Google App Credentials" - ) - - -@router.get("/admin/connector/google-drive/app-credential") -def check_google_app_credentials_exist( - _: User = Depends(current_curator_or_admin_user), -) -> dict[str, str]: - try: - return {"client_id": get_google_app_cred().web.client_id} - except ConfigNotFoundError: - raise HTTPException(status_code=404, detail="Google App Credentials not found") - - -@router.put("/admin/connector/google-drive/app-credential") -def upsert_google_app_credentials( - app_credentials: GoogleAppCredentials, _: User = Depends(current_admin_user) -) -> StatusResponse: - try: - upsert_google_app_cred(app_credentials) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully saved Google App Credentials" - ) - - -@router.delete("/admin/connector/google-drive/app-credential") -def delete_google_app_credentials( - _: User = Depends(current_admin_user), -) -> StatusResponse: - try: - delete_google_app_cred() - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully deleted Google App Credentials" - ) - - -@router.get("/admin/connector/gmail/service-account-key") -def check_google_service_gmail_account_key_exist( - _: User = Depends(current_curator_or_admin_user), -) -> dict[str, str]: - try: - return {"service_account_email": get_gmail_service_account_key().client_email} - except ConfigNotFoundError: - raise HTTPException( - status_code=404, detail="Google Service Account Key not found" - ) - - -@router.put("/admin/connector/gmail/service-account-key") -def upsert_google_service_gmail_account_key( - service_account_key: GoogleServiceAccountKey, _: User = Depends(current_admin_user) -) -> StatusResponse: - try: - upsert_gmail_service_account_key(service_account_key) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully saved Google Service Account Key" - ) - - -@router.delete("/admin/connector/gmail/service-account-key") -def delete_google_service_gmail_account_key( - _: User = Depends(current_admin_user), -) -> StatusResponse: - try: - delete_gmail_service_account_key() - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully deleted Google Service Account Key" - ) - - -@router.get("/admin/connector/google-drive/service-account-key") -def check_google_service_account_key_exist( - _: User = Depends(current_curator_or_admin_user), -) -> dict[str, str]: - try: - return {"service_account_email": get_service_account_key().client_email} - except ConfigNotFoundError: - raise HTTPException( - status_code=404, detail="Google Service Account Key not found" - ) - - -@router.put("/admin/connector/google-drive/service-account-key") -def upsert_google_service_account_key( - service_account_key: GoogleServiceAccountKey, _: User = Depends(current_admin_user) -) -> StatusResponse: - try: - upsert_service_account_key(service_account_key) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully saved Google Service Account Key" - ) - - -@router.delete("/admin/connector/google-drive/service-account-key") -def delete_google_service_account_key( - _: User = Depends(current_admin_user), -) -> StatusResponse: - try: - delete_service_account_key() - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return StatusResponse( - success=True, message="Successfully deleted Google Service Account Key" - ) - - -@router.put("/admin/connector/google-drive/service-account-credential") -def upsert_service_account_credential( - service_account_credential_request: GoogleServiceAccountCredentialRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> ObjectCreationIdResponse: - """Special API which allows the creation of a credential for a service account. - Combines the input with the saved service account key to create an entry in the - `Credential` table.""" - try: - credential_base = build_service_account_creds( - DocumentSource.GOOGLE_DRIVE, - delegated_user_email=service_account_credential_request.google_drive_delegated_user, - ) - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # first delete all existing service account credentials - delete_google_drive_service_account_credentials(user, db_session) - # `user=None` since this credential is not a personal credential - credential = create_credential( - credential_data=credential_base, user=user, db_session=db_session - ) - return ObjectCreationIdResponse(id=credential.id) - - -@router.put("/admin/connector/gmail/service-account-credential") -def upsert_gmail_service_account_credential( - service_account_credential_request: GoogleServiceAccountCredentialRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> ObjectCreationIdResponse: - """Special API which allows the creation of a credential for a service account. - Combines the input with the saved service account key to create an entry in the - `Credential` table.""" - try: - credential_base = build_service_account_creds( - DocumentSource.GMAIL, - delegated_user_email=service_account_credential_request.gmail_delegated_user, - ) - except ConfigNotFoundError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # first delete all existing service account credentials - delete_gmail_service_account_credentials(user, db_session) - # `user=None` since this credential is not a personal credential - credential = create_credential( - credential_data=credential_base, user=user, db_session=db_session - ) - return ObjectCreationIdResponse(id=credential.id) - - -@router.get("/admin/connector/google-drive/check-auth/{credential_id}") -def check_drive_tokens( - credential_id: int, - user: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> AuthStatus: - db_credentials = fetch_credential_by_id(credential_id, user, db_session) - if ( - not db_credentials - or DB_CREDENTIALS_DICT_TOKEN_KEY not in db_credentials.credential_json - ): - return AuthStatus(authenticated=False) - token_json_str = str(db_credentials.credential_json[DB_CREDENTIALS_DICT_TOKEN_KEY]) - google_drive_creds = get_google_drive_creds_for_authorized_user( - token_json_str=token_json_str - ) - if google_drive_creds is None: - return AuthStatus(authenticated=False) - return AuthStatus(authenticated=True) - - -@router.get("/admin/connector/google-drive/authorize/{credential_id}") -def admin_google_drive_auth( - response: Response, credential_id: str, _: User = Depends(current_admin_user) -) -> AuthUrl: - # set a cookie that we can read in the callback (used for `verify_csrf`) - response.set_cookie( - key=_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME, - value=credential_id, - httponly=True, - max_age=600, - ) - return AuthUrl(auth_url=get_auth_url(credential_id=int(credential_id))) - - -@router.post("/admin/connector/file/upload") -def upload_files( - files: list[UploadFile], - _: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> FileUploadResponse: - for file in files: - if not file.filename: - raise HTTPException(status_code=400, detail="File name cannot be empty") - try: - file_store = get_default_file_store(db_session) - deduped_file_paths = [] - for file in files: - file_path = os.path.join(str(uuid.uuid4()), cast(str, file.filename)) - deduped_file_paths.append(file_path) - file_store.save_file( - file_name=file_path, - content=file.file, - display_name=file.filename, - file_origin=FileOrigin.CONNECTOR, - file_type=file.content_type or "text/plain", - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - return FileUploadResponse(file_paths=deduped_file_paths) - - -@router.get("/admin/connector/indexing-status") -def get_connector_indexing_status( - secondary_index: bool = False, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), - get_editable: bool = Query( - False, description="If true, return editable document sets" - ), -) -> list[ConnectorIndexingStatus]: - indexing_statuses: list[ConnectorIndexingStatus] = [] - - # TODO: make this one query - cc_pairs = get_connector_credential_pairs( - db_session=db_session, - user=user, - get_editable=get_editable, - ) - - cc_pair_identifiers = [ - ConnectorCredentialPairIdentifier( - connector_id=cc_pair.connector_id, credential_id=cc_pair.credential_id - ) - for cc_pair in cc_pairs - ] - - latest_index_attempts = get_latest_index_attempts( - secondary_index=secondary_index, - db_session=db_session, - ) - - cc_pair_to_latest_index_attempt = { - ( - index_attempt.connector_credential_pair.connector_id, - index_attempt.connector_credential_pair.credential_id, - ): index_attempt - for index_attempt in latest_index_attempts - } - - document_count_info = get_document_cnts_for_cc_pairs( - db_session=db_session, - cc_pair_identifiers=cc_pair_identifiers, - ) - cc_pair_to_document_cnt = { - (connector_id, credential_id): cnt - for connector_id, credential_id, cnt in document_count_info - } - - group_cc_pair_relationships = get_cc_pair_groups_for_ids( - db_session=db_session, - cc_pair_ids=[cc_pair.id for cc_pair in cc_pairs], - ) - group_cc_pair_relationships_dict: dict[int, list[int]] = {} - for relationship in group_cc_pair_relationships: - group_cc_pair_relationships_dict.setdefault(relationship.cc_pair_id, []).append( - relationship.user_group_id - ) - - for cc_pair in cc_pairs: - # TODO remove this to enable ingestion API - if cc_pair.name == "DefaultCCPair": - continue - - connector = cc_pair.connector - credential = cc_pair.credential - latest_index_attempt = cc_pair_to_latest_index_attempt.get( - (connector.id, credential.id) - ) - - latest_finished_attempt = get_latest_finished_index_attempt_for_cc_pair( - connector_credential_pair_id=cc_pair.id, - secondary_index=secondary_index, - db_session=db_session, - ) - - indexing_statuses.append( - ConnectorIndexingStatus( - cc_pair_id=cc_pair.id, - name=cc_pair.name, - cc_pair_status=cc_pair.status, - connector=ConnectorSnapshot.from_connector_db_model(connector), - credential=CredentialSnapshot.from_credential_db_model(credential), - public_doc=cc_pair.is_public, - owner=credential.user.email if credential.user else "", - groups=group_cc_pair_relationships_dict.get(cc_pair.id, []), - last_finished_status=( - latest_finished_attempt.status if latest_finished_attempt else None - ), - last_status=( - latest_index_attempt.status if latest_index_attempt else None - ), - last_success=cc_pair.last_successful_index_time, - docs_indexed=cc_pair_to_document_cnt.get( - (connector.id, credential.id), 0 - ), - error_msg=( - latest_index_attempt.error_msg if latest_index_attempt else None - ), - latest_index_attempt=( - IndexAttemptSnapshot.from_index_attempt_db_model( - latest_index_attempt - ) - if latest_index_attempt - else None - ), - deletion_attempt=get_deletion_attempt_snapshot( - connector_id=connector.id, - credential_id=credential.id, - db_session=db_session, - ), - is_deletable=check_deletion_attempt_is_allowed( - connector_credential_pair=cc_pair, - db_session=db_session, - # allow scheduled indexing attempts here, since on deletion request we will cancel them - allow_scheduled=True, - ) - is None, - ) - ) - - return indexing_statuses - - -def _validate_connector_allowed(source: DocumentSource) -> None: - valid_connectors = [ - x for x in ENABLED_CONNECTOR_TYPES.replace("_", "").split(",") if x - ] - if not valid_connectors: - return - for connector_type in valid_connectors: - if source.value.lower().replace("_", "") == connector_type: - return - - raise ValueError( - "This connector type has been disabled by your system admin. " - "Please contact them to get it enabled if you wish to use it." - ) - - -def _check_connector_permissions( - connector_data: ConnectorUpdateRequest, user: User | None -) -> ConnectorBase: - """ - This is not a proper permission check, but this should prevent curators creating bad situations - until a long-term solution is implemented (Replacing CC pairs/Connectors with Connections) - """ - if user and user.role != UserRole.ADMIN: - if connector_data.is_public: - raise HTTPException( - status_code=400, - detail="Public connectors can only be created by admins", - ) - if not connector_data.groups: - raise HTTPException( - status_code=400, - detail="Connectors created by curators must have groups", - ) - return ConnectorBase( - name=connector_data.name, - source=connector_data.source, - input_type=connector_data.input_type, - connector_specific_config=connector_data.connector_specific_config, - refresh_freq=connector_data.refresh_freq, - prune_freq=connector_data.prune_freq, - indexing_start=connector_data.indexing_start, - ) - - -@router.post("/admin/connector") -def create_connector_from_model( - connector_data: ConnectorUpdateRequest, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> ObjectCreationIdResponse: - try: - _validate_connector_allowed(connector_data.source) - connector_base = _check_connector_permissions(connector_data, user) - return create_connector( - db_session=db_session, - connector_data=connector_base, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.post("/admin/connector-with-mock-credential") -def create_connector_with_mock_credential( - connector_data: ConnectorUpdateRequest, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - if user and user.role != UserRole.ADMIN: - if connector_data.is_public: - raise HTTPException( - status_code=401, - detail="User does not have permission to create public credentials", - ) - if not connector_data.groups: - raise HTTPException( - status_code=401, - detail="Curators must specify 1+ groups", - ) - try: - _validate_connector_allowed(connector_data.source) - connector_response = create_connector( - db_session=db_session, connector_data=connector_data - ) - mock_credential = CredentialBase( - credential_json={}, admin_public=True, source=connector_data.source - ) - credential = create_credential( - mock_credential, user=user, db_session=db_session - ) - response = add_credential_to_connector( - db_session=db_session, - user=user, - connector_id=cast(int, connector_response.id), # will aways be an int - credential_id=credential.id, - is_public=connector_data.is_public or False, - cc_pair_name=connector_data.name, - groups=connector_data.groups, - ) - return response - - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.patch("/admin/connector/{connector_id}") -def update_connector_from_model( - connector_id: int, - connector_data: ConnectorUpdateRequest, - user: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> ConnectorSnapshot | StatusResponse[int]: - try: - _validate_connector_allowed(connector_data.source) - connector_base = _check_connector_permissions(connector_data, user) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - updated_connector = update_connector(connector_id, connector_base, db_session) - if updated_connector is None: - raise HTTPException( - status_code=404, detail=f"Connector {connector_id} does not exist" - ) - - return ConnectorSnapshot( - id=updated_connector.id, - name=updated_connector.name, - source=updated_connector.source, - input_type=updated_connector.input_type, - connector_specific_config=updated_connector.connector_specific_config, - refresh_freq=updated_connector.refresh_freq, - prune_freq=updated_connector.prune_freq, - credential_ids=[ - association.credential.id for association in updated_connector.credentials - ], - indexing_start=updated_connector.indexing_start, - time_created=updated_connector.time_created, - time_updated=updated_connector.time_updated, - ) - - -@router.delete("/admin/connector/{connector_id}", response_model=StatusResponse[int]) -def delete_connector_by_id( - connector_id: int, - _: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse[int]: - try: - with db_session.begin(): - return delete_connector( - db_session=db_session, - connector_id=connector_id, - ) - except AssertionError: - raise HTTPException(status_code=400, detail="Connector is not deletable") - - -@router.post("/admin/connector/run-once") -def connector_run_once( - run_info: RunConnectorRequest, - _: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse[list[int]]: - connector_id = run_info.connector_id - specified_credential_ids = run_info.credential_ids - - try: - possible_credential_ids = get_connector_credential_ids( - run_info.connector_id, db_session - ) - except ValueError: - raise HTTPException( - status_code=404, - detail=f"Connector by id {connector_id} does not exist.", - ) - - if not specified_credential_ids: - credential_ids = possible_credential_ids - else: - if set(specified_credential_ids).issubset(set(possible_credential_ids)): - credential_ids = specified_credential_ids - else: - raise HTTPException( - status_code=400, - detail="Not all specified credentials are associated with connector", - ) - - if not credential_ids: - raise HTTPException( - status_code=400, - detail="Connector has no valid credentials, cannot create index attempts.", - ) - - skipped_credentials = [ - credential_id - for credential_id in credential_ids - if get_index_attempts_for_cc_pair( - cc_pair_identifier=ConnectorCredentialPairIdentifier( - connector_id=run_info.connector_id, - credential_id=credential_id, - ), - only_current=True, - disinclude_finished=True, - db_session=db_session, - ) - ] - - search_settings = get_current_search_settings(db_session) - - connector_credential_pairs = [ - get_connector_credential_pair(run_info.connector_id, credential_id, db_session) - for credential_id in credential_ids - if credential_id not in skipped_credentials - ] - - index_attempt_ids = [ - create_index_attempt( - connector_credential_pair_id=connector_credential_pair.id, - search_settings_id=search_settings.id, - from_beginning=run_info.from_beginning, - db_session=db_session, - ) - for connector_credential_pair in connector_credential_pairs - if connector_credential_pair is not None - ] - - if not index_attempt_ids: - raise HTTPException( - status_code=400, - detail="No new indexing attempts created, indexing jobs are queued or running.", - ) - - return StatusResponse( - success=True, - message=f"Successfully created {len(index_attempt_ids)} index attempts", - data=index_attempt_ids, - ) - - -"""Endpoints for basic users""" - - -@router.get("/connector/gmail/authorize/{credential_id}") -def gmail_auth( - response: Response, credential_id: str, _: User = Depends(current_user) -) -> AuthUrl: - # set a cookie that we can read in the callback (used for `verify_csrf`) - response.set_cookie( - key=_GMAIL_CREDENTIAL_ID_COOKIE_NAME, - value=credential_id, - httponly=True, - max_age=600, - ) - return AuthUrl(auth_url=get_gmail_auth_url(int(credential_id))) - - -@router.get("/connector/google-drive/authorize/{credential_id}") -def google_drive_auth( - response: Response, credential_id: str, _: User = Depends(current_user) -) -> AuthUrl: - # set a cookie that we can read in the callback (used for `verify_csrf`) - response.set_cookie( - key=_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME, - value=credential_id, - httponly=True, - max_age=600, - ) - return AuthUrl(auth_url=get_auth_url(int(credential_id))) - - -@router.get("/connector/gmail/callback") -def gmail_callback( - request: Request, - callback: GmailCallback = Depends(), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - credential_id_cookie = request.cookies.get(_GMAIL_CREDENTIAL_ID_COOKIE_NAME) - if credential_id_cookie is None or not credential_id_cookie.isdigit(): - raise HTTPException( - status_code=401, detail="Request did not pass CSRF verification." - ) - credential_id = int(credential_id_cookie) - verify_csrf(credential_id, callback.state) - if ( - update_gmail_credential_access_tokens( - callback.code, credential_id, user, db_session - ) - is None - ): - raise HTTPException( - status_code=500, detail="Unable to fetch Gmail access tokens" - ) - - return StatusResponse(success=True, message="Updated Gmail access tokens") - - -@router.get("/connector/google-drive/callback") -def google_drive_callback( - request: Request, - callback: GDriveCallback = Depends(), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - credential_id_cookie = request.cookies.get(_GOOGLE_DRIVE_CREDENTIAL_ID_COOKIE_NAME) - if credential_id_cookie is None or not credential_id_cookie.isdigit(): - raise HTTPException( - status_code=401, detail="Request did not pass CSRF verification." - ) - credential_id = int(credential_id_cookie) - verify_csrf(credential_id, callback.state) - if ( - update_credential_access_tokens(callback.code, credential_id, user, db_session) - is None - ): - raise HTTPException( - status_code=500, detail="Unable to fetch Google Drive access tokens" - ) - - return StatusResponse(success=True, message="Updated Google Drive access tokens") - - -@router.get("/connector") -def get_connectors( - _: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[ConnectorSnapshot]: - connectors = fetch_connectors(db_session) - return [ - ConnectorSnapshot.from_connector_db_model(connector) - for connector in connectors - # don't include INGESTION_API, as it's not a "real" - # connector like those created by the user - if connector.source != DocumentSource.INGESTION_API - ] - - -@router.get("/connector/{connector_id}") -def get_connector_by_id( - connector_id: int, - _: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ConnectorSnapshot | StatusResponse[int]: - connector = fetch_connector_by_id(connector_id, db_session) - if connector is None: - raise HTTPException( - status_code=404, detail=f"Connector {connector_id} does not exist" - ) - - return ConnectorSnapshot( - id=connector.id, - name=connector.name, - source=connector.source, - indexing_start=connector.indexing_start, - input_type=connector.input_type, - connector_specific_config=connector.connector_specific_config, - refresh_freq=connector.refresh_freq, - prune_freq=connector.prune_freq, - credential_ids=[ - association.credential.id for association in connector.credentials - ], - time_created=connector.time_created, - time_updated=connector.time_updated, - ) - - -class BasicCCPairInfo(BaseModel): - docs_indexed: int - has_successful_run: bool - source: DocumentSource - - -@router.get("/indexing-status") -def get_basic_connector_indexing_status( - _: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[BasicCCPairInfo]: - cc_pairs = get_connector_credential_pairs(db_session) - cc_pair_identifiers = [ - ConnectorCredentialPairIdentifier( - connector_id=cc_pair.connector_id, credential_id=cc_pair.credential_id - ) - for cc_pair in cc_pairs - ] - document_count_info = get_document_cnts_for_cc_pairs( - db_session=db_session, - cc_pair_identifiers=cc_pair_identifiers, - ) - cc_pair_to_document_cnt = { - (connector_id, credential_id): cnt - for connector_id, credential_id, cnt in document_count_info - } - return [ - BasicCCPairInfo( - docs_indexed=cc_pair_to_document_cnt.get( - (cc_pair.connector_id, cc_pair.credential_id) - ) - or 0, - has_successful_run=cc_pair.last_successful_index_time is not None, - source=cc_pair.connector.source, - ) - for cc_pair in cc_pairs - if cc_pair.connector.source != DocumentSource.INGESTION_API - ] diff --git a/backend/danswer/server/documents/credential.py b/backend/danswer/server/documents/credential.py deleted file mode 100644 index ba30b65f2f9..00000000000 --- a/backend/danswer/server/documents/credential.py +++ /dev/null @@ -1,256 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Query -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.auth.users import validate_curator_request -from danswer.db.credentials import alter_credential -from danswer.db.credentials import create_credential -from danswer.db.credentials import CREDENTIAL_PERMISSIONS_TO_IGNORE -from danswer.db.credentials import delete_credential -from danswer.db.credentials import fetch_credential_by_id -from danswer.db.credentials import fetch_credentials -from danswer.db.credentials import fetch_credentials_by_source -from danswer.db.credentials import swap_credentials_connector -from danswer.db.credentials import update_credential -from danswer.db.engine import get_session -from danswer.db.models import DocumentSource -from danswer.db.models import User -from danswer.db.models import UserRole -from danswer.server.documents.models import CredentialBase -from danswer.server.documents.models import CredentialDataUpdateRequest -from danswer.server.documents.models import CredentialSnapshot -from danswer.server.documents.models import CredentialSwapRequest -from danswer.server.documents.models import ObjectCreationIdResponse -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -router = APIRouter(prefix="/manage") - - -def _ignore_credential_permissions(source: DocumentSource) -> bool: - return source in CREDENTIAL_PERMISSIONS_TO_IGNORE - - -"""Admin-only endpoints""" - - -@router.get("/admin/credential") -def list_credentials_admin( - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> list[CredentialSnapshot]: - """Lists all public credentials""" - credentials = fetch_credentials( - db_session=db_session, - user=user, - get_editable=False, - ) - return [ - CredentialSnapshot.from_credential_db_model(credential) - for credential in credentials - ] - - -@router.get("/admin/similar-credentials/{source_type}") -def get_cc_source_full_info( - source_type: DocumentSource, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), - get_editable: bool = Query( - False, description="If true, return editable credentials" - ), -) -> list[CredentialSnapshot]: - credentials = fetch_credentials_by_source( - db_session=db_session, - user=user, - document_source=source_type, - get_editable=get_editable, - ) - return [ - CredentialSnapshot.from_credential_db_model(credential) - for credential in credentials - ] - - -@router.get("/credentials/{id}") -def list_credentials_by_id( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[CredentialSnapshot]: - credentials = fetch_credentials(db_session=db_session, user=user) - return [ - CredentialSnapshot.from_credential_db_model(credential) - for credential in credentials - ] - - -@router.delete("/admin/credential/{credential_id}") -def delete_credential_by_id_admin( - credential_id: int, - _: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - """Same as the user endpoint, but can delete any credential (not just the user's own)""" - delete_credential(db_session=db_session, credential_id=credential_id, user=None) - return StatusResponse( - success=True, message="Credential deleted successfully", data=credential_id - ) - - -@router.put("/admin/credentials/swap") -def swap_credentials_for_connector( - credential_swap_req: CredentialSwapRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - connector_credential_pair = swap_credentials_connector( - new_credential_id=credential_swap_req.new_credential_id, - connector_id=credential_swap_req.connector_id, - db_session=db_session, - user=user, - ) - - return StatusResponse( - success=True, - message="Credential swapped successfully", - data=connector_credential_pair.id, - ) - - -@router.post("/credential") -def create_credential_from_model( - credential_info: CredentialBase, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> ObjectCreationIdResponse: - if ( - user - and user.role != UserRole.ADMIN - and not _ignore_credential_permissions(credential_info.source) - ): - validate_curator_request( - groups=credential_info.groups, - is_public=credential_info.curator_public, - ) - - credential = create_credential(credential_info, user, db_session) - return ObjectCreationIdResponse( - id=credential.id, - credential=CredentialSnapshot.from_credential_db_model(credential), - ) - - -"""Endpoints for all""" - - -@router.get("/credential") -def list_credentials( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[CredentialSnapshot]: - credentials = fetch_credentials(db_session=db_session, user=user) - return [ - CredentialSnapshot.from_credential_db_model(credential) - for credential in credentials - ] - - -@router.get("/credential/{credential_id}") -def get_credential_by_id( - credential_id: int, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> CredentialSnapshot | StatusResponse[int]: - credential = fetch_credential_by_id(credential_id, user, db_session) - if credential is None: - raise HTTPException( - status_code=401, - detail=f"Credential {credential_id} does not exist or does not belong to user", - ) - - return CredentialSnapshot.from_credential_db_model(credential) - - -@router.put("/admin/credentials/{credential_id}") -def update_credential_data( - credential_id: int, - credential_update: CredentialDataUpdateRequest, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> CredentialBase: - credential = alter_credential(credential_id, credential_update, user, db_session) - - if credential is None: - raise HTTPException( - status_code=401, - detail=f"Credential {credential_id} does not exist or does not belong to user", - ) - - return CredentialSnapshot.from_credential_db_model(credential) - - -@router.patch("/credential/{credential_id}") -def update_credential_from_model( - credential_id: int, - credential_data: CredentialBase, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> CredentialSnapshot | StatusResponse[int]: - updated_credential = update_credential( - credential_id, credential_data, user, db_session - ) - if updated_credential is None: - raise HTTPException( - status_code=401, - detail=f"Credential {credential_id} does not exist or does not belong to user", - ) - - return CredentialSnapshot( - source=updated_credential.source, - id=updated_credential.id, - credential_json=updated_credential.credential_json, - user_id=updated_credential.user_id, - name=updated_credential.name, - admin_public=updated_credential.admin_public, - time_created=updated_credential.time_created, - time_updated=updated_credential.time_updated, - curator_public=updated_credential.curator_public, - ) - - -@router.delete("/credential/{credential_id}") -def delete_credential_by_id( - credential_id: int, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - delete_credential( - credential_id, - user, - db_session, - ) - - return StatusResponse( - success=True, message="Credential deleted successfully", data=credential_id - ) - - -@router.delete("/credential/force/{credential_id}") -def force_delete_credential_by_id( - credential_id: int, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - delete_credential(credential_id, user, db_session, True) - - return StatusResponse( - success=True, message="Credential deleted successfully", data=credential_id - ) diff --git a/backend/danswer/server/documents/document.py b/backend/danswer/server/documents/document.py deleted file mode 100644 index bf8cdbcef44..00000000000 --- a/backend/danswer/server/documents/document.py +++ /dev/null @@ -1,109 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Query -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.search_settings import get_current_search_settings -from danswer.document_index.factory import get_default_document_index -from danswer.document_index.interfaces import VespaChunkRequest -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.prompts.prompt_utils import build_doc_context_str -from danswer.search.models import IndexFilters -from danswer.search.preprocessing.access_filters import build_access_filters_for_user -from danswer.server.documents.models import ChunkInfo -from danswer.server.documents.models import DocumentInfo - - -router = APIRouter(prefix="/document") - - -# Have to use a query parameter as FastAPI is interpreting the URL type document_ids -# as a different path -@router.get("/document-size-info") -def get_document_info( - document_id: str = Query(...), - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> DocumentInfo: - search_settings = get_current_search_settings(db_session) - - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, secondary_index_name=None - ) - - user_acl_filters = build_access_filters_for_user(user, db_session) - inference_chunks = document_index.id_based_retrieval( - chunk_requests=[VespaChunkRequest(document_id=document_id)], - filters=IndexFilters(access_control_list=user_acl_filters), - ) - - if not inference_chunks: - raise HTTPException(status_code=404, detail="Document not found") - - contents = [chunk.content for chunk in inference_chunks] - - combined_contents = "\n".join(contents) - - # get actual document context used for LLM - first_chunk = inference_chunks[0] - tokenizer_encode = get_tokenizer( - provider_type=search_settings.provider_type, - model_name=search_settings.model_name, - ).encode - full_context_str = build_doc_context_str( - semantic_identifier=first_chunk.semantic_identifier, - source_type=first_chunk.source_type, - content=combined_contents, - metadata_dict=first_chunk.metadata, - updated_at=first_chunk.updated_at, - ind=0, - ) - - return DocumentInfo( - num_chunks=len(inference_chunks), - num_tokens=len(tokenizer_encode(full_context_str)), - ) - - -@router.get("/chunk-info") -def get_chunk_info( - document_id: str = Query(...), - chunk_id: int = Query(...), - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChunkInfo: - search_settings = get_current_search_settings(db_session) - - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, secondary_index_name=None - ) - - user_acl_filters = build_access_filters_for_user(user, db_session) - chunk_request = VespaChunkRequest( - document_id=document_id, - min_chunk_ind=chunk_id, - max_chunk_ind=chunk_id, - ) - inference_chunks = document_index.id_based_retrieval( - chunk_requests=[chunk_request], - filters=IndexFilters(access_control_list=user_acl_filters), - batch_retrieval=True, - ) - - if not inference_chunks: - raise HTTPException(status_code=404, detail="Chunk not found") - - chunk_content = inference_chunks[0].content - - tokenizer_encode = get_tokenizer( - provider_type=search_settings.provider_type, - model_name=search_settings.model_name, - ).encode - - return ChunkInfo( - content=chunk_content, num_tokens=len(tokenizer_encode(chunk_content)) - ) diff --git a/backend/danswer/server/documents/indexing.py b/backend/danswer/server/documents/indexing.py deleted file mode 100644 index 4d5081c3fe7..00000000000 --- a/backend/danswer/server/documents/indexing.py +++ /dev/null @@ -1,23 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.db.engine import get_session -from danswer.db.index_attempt import ( - get_index_attempt_errors, -) -from danswer.db.models import User -from danswer.server.documents.models import IndexAttemptError - -router = APIRouter(prefix="/manage") - - -@router.get("/admin/indexing-errors/{index_attempt_id}") -def get_indexing_errors( - index_attempt_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[IndexAttemptError]: - indexing_errors = get_index_attempt_errors(index_attempt_id, db_session) - return [IndexAttemptError.from_db_model(e) for e in indexing_errors] diff --git a/backend/danswer/server/documents/models.py b/backend/danswer/server/documents/models.py deleted file mode 100644 index ba011afc196..00000000000 --- a/backend/danswer/server/documents/models.py +++ /dev/null @@ -1,338 +0,0 @@ -from datetime import datetime -from typing import Any -from uuid import UUID - -from pydantic import BaseModel -from pydantic import Field - -from danswer.configs.app_configs import MASK_CREDENTIAL_PREFIX -from danswer.configs.constants import DocumentSource -from danswer.connectors.models import DocumentErrorSummary -from danswer.connectors.models import InputType -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.models import Connector -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Credential -from danswer.db.models import IndexAttempt -from danswer.db.models import IndexAttemptError as DbIndexAttemptError -from danswer.db.models import IndexingStatus -from danswer.db.models import TaskStatus -from danswer.server.utils import mask_credential_dict - - -class DocumentInfo(BaseModel): - num_chunks: int - num_tokens: int - - -class ChunkInfo(BaseModel): - content: str - num_tokens: int - - -class DeletionAttemptSnapshot(BaseModel): - connector_id: int - credential_id: int - status: TaskStatus - - -class ConnectorBase(BaseModel): - name: str - source: DocumentSource - input_type: InputType - connector_specific_config: dict[str, Any] - # In seconds, None for one time index with no refresh - refresh_freq: int | None = None - prune_freq: int | None = None - indexing_start: datetime | None = None - - -class ConnectorUpdateRequest(ConnectorBase): - is_public: bool | None = None - groups: list[int] = Field(default_factory=list) - - -class ConnectorSnapshot(ConnectorBase): - id: int - credential_ids: list[int] - time_created: datetime - time_updated: datetime - source: DocumentSource - - @classmethod - def from_connector_db_model(cls, connector: Connector) -> "ConnectorSnapshot": - return ConnectorSnapshot( - id=connector.id, - name=connector.name, - source=connector.source, - input_type=connector.input_type, - connector_specific_config=connector.connector_specific_config, - refresh_freq=connector.refresh_freq, - prune_freq=connector.prune_freq, - credential_ids=[ - association.credential.id for association in connector.credentials - ], - indexing_start=connector.indexing_start, - time_created=connector.time_created, - time_updated=connector.time_updated, - ) - - -class CredentialSwapRequest(BaseModel): - new_credential_id: int - connector_id: int - - -class CredentialDataUpdateRequest(BaseModel): - name: str - credential_json: dict[str, Any] - - -class CredentialBase(BaseModel): - credential_json: dict[str, Any] - # if `true`, then all Admins will have access to the credential - admin_public: bool - source: DocumentSource - name: str | None = None - curator_public: bool = False - groups: list[int] = Field(default_factory=list) - - -class CredentialSnapshot(CredentialBase): - id: int - user_id: UUID | None - time_created: datetime - time_updated: datetime - name: str | None - source: DocumentSource - credential_json: dict[str, Any] - admin_public: bool - curator_public: bool - - @classmethod - def from_credential_db_model(cls, credential: Credential) -> "CredentialSnapshot": - return CredentialSnapshot( - id=credential.id, - credential_json=( - mask_credential_dict(credential.credential_json) - if MASK_CREDENTIAL_PREFIX and credential.credential_json - else credential.credential_json - ), - user_id=credential.user_id, - admin_public=credential.admin_public, - time_created=credential.time_created, - time_updated=credential.time_updated, - source=credential.source or DocumentSource.NOT_APPLICABLE, - name=credential.name, - curator_public=credential.curator_public, - ) - - -class IndexAttemptSnapshot(BaseModel): - id: int - status: IndexingStatus | None - new_docs_indexed: int # only includes completely new docs - total_docs_indexed: int # includes docs that are updated - docs_removed_from_index: int - error_msg: str | None - error_count: int - full_exception_trace: str | None - time_started: str | None - time_updated: str - - @classmethod - def from_index_attempt_db_model( - cls, index_attempt: IndexAttempt - ) -> "IndexAttemptSnapshot": - return IndexAttemptSnapshot( - id=index_attempt.id, - status=index_attempt.status, - new_docs_indexed=index_attempt.new_docs_indexed or 0, - total_docs_indexed=index_attempt.total_docs_indexed or 0, - docs_removed_from_index=index_attempt.docs_removed_from_index or 0, - error_msg=index_attempt.error_msg, - error_count=len(index_attempt.error_rows), - full_exception_trace=index_attempt.full_exception_trace, - time_started=( - index_attempt.time_started.isoformat() - if index_attempt.time_started - else None - ), - time_updated=index_attempt.time_updated.isoformat(), - ) - - -class IndexAttemptError(BaseModel): - id: int - index_attempt_id: int | None - batch_number: int | None - doc_summaries: list[DocumentErrorSummary] - error_msg: str | None - traceback: str | None - time_created: str - - @classmethod - def from_db_model(cls, error: DbIndexAttemptError) -> "IndexAttemptError": - doc_summaries = [ - DocumentErrorSummary.from_dict(summary) for summary in error.doc_summaries - ] - return IndexAttemptError( - id=error.id, - index_attempt_id=error.index_attempt_id, - batch_number=error.batch, - doc_summaries=doc_summaries, - error_msg=error.error_msg, - traceback=error.traceback, - time_created=error.time_created.isoformat(), - ) - - -class CCPairFullInfo(BaseModel): - id: int - name: str - status: ConnectorCredentialPairStatus - num_docs_indexed: int - connector: ConnectorSnapshot - credential: CredentialSnapshot - index_attempts: list[IndexAttemptSnapshot] - latest_deletion_attempt: DeletionAttemptSnapshot | None - is_public: bool - is_editable_for_current_user: bool - - @classmethod - def from_models( - cls, - cc_pair_model: ConnectorCredentialPair, - index_attempt_models: list[IndexAttempt], - latest_deletion_attempt: DeletionAttemptSnapshot | None, - num_docs_indexed: int, # not ideal, but this must be computed separately - is_editable_for_current_user: bool, - ) -> "CCPairFullInfo": - return cls( - id=cc_pair_model.id, - name=cc_pair_model.name, - status=cc_pair_model.status, - num_docs_indexed=num_docs_indexed, - connector=ConnectorSnapshot.from_connector_db_model( - cc_pair_model.connector - ), - credential=CredentialSnapshot.from_credential_db_model( - cc_pair_model.credential - ), - index_attempts=[ - IndexAttemptSnapshot.from_index_attempt_db_model(index_attempt_model) - for index_attempt_model in index_attempt_models - ], - latest_deletion_attempt=latest_deletion_attempt, - is_public=cc_pair_model.is_public, - is_editable_for_current_user=is_editable_for_current_user, - ) - - -class ConnectorIndexingStatus(BaseModel): - """Represents the latest indexing status of a connector""" - - cc_pair_id: int - name: str | None - cc_pair_status: ConnectorCredentialPairStatus - connector: ConnectorSnapshot - credential: CredentialSnapshot - owner: str - groups: list[int] - public_doc: bool - last_finished_status: IndexingStatus | None - last_status: IndexingStatus | None - last_success: datetime | None - docs_indexed: int - error_msg: str | None - latest_index_attempt: IndexAttemptSnapshot | None - deletion_attempt: DeletionAttemptSnapshot | None - is_deletable: bool - - -class ConnectorCredentialPairIdentifier(BaseModel): - connector_id: int - credential_id: int - - -class ConnectorCredentialPairMetadata(BaseModel): - name: str | None = None - is_public: bool | None = None - groups: list[int] = Field(default_factory=list) - - -class ConnectorCredentialPairDescriptor(BaseModel): - id: int - name: str | None = None - connector: ConnectorSnapshot - credential: CredentialSnapshot - - -class RunConnectorRequest(BaseModel): - connector_id: int - credential_ids: list[int] | None = None - from_beginning: bool = False - - -"""Connectors Models""" - - -class GoogleAppWebCredentials(BaseModel): - client_id: str - project_id: str - auth_uri: str - token_uri: str - auth_provider_x509_cert_url: str - client_secret: str - redirect_uris: list[str] - javascript_origins: list[str] - - -class GoogleAppCredentials(BaseModel): - web: GoogleAppWebCredentials - - -class GoogleServiceAccountKey(BaseModel): - type: str - project_id: str - private_key_id: str - private_key: str - client_email: str - client_id: str - auth_uri: str - token_uri: str - auth_provider_x509_cert_url: str - client_x509_cert_url: str - universe_domain: str - - -class GoogleServiceAccountCredentialRequest(BaseModel): - google_drive_delegated_user: str | None # email of user to impersonate - gmail_delegated_user: str | None # email of user to impersonate - - -class FileUploadResponse(BaseModel): - file_paths: list[str] - - -class ObjectCreationIdResponse(BaseModel): - id: int | str - credential: CredentialSnapshot | None = None - - -class AuthStatus(BaseModel): - authenticated: bool - - -class AuthUrl(BaseModel): - auth_url: str - - -class GmailCallback(BaseModel): - state: str - code: str - - -class GDriveCallback(BaseModel): - state: str - code: str diff --git a/backend/danswer/server/features/__init__.py b/backend/danswer/server/features/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/document_set/__init__.py b/backend/danswer/server/features/document_set/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/document_set/api.py b/backend/danswer/server/features/document_set/api.py deleted file mode 100644 index d1eff082891..00000000000 --- a/backend/danswer/server/features/document_set/api.py +++ /dev/null @@ -1,115 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Query -from sqlalchemy.orm import Session - -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.auth.users import validate_curator_request -from danswer.db.document_set import check_document_sets_are_public -from danswer.db.document_set import fetch_all_document_sets_for_user -from danswer.db.document_set import insert_document_set -from danswer.db.document_set import mark_document_set_as_to_be_deleted -from danswer.db.document_set import update_document_set -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.models import UserRole -from danswer.server.features.document_set.models import CheckDocSetPublicRequest -from danswer.server.features.document_set.models import CheckDocSetPublicResponse -from danswer.server.features.document_set.models import DocumentSet -from danswer.server.features.document_set.models import DocumentSetCreationRequest -from danswer.server.features.document_set.models import DocumentSetUpdateRequest - - -router = APIRouter(prefix="/manage") - - -@router.post("/admin/document-set") -def create_document_set( - document_set_creation_request: DocumentSetCreationRequest, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> int: - if user and user.role != UserRole.ADMIN: - validate_curator_request( - groups=document_set_creation_request.groups, - is_public=document_set_creation_request.is_public, - ) - try: - document_set_db_model, _ = insert_document_set( - document_set_creation_request=document_set_creation_request, - user_id=user.id if user else None, - db_session=db_session, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - return document_set_db_model.id - - -@router.patch("/admin/document-set") -def patch_document_set( - document_set_update_request: DocumentSetUpdateRequest, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> None: - if user and user.role != UserRole.ADMIN: - validate_curator_request( - groups=document_set_update_request.groups, - is_public=document_set_update_request.is_public, - ) - try: - update_document_set( - document_set_update_request=document_set_update_request, - db_session=db_session, - user=user, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.delete("/admin/document-set/{document_set_id}") -def delete_document_set( - document_set_id: int, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> None: - try: - mark_document_set_as_to_be_deleted( - db_session=db_session, - document_set_id=document_set_id, - user=user, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -"""Endpoints for non-admins""" - - -@router.get("/document-set") -def list_document_sets_for_user( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), - get_editable: bool = Query( - False, description="If true, return editable document sets" - ), -) -> list[DocumentSet]: - return [ - DocumentSet.from_model(ds) - for ds in fetch_all_document_sets_for_user( - db_session=db_session, user=user, get_editable=get_editable - ) - ] - - -@router.get("/document-set-public") -def document_set_public( - check_public_request: CheckDocSetPublicRequest, - _: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> CheckDocSetPublicResponse: - is_public = check_document_sets_are_public( - document_set_ids=check_public_request.document_set_ids, db_session=db_session - ) - return CheckDocSetPublicResponse(is_public=is_public) diff --git a/backend/danswer/server/features/document_set/models.py b/backend/danswer/server/features/document_set/models.py deleted file mode 100644 index 55f3376545f..00000000000 --- a/backend/danswer/server/features/document_set/models.py +++ /dev/null @@ -1,85 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel -from pydantic import Field - -from danswer.db.models import DocumentSet as DocumentSetDBModel -from danswer.server.documents.models import ConnectorCredentialPairDescriptor -from danswer.server.documents.models import ConnectorSnapshot -from danswer.server.documents.models import CredentialSnapshot - - -class DocumentSetCreationRequest(BaseModel): - name: str - description: str - cc_pair_ids: list[int] - is_public: bool - # For Private Document Sets, who should be able to access these - users: list[UUID] = Field(default_factory=list) - groups: list[int] = Field(default_factory=list) - - -class DocumentSetUpdateRequest(BaseModel): - id: int - description: str - cc_pair_ids: list[int] - is_public: bool - # For Private Document Sets, who should be able to access these - users: list[UUID] - groups: list[int] - - -class CheckDocSetPublicRequest(BaseModel): - """Note that this does not mean that the Document Set itself is to be viewable by everyone - Rather, this refers to the CC-Pairs in the Document Set, and if every CC-Pair is public - """ - - document_set_ids: list[int] - - -class CheckDocSetPublicResponse(BaseModel): - is_public: bool - - -class DocumentSet(BaseModel): - id: int - name: str - description: str - cc_pair_descriptors: list[ConnectorCredentialPairDescriptor] - is_up_to_date: bool - contains_non_public: bool - is_public: bool - # For Private Document Sets, who should be able to access these - users: list[UUID] - groups: list[int] - - @classmethod - def from_model(cls, document_set_model: DocumentSetDBModel) -> "DocumentSet": - return cls( - id=document_set_model.id, - name=document_set_model.name, - description=document_set_model.description, - contains_non_public=any( - [ - not cc_pair.is_public - for cc_pair in document_set_model.connector_credential_pairs - ] - ), - cc_pair_descriptors=[ - ConnectorCredentialPairDescriptor( - id=cc_pair.id, - name=cc_pair.name, - connector=ConnectorSnapshot.from_connector_db_model( - cc_pair.connector - ), - credential=CredentialSnapshot.from_credential_db_model( - cc_pair.credential - ), - ) - for cc_pair in document_set_model.connector_credential_pairs - ], - is_up_to_date=document_set_model.is_up_to_date, - is_public=document_set_model.is_public, - users=[user.id for user in document_set_model.users], - groups=[group.id for group in document_set_model.groups], - ) diff --git a/backend/danswer/server/features/folder/__init__.py b/backend/danswer/server/features/folder/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/folder/api.py b/backend/danswer/server/features/folder/api.py deleted file mode 100644 index 000207370d6..00000000000 --- a/backend/danswer/server/features/folder/api.py +++ /dev/null @@ -1,176 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Path -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.db.chat import get_chat_session_by_id -from danswer.db.engine import get_session -from danswer.db.folder import add_chat_to_folder -from danswer.db.folder import create_folder -from danswer.db.folder import delete_folder -from danswer.db.folder import get_user_folders -from danswer.db.folder import remove_chat_from_folder -from danswer.db.folder import rename_folder -from danswer.db.folder import update_folder_display_priority -from danswer.db.models import User -from danswer.server.features.folder.models import DeleteFolderOptions -from danswer.server.features.folder.models import FolderChatSessionRequest -from danswer.server.features.folder.models import FolderCreationRequest -from danswer.server.features.folder.models import FolderResponse -from danswer.server.features.folder.models import FolderUpdateRequest -from danswer.server.features.folder.models import GetUserFoldersResponse -from danswer.server.models import DisplayPriorityRequest -from danswer.server.query_and_chat.models import ChatSessionDetails - -router = APIRouter(prefix="/folder") - - -@router.get("") -def get_folders( - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> GetUserFoldersResponse: - folders = get_user_folders( - user_id=user.id if user else None, - db_session=db_session, - ) - folders.sort() - return GetUserFoldersResponse( - folders=[ - FolderResponse( - folder_id=folder.id, - folder_name=folder.name, - display_priority=folder.display_priority, - chat_sessions=[ - ChatSessionDetails( - id=chat_session.id, - name=chat_session.description, - persona_id=chat_session.persona_id, - time_created=chat_session.time_created.isoformat(), - shared_status=chat_session.shared_status, - folder_id=folder.id, - ) - for chat_session in folder.chat_sessions - if not chat_session.deleted - ], - ) - for folder in folders - ] - ) - - -@router.put("/reorder") -def put_folder_display_priority( - display_priority_request: DisplayPriorityRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - update_folder_display_priority( - user_id=user.id if user else None, - display_priority_map=display_priority_request.display_priority_map, - db_session=db_session, - ) - - -@router.post("") -def create_folder_endpoint( - request: FolderCreationRequest, - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> int: - return create_folder( - user_id=user.id if user else None, - folder_name=request.folder_name, - db_session=db_session, - ) - - -@router.patch("/{folder_id}") -def patch_folder_endpoint( - request: FolderUpdateRequest, - folder_id: int = Path(..., description="The ID of the folder to rename"), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - try: - rename_folder( - user_id=user.id if user else None, - folder_id=folder_id, - folder_name=request.folder_name, - db_session=db_session, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.delete("/{folder_id}") -def delete_folder_endpoint( - request: DeleteFolderOptions, - folder_id: int = Path(..., description="The ID of the folder to delete"), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user else None - try: - delete_folder( - user_id=user_id, - folder_id=folder_id, - including_chats=request.including_chats, - db_session=db_session, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.post("/{folder_id}/add-chat-session") -def add_chat_to_folder_endpoint( - request: FolderChatSessionRequest, - folder_id: int = Path( - ..., description="The ID of the folder in which to add the chat session" - ), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user else None - try: - chat_session = get_chat_session_by_id( - chat_session_id=request.chat_session_id, - user_id=user_id, - db_session=db_session, - ) - add_chat_to_folder( - user_id=user.id if user else None, - folder_id=folder_id, - chat_session=chat_session, - db_session=db_session, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@router.post("/{folder_id}/remove-chat-session/") -def remove_chat_from_folder_endpoint( - request: FolderChatSessionRequest, - folder_id: int = Path( - ..., description="The ID of the folder from which to remove the chat session" - ), - user: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user else None - try: - chat_session = get_chat_session_by_id( - chat_session_id=request.chat_session_id, - user_id=user_id, - db_session=db_session, - ) - remove_chat_from_folder( - user_id=user_id, - folder_id=folder_id, - chat_session=chat_session, - db_session=db_session, - ) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/danswer/server/features/folder/models.py b/backend/danswer/server/features/folder/models.py deleted file mode 100644 index d7b161414a3..00000000000 --- a/backend/danswer/server/features/folder/models.py +++ /dev/null @@ -1,30 +0,0 @@ -from pydantic import BaseModel - -from danswer.server.query_and_chat.models import ChatSessionDetails - - -class FolderResponse(BaseModel): - folder_id: int - folder_name: str | None - display_priority: int - chat_sessions: list[ChatSessionDetails] - - -class GetUserFoldersResponse(BaseModel): - folders: list[FolderResponse] - - -class FolderCreationRequest(BaseModel): - folder_name: str | None = None - - -class FolderUpdateRequest(BaseModel): - folder_name: str | None = None - - -class FolderChatSessionRequest(BaseModel): - chat_session_id: int - - -class DeleteFolderOptions(BaseModel): - including_chats: bool = False diff --git a/backend/danswer/server/features/input_prompt/__init__.py b/backend/danswer/server/features/input_prompt/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/input_prompt/api.py b/backend/danswer/server/features/input_prompt/api.py deleted file mode 100644 index 58eecd0093d..00000000000 --- a/backend/danswer/server/features/input_prompt/api.py +++ /dev/null @@ -1,134 +0,0 @@ -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.auth.users import current_user -from danswer.db.engine import get_session -from danswer.db.input_prompt import fetch_input_prompt_by_id -from danswer.db.input_prompt import fetch_input_prompts_by_user -from danswer.db.input_prompt import fetch_public_input_prompts -from danswer.db.input_prompt import insert_input_prompt -from danswer.db.input_prompt import remove_input_prompt -from danswer.db.input_prompt import remove_public_input_prompt -from danswer.db.input_prompt import update_input_prompt -from danswer.db.models import User -from danswer.server.features.input_prompt.models import CreateInputPromptRequest -from danswer.server.features.input_prompt.models import InputPromptSnapshot -from danswer.server.features.input_prompt.models import UpdateInputPromptRequest -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -basic_router = APIRouter(prefix="/input_prompt") -admin_router = APIRouter(prefix="/admin/input_prompt") - - -@basic_router.get("") -def list_input_prompts( - user: User | None = Depends(current_user), - include_public: bool = False, - db_session: Session = Depends(get_session), -) -> list[InputPromptSnapshot]: - user_prompts = fetch_input_prompts_by_user( - user_id=user.id if user is not None else None, - db_session=db_session, - include_public=include_public, - ) - return [InputPromptSnapshot.from_model(prompt) for prompt in user_prompts] - - -@basic_router.get("/{input_prompt_id}") -def get_input_prompt( - input_prompt_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> InputPromptSnapshot: - input_prompt = fetch_input_prompt_by_id( - id=input_prompt_id, - user_id=user.id if user is not None else None, - db_session=db_session, - ) - return InputPromptSnapshot.from_model(input_prompt=input_prompt) - - -@basic_router.post("") -def create_input_prompt( - create_input_prompt_request: CreateInputPromptRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> InputPromptSnapshot: - input_prompt = insert_input_prompt( - prompt=create_input_prompt_request.prompt, - content=create_input_prompt_request.content, - is_public=create_input_prompt_request.is_public, - user=user, - db_session=db_session, - ) - return InputPromptSnapshot.from_model(input_prompt) - - -@basic_router.patch("/{input_prompt_id}") -def patch_input_prompt( - input_prompt_id: int, - update_input_prompt_request: UpdateInputPromptRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> InputPromptSnapshot: - try: - updated_input_prompt = update_input_prompt( - user=user, - input_prompt_id=input_prompt_id, - prompt=update_input_prompt_request.prompt, - content=update_input_prompt_request.content, - active=update_input_prompt_request.active, - db_session=db_session, - ) - except ValueError as e: - error_msg = "Error occurred while updated input prompt" - logger.warn(f"{error_msg}. Stack trace: {e}") - raise HTTPException(status_code=404, detail=error_msg) - - return InputPromptSnapshot.from_model(updated_input_prompt) - - -@basic_router.delete("/{input_prompt_id}") -def delete_input_prompt( - input_prompt_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - try: - remove_input_prompt(user, input_prompt_id, db_session) - - except ValueError as e: - error_msg = "Error occurred while deleting input prompt" - logger.warn(f"{error_msg}. Stack trace: {e}") - raise HTTPException(status_code=404, detail=error_msg) - - -@admin_router.delete("/{input_prompt_id}") -def delete_public_input_prompt( - input_prompt_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - try: - remove_public_input_prompt(input_prompt_id, db_session) - - except ValueError as e: - error_msg = "Error occurred while deleting input prompt" - logger.warn(f"{error_msg}. Stack trace: {e}") - raise HTTPException(status_code=404, detail=error_msg) - - -@admin_router.get("") -def list_public_input_prompts( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[InputPromptSnapshot]: - user_prompts = fetch_public_input_prompts( - db_session=db_session, - ) - return [InputPromptSnapshot.from_model(prompt) for prompt in user_prompts] diff --git a/backend/danswer/server/features/input_prompt/models.py b/backend/danswer/server/features/input_prompt/models.py deleted file mode 100644 index 21ce2ba4e5b..00000000000 --- a/backend/danswer/server/features/input_prompt/models.py +++ /dev/null @@ -1,47 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - -from danswer.db.models import InputPrompt -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class CreateInputPromptRequest(BaseModel): - prompt: str - content: str - is_public: bool - - -class UpdateInputPromptRequest(BaseModel): - prompt: str - content: str - active: bool - - -class InputPromptResponse(BaseModel): - id: int - prompt: str - content: str - active: bool - - -class InputPromptSnapshot(BaseModel): - id: int - prompt: str - content: str - active: bool - user_id: UUID | None - is_public: bool - - @classmethod - def from_model(cls, input_prompt: InputPrompt) -> "InputPromptSnapshot": - return InputPromptSnapshot( - id=input_prompt.id, - prompt=input_prompt.prompt, - content=input_prompt.content, - active=input_prompt.active, - user_id=input_prompt.user_id, - is_public=input_prompt.is_public, - ) diff --git a/backend/danswer/server/features/persona/__init__.py b/backend/danswer/server/features/persona/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/persona/api.py b/backend/danswer/server/features/persona/api.py deleted file mode 100644 index 72b16d719ff..00000000000 --- a/backend/danswer/server/features/persona/api.py +++ /dev/null @@ -1,236 +0,0 @@ -import uuid -from uuid import UUID - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import Query -from fastapi import UploadFile -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.configs.constants import FileOrigin -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.persona import create_update_persona -from danswer.db.persona import get_persona_by_id -from danswer.db.persona import get_personas -from danswer.db.persona import mark_persona_as_deleted -from danswer.db.persona import mark_persona_as_not_deleted -from danswer.db.persona import update_all_personas_display_priority -from danswer.db.persona import update_persona_shared_users -from danswer.db.persona import update_persona_visibility -from danswer.file_store.file_store import get_default_file_store -from danswer.file_store.models import ChatFileType -from danswer.llm.answering.prompts.utils import build_dummy_prompt -from danswer.server.features.persona.models import CreatePersonaRequest -from danswer.server.features.persona.models import PersonaSnapshot -from danswer.server.features.persona.models import PromptTemplateResponse -from danswer.server.models import DisplayPriorityRequest -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -admin_router = APIRouter(prefix="/admin/persona") -basic_router = APIRouter(prefix="/persona") - - -class IsVisibleRequest(BaseModel): - is_visible: bool - - -@admin_router.patch("/{persona_id}/visible") -def patch_persona_visibility( - persona_id: int, - is_visible_request: IsVisibleRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> None: - update_persona_visibility( - persona_id=persona_id, - is_visible=is_visible_request.is_visible, - db_session=db_session, - user=user, - ) - - -@admin_router.put("/display-priority") -def patch_persona_display_priority( - display_priority_request: DisplayPriorityRequest, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - update_all_personas_display_priority( - display_priority_map=display_priority_request.display_priority_map, - db_session=db_session, - ) - - -@admin_router.get("") -def list_personas_admin( - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), - include_deleted: bool = False, - get_editable: bool = Query(False, description="If true, return editable personas"), -) -> list[PersonaSnapshot]: - return [ - PersonaSnapshot.from_model(persona) - for persona in get_personas( - db_session=db_session, - user=user, - get_editable=get_editable, - include_deleted=include_deleted, - joinedload_all=True, - ) - ] - - -@admin_router.patch("/{persona_id}/undelete") -def undelete_persona( - persona_id: int, - user: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - mark_persona_as_not_deleted( - persona_id=persona_id, - user=user, - db_session=db_session, - ) - - -# used for assistat profile pictures -@admin_router.post("/upload-image") -def upload_file( - file: UploadFile, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> dict[str, str]: - file_store = get_default_file_store(db_session) - file_type = ChatFileType.IMAGE - file_id = str(uuid.uuid4()) - file_store.save_file( - file_name=file_id, - content=file.file, - display_name=file.filename, - file_origin=FileOrigin.CHAT_UPLOAD, - file_type=file.content_type or file_type.value, - ) - return {"file_id": file_id} - - -"""Endpoints for all""" - - -@basic_router.post("") -def create_persona( - create_persona_request: CreatePersonaRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PersonaSnapshot: - return create_update_persona( - persona_id=None, - create_persona_request=create_persona_request, - user=user, - db_session=db_session, - ) - - -@basic_router.patch("/{persona_id}") -def update_persona( - persona_id: int, - update_persona_request: CreatePersonaRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PersonaSnapshot: - return create_update_persona( - persona_id=persona_id, - create_persona_request=update_persona_request, - user=user, - db_session=db_session, - ) - - -class PersonaShareRequest(BaseModel): - user_ids: list[UUID] - - -@basic_router.patch("/{persona_id}/share") -def share_persona( - persona_id: int, - persona_share_request: PersonaShareRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - update_persona_shared_users( - persona_id=persona_id, - user_ids=persona_share_request.user_ids, - user=user, - db_session=db_session, - ) - - -@basic_router.delete("/{persona_id}") -def delete_persona( - persona_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - mark_persona_as_deleted( - persona_id=persona_id, - user=user, - db_session=db_session, - ) - - -@basic_router.get("") -def list_personas( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), - include_deleted: bool = False, -) -> list[PersonaSnapshot]: - return [ - PersonaSnapshot.from_model(persona) - for persona in get_personas( - user=user, - include_deleted=include_deleted, - db_session=db_session, - get_editable=False, - joinedload_all=True, - ) - ] - - -@basic_router.get("/{persona_id}") -def get_persona( - persona_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PersonaSnapshot: - return PersonaSnapshot.from_model( - get_persona_by_id( - persona_id=persona_id, - user=user, - db_session=db_session, - is_for_edit=False, - ) - ) - - -@basic_router.get("/utils/prompt-explorer") -def build_final_template_prompt( - system_prompt: str, - task_prompt: str, - retrieval_disabled: bool = False, - _: User | None = Depends(current_user), -) -> PromptTemplateResponse: - return PromptTemplateResponse( - final_prompt_template=build_dummy_prompt( - system_prompt=system_prompt, - task_prompt=task_prompt, - retrieval_disabled=retrieval_disabled, - ) - ) diff --git a/backend/danswer/server/features/persona/models.py b/backend/danswer/server/features/persona/models.py deleted file mode 100644 index 777ef2037ee..00000000000 --- a/backend/danswer/server/features/persona/models.py +++ /dev/null @@ -1,115 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel -from pydantic import Field - -from danswer.db.models import Persona -from danswer.db.models import StarterMessage -from danswer.search.enums import RecencyBiasSetting -from danswer.server.features.document_set.models import DocumentSet -from danswer.server.features.prompt.models import PromptSnapshot -from danswer.server.features.tool.api import ToolSnapshot -from danswer.server.models import MinimalUserSnapshot -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -class CreatePersonaRequest(BaseModel): - name: str - description: str - num_chunks: float - llm_relevance_filter: bool - is_public: bool - llm_filter_extraction: bool - recency_bias: RecencyBiasSetting - prompt_ids: list[int] - document_set_ids: list[int] - # e.g. ID of SearchTool or ImageGenerationTool or - tool_ids: list[int] - llm_model_provider_override: str | None = None - llm_model_version_override: str | None = None - starter_messages: list[StarterMessage] | None = None - # For Private Personas, who should be able to access these - users: list[UUID] = Field(default_factory=list) - groups: list[int] = Field(default_factory=list) - icon_color: str | None = None - icon_shape: int | None = None - uploaded_image_id: str | None = None # New field for uploaded image - remove_image: bool | None = None - - -class PersonaSnapshot(BaseModel): - id: int - owner: MinimalUserSnapshot | None - name: str - is_visible: bool - is_public: bool - display_priority: int | None - description: str - num_chunks: float | None - llm_relevance_filter: bool - llm_filter_extraction: bool - llm_model_provider_override: str | None - llm_model_version_override: str | None - starter_messages: list[StarterMessage] | None - default_persona: bool - prompts: list[PromptSnapshot] - tools: list[ToolSnapshot] - document_sets: list[DocumentSet] - users: list[MinimalUserSnapshot] - groups: list[int] - icon_color: str | None - icon_shape: int | None - uploaded_image_id: str | None = None - - @classmethod - def from_model( - cls, persona: Persona, allow_deleted: bool = False - ) -> "PersonaSnapshot": - if persona.deleted: - error_msg = f"Persona with ID {persona.id} has been deleted" - if not allow_deleted: - raise ValueError(error_msg) - else: - logger.warning(error_msg) - - return PersonaSnapshot( - id=persona.id, - name=persona.name, - owner=( - MinimalUserSnapshot(id=persona.user.id, email=persona.user.email) - if persona.user - else None - ), - is_visible=persona.is_visible, - is_public=persona.is_public, - display_priority=persona.display_priority, - description=persona.description, - num_chunks=persona.num_chunks, - llm_relevance_filter=persona.llm_relevance_filter, - llm_filter_extraction=persona.llm_filter_extraction, - llm_model_provider_override=persona.llm_model_provider_override, - llm_model_version_override=persona.llm_model_version_override, - starter_messages=persona.starter_messages, - default_persona=persona.default_persona, - prompts=[PromptSnapshot.from_model(prompt) for prompt in persona.prompts], - tools=[ToolSnapshot.from_model(tool) for tool in persona.tools], - document_sets=[ - DocumentSet.from_model(document_set_model) - for document_set_model in persona.document_sets - ], - users=[ - MinimalUserSnapshot(id=user.id, email=user.email) - for user in persona.users - ], - groups=[user_group.id for user_group in persona.groups], - icon_color=persona.icon_color, - icon_shape=persona.icon_shape, - uploaded_image_id=persona.uploaded_image_id, - ) - - -class PromptTemplateResponse(BaseModel): - final_prompt_template: str diff --git a/backend/danswer/server/features/prompt/__init__.py b/backend/danswer/server/features/prompt/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/features/prompt/api.py b/backend/danswer/server/features/prompt/api.py deleted file mode 100644 index aebcbb8434d..00000000000 --- a/backend/danswer/server/features/prompt/api.py +++ /dev/null @@ -1,152 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy.orm import Session -from starlette import status - -from danswer.auth.users import current_user -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.persona import get_personas_by_ids -from danswer.db.persona import get_prompt_by_id -from danswer.db.persona import get_prompts -from danswer.db.persona import mark_prompt_as_deleted -from danswer.db.persona import upsert_prompt -from danswer.server.features.prompt.models import CreatePromptRequest -from danswer.server.features.prompt.models import PromptSnapshot -from danswer.utils.logger import setup_logger - - -# Note: As prompts are fairly innocuous/harmless, there are no protections -# to prevent users from messing with prompts of other users. - -logger = setup_logger() - -basic_router = APIRouter(prefix="/prompt") - - -def create_update_prompt( - prompt_id: int | None, - create_prompt_request: CreatePromptRequest, - user: User | None, - db_session: Session, -) -> PromptSnapshot: - personas = ( - list( - get_personas_by_ids( - persona_ids=create_prompt_request.persona_ids, - db_session=db_session, - ) - ) - if create_prompt_request.persona_ids - else [] - ) - - prompt = upsert_prompt( - prompt_id=prompt_id, - user=user, - name=create_prompt_request.name, - description=create_prompt_request.description, - system_prompt=create_prompt_request.system_prompt, - task_prompt=create_prompt_request.task_prompt, - include_citations=create_prompt_request.include_citations, - datetime_aware=create_prompt_request.datetime_aware, - personas=personas, - db_session=db_session, - ) - return PromptSnapshot.from_model(prompt) - - -@basic_router.post("") -def create_prompt( - create_prompt_request: CreatePromptRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PromptSnapshot: - try: - return create_update_prompt( - prompt_id=None, - create_prompt_request=create_prompt_request, - user=user, - db_session=db_session, - ) - except ValueError as ve: - logger.exception(ve) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to create Persona, invalid info.", - ) - except Exception as e: - logger.exception(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred. Please try again later.", - ) - - -@basic_router.patch("/{prompt_id}") -def update_prompt( - prompt_id: int, - update_prompt_request: CreatePromptRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PromptSnapshot: - try: - return create_update_prompt( - prompt_id=prompt_id, - create_prompt_request=update_prompt_request, - user=user, - db_session=db_session, - ) - except ValueError as ve: - logger.exception(ve) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to create Persona, invalid info.", - ) - except Exception as e: - logger.exception(e) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="An unexpected error occurred. Please try again later.", - ) - - -@basic_router.delete("/{prompt_id}") -def delete_prompt( - prompt_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - mark_prompt_as_deleted( - prompt_id=prompt_id, - user=user, - db_session=db_session, - ) - - -@basic_router.get("") -def list_prompts( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[PromptSnapshot]: - user_id = user.id if user is not None else None - return [ - PromptSnapshot.from_model(prompt) - for prompt in get_prompts(user_id=user_id, db_session=db_session) - ] - - -@basic_router.get("/{prompt_id}") -def get_prompt( - prompt_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> PromptSnapshot: - return PromptSnapshot.from_model( - get_prompt_by_id( - prompt_id=prompt_id, - user=user, - db_session=db_session, - ) - ) diff --git a/backend/danswer/server/features/prompt/models.py b/backend/danswer/server/features/prompt/models.py deleted file mode 100644 index 1cc9452f435..00000000000 --- a/backend/danswer/server/features/prompt/models.py +++ /dev/null @@ -1,41 +0,0 @@ -from pydantic import BaseModel - -from danswer.db.models import Prompt - - -class CreatePromptRequest(BaseModel): - name: str - description: str - system_prompt: str - task_prompt: str - include_citations: bool = False - datetime_aware: bool = False - persona_ids: list[int] | None = None - - -class PromptSnapshot(BaseModel): - id: int - name: str - description: str - system_prompt: str - task_prompt: str - include_citations: bool - datetime_aware: bool - default_prompt: bool - # Not including persona info, not needed - - @classmethod - def from_model(cls, prompt: Prompt) -> "PromptSnapshot": - if prompt.deleted: - raise ValueError("Prompt has been deleted") - - return PromptSnapshot( - id=prompt.id, - name=prompt.name, - description=prompt.description, - system_prompt=prompt.system_prompt, - task_prompt=prompt.task_prompt, - include_citations=prompt.include_citations, - datetime_aware=prompt.datetime_aware, - default_prompt=prompt.default_prompt, - ) diff --git a/backend/danswer/server/features/tool/api.py b/backend/danswer/server/features/tool/api.py deleted file mode 100644 index 9635a276507..00000000000 --- a/backend/danswer/server/features/tool/api.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import Any - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_user -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.tools import create_tool -from danswer.db.tools import delete_tool -from danswer.db.tools import get_tool_by_id -from danswer.db.tools import get_tools -from danswer.db.tools import update_tool -from danswer.server.features.tool.models import ToolSnapshot -from danswer.tools.custom.openapi_parsing import MethodSpec -from danswer.tools.custom.openapi_parsing import openapi_to_method_specs -from danswer.tools.custom.openapi_parsing import validate_openapi_schema - -router = APIRouter(prefix="/tool") -admin_router = APIRouter(prefix="/admin/tool") - - -class CustomToolCreate(BaseModel): - name: str - description: str | None = None - definition: dict[str, Any] - - -class CustomToolUpdate(BaseModel): - name: str | None = None - description: str | None = None - definition: dict[str, Any] | None = None - - -def _validate_tool_definition(definition: dict[str, Any]) -> None: - try: - validate_openapi_schema(definition) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@admin_router.post("/custom") -def create_custom_tool( - tool_data: CustomToolCreate, - db_session: Session = Depends(get_session), - user: User | None = Depends(current_admin_user), -) -> ToolSnapshot: - _validate_tool_definition(tool_data.definition) - tool = create_tool( - name=tool_data.name, - description=tool_data.description, - openapi_schema=tool_data.definition, - user_id=user.id if user else None, - db_session=db_session, - ) - return ToolSnapshot.from_model(tool) - - -@admin_router.put("/custom/{tool_id}") -def update_custom_tool( - tool_id: int, - tool_data: CustomToolUpdate, - db_session: Session = Depends(get_session), - user: User | None = Depends(current_admin_user), -) -> ToolSnapshot: - if tool_data.definition: - _validate_tool_definition(tool_data.definition) - updated_tool = update_tool( - tool_id=tool_id, - name=tool_data.name, - description=tool_data.description, - openapi_schema=tool_data.definition, - user_id=user.id if user else None, - db_session=db_session, - ) - return ToolSnapshot.from_model(updated_tool) - - -@admin_router.delete("/custom/{tool_id}") -def delete_custom_tool( - tool_id: int, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_admin_user), -) -> None: - try: - delete_tool(tool_id, db_session) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - # handles case where tool is still used by an Assistant - raise HTTPException(status_code=400, detail=str(e)) - - -class ValidateToolRequest(BaseModel): - definition: dict[str, Any] - - -class ValidateToolResponse(BaseModel): - methods: list[MethodSpec] - - -@admin_router.post("/custom/validate") -def validate_tool( - tool_data: ValidateToolRequest, - _: User | None = Depends(current_admin_user), -) -> ValidateToolResponse: - _validate_tool_definition(tool_data.definition) - method_specs = openapi_to_method_specs(tool_data.definition) - return ValidateToolResponse(methods=method_specs) - - -"""Endpoints for all""" - - -@router.get("/{tool_id}") -def get_custom_tool( - tool_id: int, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> ToolSnapshot: - try: - tool = get_tool_by_id(tool_id, db_session) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - return ToolSnapshot.from_model(tool) - - -@router.get("") -def list_tools( - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> list[ToolSnapshot]: - tools = get_tools(db_session) - return [ToolSnapshot.from_model(tool) for tool in tools] diff --git a/backend/danswer/server/features/tool/models.py b/backend/danswer/server/features/tool/models.py deleted file mode 100644 index 0c1da965d4f..00000000000 --- a/backend/danswer/server/features/tool/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - -from danswer.db.models import Tool - - -class ToolSnapshot(BaseModel): - id: int - name: str - description: str - definition: dict[str, Any] | None - display_name: str - in_code_tool_id: str | None - - @classmethod - def from_model(cls, tool: Tool) -> "ToolSnapshot": - return cls( - id=tool.id, - name=tool.name, - description=tool.description, - definition=tool.openapi_schema, - display_name=tool.display_name or tool.name, - in_code_tool_id=tool.in_code_tool_id, - ) diff --git a/backend/danswer/server/gpts/api.py b/backend/danswer/server/gpts/api.py deleted file mode 100644 index 1bebc3bfc1e..00000000000 --- a/backend/danswer/server/gpts/api.py +++ /dev/null @@ -1,98 +0,0 @@ -import math -from datetime import datetime - -from fastapi import APIRouter -from fastapi import Depends -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.llm.factory import get_default_llms -from danswer.search.models import SearchRequest -from danswer.search.pipeline import SearchPipeline -from danswer.server.danswer_api.ingestion import api_key_dep -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -router = APIRouter(prefix="/gpts") - - -def time_ago(dt: datetime) -> str: - # Calculate time difference - now = datetime.now() - diff = now - dt - - # Convert difference to minutes - minutes = diff.total_seconds() / 60 - - # Determine the appropriate unit and calculate the age - if minutes < 60: - return f"~{math.floor(minutes)} minutes" - hours = minutes / 60 - if hours < 24: - return f"~{math.floor(hours)} hours" - days = hours / 24 - if days < 7: - return f"~{math.floor(days)} days" - weeks = days / 7 - if weeks < 4: - return f"~{math.floor(weeks)} weeks" - months = days / 30 - return f"~{math.floor(months)} months" - - -class GptSearchRequest(BaseModel): - query: str - - -class GptDocChunk(BaseModel): - title: str - content: str - source_type: str - link: str - metadata: dict[str, str | list[str]] - document_age: str - - -class GptSearchResponse(BaseModel): - matching_document_chunks: list[GptDocChunk] - - -@router.post("/gpt-document-search") -def gpt_search( - search_request: GptSearchRequest, - _: User | None = Depends(api_key_dep), - db_session: Session = Depends(get_session), -) -> GptSearchResponse: - llm, fast_llm = get_default_llms() - top_sections = SearchPipeline( - search_request=SearchRequest( - query=search_request.query, - ), - user=None, - llm=llm, - fast_llm=fast_llm, - db_session=db_session, - ).reranked_sections - - return GptSearchResponse( - matching_document_chunks=[ - GptDocChunk( - title=section.center_chunk.semantic_identifier, - content=section.center_chunk.content, - source_type=section.center_chunk.source_type, - link=section.center_chunk.source_links.get(0, "") - if section.center_chunk.source_links - else "", - metadata=section.center_chunk.metadata, - document_age=time_ago(section.center_chunk.updated_at) - if section.center_chunk.updated_at - else "Unknown", - ) - for section in top_sections - ], - ) diff --git a/backend/danswer/server/manage/__init__.py b/backend/danswer/server/manage/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/manage/administrative.py b/backend/danswer/server/manage/administrative.py deleted file mode 100644 index 0ac90ba8d11..00000000000 --- a/backend/danswer/server/manage/administrative.py +++ /dev/null @@ -1,205 +0,0 @@ -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import cast - -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.auth.users import current_curator_or_admin_user -from danswer.configs.app_configs import GENERATIVE_MODEL_ACCESS_CHECK_FREQ -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import KV_GEN_AI_KEY_CHECK_TIME -from danswer.db.connector_credential_pair import get_connector_credential_pair -from danswer.db.connector_credential_pair import ( - update_connector_credential_pair_from_id, -) -from danswer.db.deletion_attempt import check_deletion_attempt_is_allowed -from danswer.db.engine import get_session -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.feedback import fetch_docs_ranked_by_boost -from danswer.db.feedback import update_document_boost -from danswer.db.feedback import update_document_hidden -from danswer.db.index_attempt import cancel_indexing_attempts_for_ccpair -from danswer.db.models import User -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.document_index.factory import get_default_document_index -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.file_store.file_store import get_default_file_store -from danswer.llm.factory import get_default_llms -from danswer.llm.utils import test_llm -from danswer.server.documents.models import ConnectorCredentialPairIdentifier -from danswer.server.manage.models import BoostDoc -from danswer.server.manage.models import BoostUpdateRequest -from danswer.server.manage.models import HiddenUpdateRequest -from danswer.server.models import StatusResponse -from danswer.utils.logger import setup_logger - -router = APIRouter(prefix="/manage") -logger = setup_logger() - -"""Admin only API endpoints""" - - -@router.get("/admin/doc-boosts") -def get_most_boosted_docs( - ascending: bool, - limit: int, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> list[BoostDoc]: - boost_docs = fetch_docs_ranked_by_boost( - ascending=ascending, - limit=limit, - db_session=db_session, - user=user, - ) - return [ - BoostDoc( - document_id=doc.id, - semantic_id=doc.semantic_id, - # source=doc.source, - link=doc.link or "", - boost=doc.boost, - hidden=doc.hidden, - ) - for doc in boost_docs - ] - - -@router.post("/admin/doc-boosts") -def document_boost_update( - boost_update: BoostUpdateRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - update_document_boost( - db_session=db_session, - document_id=boost_update.document_id, - boost=boost_update.boost, - document_index=document_index, - user=user, - ) - return StatusResponse(success=True, message="Updated document boost") - - -@router.post("/admin/doc-hidden") -def document_hidden_update( - hidden_update: HiddenUpdateRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> StatusResponse: - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - update_document_hidden( - db_session=db_session, - document_id=hidden_update.document_id, - hidden=hidden_update.hidden, - document_index=document_index, - user=user, - ) - return StatusResponse(success=True, message="Updated document boost") - - -@router.get("/admin/genai-api-key/validate") -def validate_existing_genai_api_key( - _: User = Depends(current_admin_user), -) -> None: - # Only validate every so often - kv_store = get_dynamic_config_store() - curr_time = datetime.now(tz=timezone.utc) - try: - last_check = datetime.fromtimestamp( - cast(float, kv_store.load(KV_GEN_AI_KEY_CHECK_TIME)), tz=timezone.utc - ) - check_freq_sec = timedelta(seconds=GENERATIVE_MODEL_ACCESS_CHECK_FREQ) - if curr_time - last_check < check_freq_sec: - return - except ConfigNotFoundError: - # First time checking the key, nothing unusual - pass - - try: - llm, __ = get_default_llms(timeout=10) - except ValueError: - raise HTTPException(status_code=404, detail="LLM not setup") - - error = test_llm(llm) - if error: - raise HTTPException(status_code=400, detail=error) - - # Mark check as successful - curr_time = datetime.now(tz=timezone.utc) - kv_store.store(KV_GEN_AI_KEY_CHECK_TIME, curr_time.timestamp()) - - -@router.post("/admin/deletion-attempt") -def create_deletion_attempt_for_connector_id( - connector_credential_pair_identifier: ConnectorCredentialPairIdentifier, - user: User = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> None: - from danswer.background.celery.celery_app import ( - cleanup_connector_credential_pair_task, - ) - - connector_id = connector_credential_pair_identifier.connector_id - credential_id = connector_credential_pair_identifier.credential_id - - cc_pair = get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - user=user, - get_editable=True, - ) - if cc_pair is None: - raise HTTPException( - status_code=404, - detail=f"Connector with ID '{connector_id}' and credential ID " - f"'{credential_id}' does not exist. Has it already been deleted?", - ) - - # Cancel any scheduled indexing attempts - cancel_indexing_attempts_for_ccpair( - cc_pair_id=cc_pair.id, db_session=db_session, include_secondary_index=True - ) - - # Check if the deletion attempt should be allowed - deletion_attempt_disallowed_reason = check_deletion_attempt_is_allowed( - connector_credential_pair=cc_pair, db_session=db_session - ) - if deletion_attempt_disallowed_reason: - raise HTTPException( - status_code=400, - detail=deletion_attempt_disallowed_reason, - ) - - # mark as deleting - update_connector_credential_pair_from_id( - db_session=db_session, - cc_pair_id=cc_pair.id, - status=ConnectorCredentialPairStatus.DELETING, - ) - # actually kick off the deletion - cleanup_connector_credential_pair_task.apply_async( - kwargs=dict(connector_id=connector_id, credential_id=credential_id), - ) - - if cc_pair.connector.source == DocumentSource.FILE: - connector = cc_pair.connector - file_store = get_default_file_store(db_session) - for file_name in connector.connector_specific_config.get("file_locations", []): - file_store.delete_file(file_name) diff --git a/backend/danswer/server/manage/embedding/api.py b/backend/danswer/server/manage/embedding/api.py deleted file mode 100644 index 90fa69401c2..00000000000 --- a/backend/danswer/server/manage/embedding/api.py +++ /dev/null @@ -1,94 +0,0 @@ -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.llm import fetch_existing_embedding_providers -from danswer.db.llm import remove_embedding_provider -from danswer.db.llm import upsert_cloud_embedding_provider -from danswer.db.models import User -from danswer.db.search_settings import get_current_db_embedding_provider -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from danswer.server.manage.embedding.models import CloudEmbeddingProvider -from danswer.server.manage.embedding.models import CloudEmbeddingProviderCreationRequest -from danswer.server.manage.embedding.models import TestEmbeddingRequest -from danswer.utils.logger import setup_logger -from shared_configs.configs import MODEL_SERVER_HOST -from shared_configs.configs import MODEL_SERVER_PORT -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import EmbedTextType - -logger = setup_logger() - - -admin_router = APIRouter(prefix="/admin/embedding") -basic_router = APIRouter(prefix="/embedding") - - -@admin_router.post("/test-embedding") -def test_embedding_configuration( - test_llm_request: TestEmbeddingRequest, - _: User | None = Depends(current_admin_user), -) -> None: - try: - test_model = EmbeddingModel( - server_host=MODEL_SERVER_HOST, - server_port=MODEL_SERVER_PORT, - api_key=test_llm_request.api_key, - provider_type=test_llm_request.provider_type, - normalize=False, - query_prefix=None, - passage_prefix=None, - model_name=None, - ) - test_model.encode(["Testing Embedding"], text_type=EmbedTextType.QUERY) - - except ValueError as e: - error_msg = f"Not a valid embedding model. Exception thrown: {e}" - logger.error(error_msg) - raise ValueError(error_msg) - - except Exception as e: - error_msg = "An error occurred while testing your embedding model. Please check your configuration." - logger.error(f"{error_msg} Error message: {e}", exc_info=True) - raise HTTPException(status_code=400, detail=error_msg) - - -@admin_router.get("/embedding-provider") -def list_embedding_providers( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[CloudEmbeddingProvider]: - return [ - CloudEmbeddingProvider.from_request(embedding_provider_model) - for embedding_provider_model in fetch_existing_embedding_providers(db_session) - ] - - -@admin_router.delete("/embedding-provider/{provider_type}") -def delete_embedding_provider( - provider_type: EmbeddingProvider, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - embedding_provider = get_current_db_embedding_provider(db_session=db_session) - if ( - embedding_provider is not None - and provider_type == embedding_provider.provider_type - ): - raise HTTPException( - status_code=400, detail="You can't delete a currently active model" - ) - - remove_embedding_provider(db_session, provider_type=provider_type) - - -@admin_router.put("/embedding-provider") -def put_cloud_embedding_provider( - provider: CloudEmbeddingProviderCreationRequest, - _: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> CloudEmbeddingProvider: - return upsert_cloud_embedding_provider(db_session, provider) diff --git a/backend/danswer/server/manage/embedding/models.py b/backend/danswer/server/manage/embedding/models.py deleted file mode 100644 index 132d311413c..00000000000 --- a/backend/danswer/server/manage/embedding/models.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import TYPE_CHECKING - -from pydantic import BaseModel - -from shared_configs.enums import EmbeddingProvider - -if TYPE_CHECKING: - from danswer.db.models import CloudEmbeddingProvider as CloudEmbeddingProviderModel - - -class TestEmbeddingRequest(BaseModel): - provider_type: EmbeddingProvider - api_key: str | None = None - - -class CloudEmbeddingProvider(BaseModel): - provider_type: EmbeddingProvider - api_key: str | None = None - - @classmethod - def from_request( - cls, cloud_provider_model: "CloudEmbeddingProviderModel" - ) -> "CloudEmbeddingProvider": - return cls( - provider_type=cloud_provider_model.provider_type, - api_key=cloud_provider_model.api_key, - ) - - -class CloudEmbeddingProviderCreationRequest(BaseModel): - provider_type: EmbeddingProvider - api_key: str | None = None diff --git a/backend/danswer/server/manage/get_state.py b/backend/danswer/server/manage/get_state.py deleted file mode 100644 index 3ca47841b64..00000000000 --- a/backend/danswer/server/manage/get_state.py +++ /dev/null @@ -1,27 +0,0 @@ -from fastapi import APIRouter - -from danswer import __version__ -from danswer.auth.users import user_needs_to_be_verified -from danswer.configs.app_configs import AUTH_TYPE -from danswer.server.manage.models import AuthTypeResponse -from danswer.server.manage.models import VersionResponse -from danswer.server.models import StatusResponse - -router = APIRouter() - - -@router.get("/health") -def healthcheck() -> StatusResponse: - return StatusResponse(success=True, message="ok") - - -@router.get("/auth/type") -def get_auth_type() -> AuthTypeResponse: - return AuthTypeResponse( - auth_type=AUTH_TYPE, requires_verification=user_needs_to_be_verified() - ) - - -@router.get("/version") -def get_version() -> VersionResponse: - return VersionResponse(backend_version=__version__) diff --git a/backend/danswer/server/manage/llm/api.py b/backend/danswer/server/manage/llm/api.py deleted file mode 100644 index 9ea9fe927db..00000000000 --- a/backend/danswer/server/manage/llm/api.py +++ /dev/null @@ -1,156 +0,0 @@ -from collections.abc import Callable - -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.auth.users import current_user -from danswer.db.engine import get_session -from danswer.db.llm import fetch_existing_llm_providers -from danswer.db.llm import remove_llm_provider -from danswer.db.llm import update_default_provider -from danswer.db.llm import upsert_llm_provider -from danswer.db.models import User -from danswer.llm.factory import get_default_llms -from danswer.llm.factory import get_llm -from danswer.llm.llm_provider_options import fetch_available_well_known_llms -from danswer.llm.llm_provider_options import WellKnownLLMProviderDescriptor -from danswer.llm.utils import test_llm -from danswer.server.manage.llm.models import FullLLMProvider -from danswer.server.manage.llm.models import LLMProviderDescriptor -from danswer.server.manage.llm.models import LLMProviderUpsertRequest -from danswer.server.manage.llm.models import TestLLMRequest -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - -logger = setup_logger() - - -admin_router = APIRouter(prefix="/admin/llm") -basic_router = APIRouter(prefix="/llm") - - -@admin_router.get("/built-in/options") -def fetch_llm_options( - _: User | None = Depends(current_admin_user), -) -> list[WellKnownLLMProviderDescriptor]: - return fetch_available_well_known_llms() - - -@admin_router.post("/test") -def test_llm_configuration( - test_llm_request: TestLLMRequest, - _: User | None = Depends(current_admin_user), -) -> None: - llm = get_llm( - provider=test_llm_request.provider, - model=test_llm_request.default_model_name, - api_key=test_llm_request.api_key, - api_base=test_llm_request.api_base, - api_version=test_llm_request.api_version, - custom_config=test_llm_request.custom_config, - ) - functions_with_args: list[tuple[Callable, tuple]] = [(test_llm, (llm,))] - - if ( - test_llm_request.fast_default_model_name - and test_llm_request.fast_default_model_name - != test_llm_request.default_model_name - ): - fast_llm = get_llm( - provider=test_llm_request.provider, - model=test_llm_request.fast_default_model_name, - api_key=test_llm_request.api_key, - api_base=test_llm_request.api_base, - api_version=test_llm_request.api_version, - custom_config=test_llm_request.custom_config, - ) - functions_with_args.append((test_llm, (fast_llm,))) - - parallel_results = run_functions_tuples_in_parallel( - functions_with_args, allow_failures=False - ) - error = parallel_results[0] or ( - parallel_results[1] if len(parallel_results) > 1 else None - ) - - if error: - raise HTTPException(status_code=400, detail=error) - - -@admin_router.post("/test/default") -def test_default_provider( - _: User | None = Depends(current_admin_user), -) -> None: - try: - llm, fast_llm = get_default_llms() - except ValueError: - logger.exception("Failed to fetch default LLM Provider") - raise HTTPException(status_code=400, detail="No LLM Provider setup") - - functions_with_args: list[tuple[Callable, tuple]] = [ - (test_llm, (llm,)), - (test_llm, (fast_llm,)), - ] - parallel_results = run_functions_tuples_in_parallel( - functions_with_args, allow_failures=False - ) - error = parallel_results[0] or ( - parallel_results[1] if len(parallel_results) > 1 else None - ) - if error: - raise HTTPException(status_code=400, detail=error) - - -@admin_router.get("/provider") -def list_llm_providers( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[FullLLMProvider]: - return [ - FullLLMProvider.from_model(llm_provider_model) - for llm_provider_model in fetch_existing_llm_providers(db_session) - ] - - -@admin_router.put("/provider") -def put_llm_provider( - llm_provider: LLMProviderUpsertRequest, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> FullLLMProvider: - return upsert_llm_provider(db_session, llm_provider) - - -@admin_router.delete("/provider/{provider_id}") -def delete_llm_provider( - provider_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - remove_llm_provider(db_session, provider_id) - - -@admin_router.post("/provider/{provider_id}/default") -def set_provider_as_default( - provider_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - update_default_provider(db_session, provider_id) - - -"""Endpoints for all""" - - -@basic_router.get("/provider") -def list_llm_provider_basics( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[LLMProviderDescriptor]: - return [ - LLMProviderDescriptor.from_model(llm_provider_model) - for llm_provider_model in fetch_existing_llm_providers(db_session, user) - ] diff --git a/backend/danswer/server/manage/llm/models.py b/backend/danswer/server/manage/llm/models.py deleted file mode 100644 index 3ef66971003..00000000000 --- a/backend/danswer/server/manage/llm/models.py +++ /dev/null @@ -1,103 +0,0 @@ -from typing import TYPE_CHECKING - -from pydantic import BaseModel -from pydantic import Field - -from danswer.llm.llm_provider_options import fetch_models_for_provider - - -if TYPE_CHECKING: - from danswer.db.models import LLMProvider as LLMProviderModel - - -class TestLLMRequest(BaseModel): - # provider level - provider: str - api_key: str | None = None - api_base: str | None = None - api_version: str | None = None - custom_config: dict[str, str] | None = None - - # model level - default_model_name: str - fast_default_model_name: str | None = None - - -class LLMProviderDescriptor(BaseModel): - """A descriptor for an LLM provider that can be safely viewed by - non-admin users. Used when giving a list of available LLMs.""" - - name: str - provider: str - model_names: list[str] - default_model_name: str - fast_default_model_name: str | None - is_default_provider: bool | None - display_model_names: list[str] | None - - @classmethod - def from_model( - cls, llm_provider_model: "LLMProviderModel" - ) -> "LLMProviderDescriptor": - return cls( - name=llm_provider_model.name, - provider=llm_provider_model.provider, - default_model_name=llm_provider_model.default_model_name, - fast_default_model_name=llm_provider_model.fast_default_model_name, - is_default_provider=llm_provider_model.is_default_provider, - model_names=( - llm_provider_model.model_names - or fetch_models_for_provider(llm_provider_model.provider) - or [llm_provider_model.default_model_name] - ), - display_model_names=llm_provider_model.display_model_names, - ) - - -class LLMProvider(BaseModel): - name: str - provider: str - api_key: str | None = None - api_base: str | None = None - api_version: str | None = None - custom_config: dict[str, str] | None = None - default_model_name: str - fast_default_model_name: str | None = None - is_public: bool = True - groups: list[int] = Field(default_factory=list) - display_model_names: list[str] | None = None - - -class LLMProviderUpsertRequest(LLMProvider): - # should only be used for a "custom" provider - # for default providers, the built-in model names are used - model_names: list[str] | None = None - - -class FullLLMProvider(LLMProvider): - id: int - is_default_provider: bool | None = None - model_names: list[str] - - @classmethod - def from_model(cls, llm_provider_model: "LLMProviderModel") -> "FullLLMProvider": - return cls( - id=llm_provider_model.id, - name=llm_provider_model.name, - provider=llm_provider_model.provider, - api_key=llm_provider_model.api_key, - api_base=llm_provider_model.api_base, - api_version=llm_provider_model.api_version, - custom_config=llm_provider_model.custom_config, - default_model_name=llm_provider_model.default_model_name, - fast_default_model_name=llm_provider_model.fast_default_model_name, - is_default_provider=llm_provider_model.is_default_provider, - display_model_names=llm_provider_model.display_model_names, - model_names=( - llm_provider_model.model_names - or fetch_models_for_provider(llm_provider_model.provider) - or [llm_provider_model.default_model_name] - ), - is_public=llm_provider_model.is_public, - groups=[group.id for group in llm_provider_model.groups], - ) diff --git a/backend/danswer/server/manage/models.py b/backend/danswer/server/manage/models.py deleted file mode 100644 index 160c90bdb78..00000000000 --- a/backend/danswer/server/manage/models.py +++ /dev/null @@ -1,256 +0,0 @@ -from datetime import datetime -from typing import TYPE_CHECKING - -from pydantic import BaseModel -from pydantic import ConfigDict -from pydantic import Field -from pydantic import field_validator -from pydantic import model_validator - -from danswer.auth.schemas import UserRole -from danswer.configs.app_configs import TRACK_EXTERNAL_IDP_EXPIRY -from danswer.configs.constants import AuthType -from danswer.danswerbot.slack.config import VALID_SLACK_FILTERS -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.db.models import User -from danswer.search.models import SavedSearchSettings -from danswer.server.features.persona.models import PersonaSnapshot -from danswer.server.models import FullUserSnapshot -from danswer.server.models import InvitedUserSnapshot - - -if TYPE_CHECKING: - pass - - -class VersionResponse(BaseModel): - backend_version: str - - -class AuthTypeResponse(BaseModel): - auth_type: AuthType - # specifies whether the current auth setup requires - # users to have verified emails - requires_verification: bool - - -class UserPreferences(BaseModel): - chosen_assistants: list[int] | None = None - default_model: str | None = None - - -class UserInfo(BaseModel): - id: str - email: str - is_active: bool - is_superuser: bool - is_verified: bool - role: UserRole - preferences: UserPreferences - oidc_expiry: datetime | None = None - current_token_created_at: datetime | None = None - current_token_expiry_length: int | None = None - - @classmethod - def from_model( - cls, - user: User, - current_token_created_at: datetime | None = None, - expiry_length: int | None = None, - ) -> "UserInfo": - return cls( - id=str(user.id), - email=user.email, - is_active=user.is_active, - is_superuser=user.is_superuser, - is_verified=user.is_verified, - role=user.role, - preferences=( - UserPreferences( - chosen_assistants=user.chosen_assistants, - default_model=user.default_model, - ) - ), - # set to None if TRACK_EXTERNAL_IDP_EXPIRY is False so that we avoid cases - # where they previously had this set + used OIDC, and now they switched to - # basic auth are now constantly getting redirected back to the login page - # since their "oidc_expiry is old" - oidc_expiry=user.oidc_expiry if TRACK_EXTERNAL_IDP_EXPIRY else None, - current_token_created_at=current_token_created_at, - current_token_expiry_length=expiry_length, - ) - - -class UserByEmail(BaseModel): - user_email: str - - -class UserRoleUpdateRequest(BaseModel): - user_email: str - new_role: UserRole - - -class UserRoleResponse(BaseModel): - role: str - - -class BoostDoc(BaseModel): - document_id: str - semantic_id: str - link: str - boost: int - hidden: bool - - -class BoostUpdateRequest(BaseModel): - document_id: str - boost: int - - -class HiddenUpdateRequest(BaseModel): - document_id: str - 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] - - @field_validator("categories", mode="before") - @classmethod - 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 - model_config = ConfigDict(frozen=True) - - -class SlackBotConfigCreationRequest(BaseModel): - # currently, a persona is created for each slack bot config - # in the future, `document_sets` will probably be replaced - # by an optional `PersonaSnapshot` object. Keeping it like this - # for now for simplicity / speed of development - document_sets: list[int] | None = None - persona_id: ( - int | None - ) = None # NOTE: only one of `document_sets` / `persona_id` should be set - channel_names: list[str] - respond_tag_only: bool = False - respond_to_bots: bool = False - enable_auto_filters: bool = False - # If no team members, assume respond in the channel to everyone - respond_member_group_list: list[str] = Field(default_factory=list) - answer_filters: list[AllowedAnswerFilters] = Field(default_factory=list) - # list of user emails - follow_up_tags: list[str] | None = None - response_type: SlackBotResponseType - standard_answer_categories: list[int] = Field(default_factory=list) - - @field_validator("answer_filters", mode="before") - @classmethod - def validate_filters(cls, value: list[str]) -> list[str]: - if any(test not in VALID_SLACK_FILTERS for test in value): - raise ValueError( - f"Slack Answer filters must be one of {VALID_SLACK_FILTERS}" - ) - return value - - @model_validator(mode="after") - def validate_document_sets_and_persona_id(self) -> "SlackBotConfigCreationRequest": - if self.document_sets and self.persona_id: - raise ValueError("Only one of `document_sets` / `persona_id` should be set") - - return self - - -class SlackBotConfig(BaseModel): - id: int - persona: PersonaSnapshot | None - channel_config: ChannelConfig - response_type: SlackBotResponseType - standard_answer_categories: list[StandardAnswerCategory] - enable_auto_filters: bool - - @classmethod - def from_model( - cls, slack_bot_config_model: SlackBotConfigModel - ) -> "SlackBotConfig": - return cls( - id=slack_bot_config_model.id, - persona=( - PersonaSnapshot.from_model( - slack_bot_config_model.persona, allow_deleted=True - ) - if slack_bot_config_model.persona - else None - ), - 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 - ], - enable_auto_filters=slack_bot_config_model.enable_auto_filters, - ) - - -class FullModelVersionResponse(BaseModel): - current_settings: SavedSearchSettings - secondary_settings: SavedSearchSettings | None - - -class AllUsersResponse(BaseModel): - accepted: list[FullUserSnapshot] - invited: list[InvitedUserSnapshot] - accepted_pages: int - invited_pages: int diff --git a/backend/danswer/server/manage/search_settings.py b/backend/danswer/server/manage/search_settings.py deleted file mode 100644 index db483eff5da..00000000000 --- a/backend/danswer/server/manage/search_settings.py +++ /dev/null @@ -1,180 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import status -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_user -from danswer.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP -from danswer.db.connector_credential_pair import get_connector_credential_pairs -from danswer.db.connector_credential_pair import resync_cc_pair -from danswer.db.engine import get_session -from danswer.db.index_attempt import expire_index_attempts -from danswer.db.models import IndexModelStatus -from danswer.db.models import User -from danswer.db.search_settings import create_search_settings -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_embedding_provider_from_provider_type -from danswer.db.search_settings import get_secondary_search_settings -from danswer.db.search_settings import update_current_search_settings -from danswer.db.search_settings import update_search_settings_status -from danswer.document_index.factory import get_default_document_index -from danswer.natural_language_processing.search_nlp_models import clean_model_name -from danswer.search.models import SavedSearchSettings -from danswer.search.models import SearchSettingsCreationRequest -from danswer.server.manage.models import FullModelVersionResponse -from danswer.server.models import IdReturn -from danswer.utils.logger import setup_logger -from shared_configs.configs import ALT_INDEX_SUFFIX - - -router = APIRouter(prefix="/search-settings") -logger = setup_logger() - - -@router.post("/set-new-search-settings") -def set_new_search_settings( - search_settings_new: SearchSettingsCreationRequest, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> IdReturn: - """Creates a new EmbeddingModel row and cancels the previous secondary indexing if any - Gives an error if the same model name is used as the current or secondary index - """ - if search_settings_new.index_name: - logger.warning("Index name was specified by request, this is not suggested") - - # Validate cloud provider exists - if search_settings_new.provider_type is not None: - cloud_provider = get_embedding_provider_from_provider_type( - db_session, provider_type=search_settings_new.provider_type - ) - - if cloud_provider is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"No embedding provider exists for cloud embedding type {search_settings_new.provider_type}", - ) - - search_settings = get_current_search_settings(db_session) - - if search_settings_new.index_name is None: - # We define index name here - index_name = f"danswer_chunk_{clean_model_name(search_settings_new.model_name)}" - if ( - search_settings_new.model_name == search_settings.model_name - and not search_settings.index_name.endswith(ALT_INDEX_SUFFIX) - ): - index_name += ALT_INDEX_SUFFIX - search_values = search_settings_new.dict() - search_values["index_name"] = index_name - new_search_settings_request = SavedSearchSettings(**search_values) - else: - new_search_settings_request = SavedSearchSettings(**search_settings_new.dict()) - - secondary_search_settings = get_secondary_search_settings(db_session) - - if secondary_search_settings: - # Cancel any background indexing jobs - expire_index_attempts( - search_settings_id=secondary_search_settings.id, db_session=db_session - ) - - # Mark previous model as a past model directly - update_search_settings_status( - search_settings=secondary_search_settings, - new_status=IndexModelStatus.PAST, - db_session=db_session, - ) - - new_search_settings = create_search_settings( - search_settings=new_search_settings_request, db_session=db_session - ) - - # Ensure Vespa has the new index immediately - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, - secondary_index_name=new_search_settings.index_name, - ) - document_index.ensure_indices_exist( - index_embedding_dim=search_settings.model_dim, - secondary_index_embedding_dim=new_search_settings.model_dim, - ) - - # Pause index attempts for the currently in use index to preserve resources - if DISABLE_INDEX_UPDATE_ON_SWAP: - expire_index_attempts( - search_settings_id=search_settings.id, db_session=db_session - ) - for cc_pair in get_connector_credential_pairs(db_session): - resync_cc_pair(cc_pair, db_session=db_session) - - return IdReturn(id=new_search_settings.id) - - -@router.post("/cancel-new-embedding") -def cancel_new_embedding( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - secondary_search_settings = get_secondary_search_settings(db_session) - - if secondary_search_settings: - expire_index_attempts( - search_settings_id=secondary_search_settings.id, db_session=db_session - ) - - update_search_settings_status( - search_settings=secondary_search_settings, - new_status=IndexModelStatus.PAST, - db_session=db_session, - ) - - -@router.get("/get-current-search-settings") -def get_curr_search_settings( - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> SavedSearchSettings: - current_search_settings = get_current_search_settings(db_session) - return SavedSearchSettings.from_db_model(current_search_settings) - - -@router.get("/get-secondary-search-settings") -def get_sec_search_settings( - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> SavedSearchSettings | None: - secondary_search_settings = get_secondary_search_settings(db_session) - if not secondary_search_settings: - return None - - return SavedSearchSettings.from_db_model(secondary_search_settings) - - -@router.get("/get-all-search-settings") -def get_all_search_settings( - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> FullModelVersionResponse: - current_search_settings = get_current_search_settings(db_session) - secondary_search_settings = get_secondary_search_settings(db_session) - return FullModelVersionResponse( - current_settings=SavedSearchSettings.from_db_model(current_search_settings), - secondary_settings=SavedSearchSettings.from_db_model(secondary_search_settings) - if secondary_search_settings - else None, - ) - - -# Updates current non-reindex search settings -@router.post("/update-inference-settings") -def update_saved_search_settings( - search_settings: SavedSearchSettings, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - update_current_search_settings( - search_settings=search_settings, db_session=db_session - ) diff --git a/backend/danswer/server/manage/slack_bot.py b/backend/danswer/server/manage/slack_bot.py deleted file mode 100644 index 0fb1459072b..00000000000 --- a/backend/danswer/server/manage/slack_bot.py +++ /dev/null @@ -1,215 +0,0 @@ -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.danswerbot.slack.config import validate_channel_names -from danswer.danswerbot.slack.tokens import fetch_tokens -from danswer.danswerbot.slack.tokens import save_tokens -from danswer.db.constants import SLACK_BOT_PERSONA_PREFIX -from danswer.db.engine import get_session -from danswer.db.models import ChannelConfig -from danswer.db.models import User -from danswer.db.persona import get_persona_by_id -from danswer.db.slack_bot_config import create_slack_bot_persona -from danswer.db.slack_bot_config import fetch_slack_bot_config -from danswer.db.slack_bot_config import fetch_slack_bot_configs -from danswer.db.slack_bot_config import insert_slack_bot_config -from danswer.db.slack_bot_config import remove_slack_bot_config -from danswer.db.slack_bot_config import update_slack_bot_config -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.server.manage.models import SlackBotConfig -from danswer.server.manage.models import SlackBotConfigCreationRequest -from danswer.server.manage.models import SlackBotTokens - - -router = APIRouter(prefix="/manage") - - -def _form_channel_config( - slack_bot_config_creation_request: SlackBotConfigCreationRequest, - current_slack_bot_config_id: int | None, - db_session: Session, -) -> ChannelConfig: - raw_channel_names = slack_bot_config_creation_request.channel_names - respond_tag_only = slack_bot_config_creation_request.respond_tag_only - respond_member_group_list = ( - slack_bot_config_creation_request.respond_member_group_list - ) - answer_filters = slack_bot_config_creation_request.answer_filters - follow_up_tags = slack_bot_config_creation_request.follow_up_tags - - if not raw_channel_names: - raise HTTPException( - status_code=400, - detail="Must provide at least one channel name", - ) - - try: - cleaned_channel_names = validate_channel_names( - channel_names=raw_channel_names, - current_slack_bot_config_id=current_slack_bot_config_id, - db_session=db_session, - ) - except ValueError as e: - raise HTTPException( - status_code=400, - detail=str(e), - ) - - if respond_tag_only and respond_member_group_list: - raise ValueError( - "Cannot set DanswerBot to only respond to tags only and " - "also respond to a predetermined set of users." - ) - - channel_config: ChannelConfig = { - "channel_names": cleaned_channel_names, - } - if respond_tag_only is not None: - channel_config["respond_tag_only"] = respond_tag_only - if respond_member_group_list: - channel_config["respond_member_group_list"] = respond_member_group_list - if answer_filters: - channel_config["answer_filters"] = answer_filters - if follow_up_tags is not None: - channel_config["follow_up_tags"] = follow_up_tags - - channel_config[ - "respond_to_bots" - ] = slack_bot_config_creation_request.respond_to_bots - - return channel_config - - -@router.post("/admin/slack-bot/config") -def create_slack_bot_config( - slack_bot_config_creation_request: SlackBotConfigCreationRequest, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_admin_user), -) -> SlackBotConfig: - channel_config = _form_channel_config( - slack_bot_config_creation_request, None, db_session - ) - - persona_id = None - if slack_bot_config_creation_request.persona_id is not None: - persona_id = slack_bot_config_creation_request.persona_id - elif slack_bot_config_creation_request.document_sets: - persona_id = create_slack_bot_persona( - db_session=db_session, - channel_names=channel_config["channel_names"], - document_set_ids=slack_bot_config_creation_request.document_sets, - existing_persona_id=None, - ).id - - slack_bot_config_model = insert_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, - enable_auto_filters=slack_bot_config_creation_request.enable_auto_filters, - ) - return SlackBotConfig.from_model(slack_bot_config_model) - - -@router.patch("/admin/slack-bot/config/{slack_bot_config_id}") -def patch_slack_bot_config( - slack_bot_config_id: int, - slack_bot_config_creation_request: SlackBotConfigCreationRequest, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_admin_user), -) -> SlackBotConfig: - channel_config = _form_channel_config( - slack_bot_config_creation_request, slack_bot_config_id, db_session - ) - - persona_id = None - if slack_bot_config_creation_request.persona_id is not None: - persona_id = slack_bot_config_creation_request.persona_id - elif slack_bot_config_creation_request.document_sets: - existing_slack_bot_config = fetch_slack_bot_config( - db_session=db_session, slack_bot_config_id=slack_bot_config_id - ) - if existing_slack_bot_config is None: - raise HTTPException( - status_code=404, - detail="Slack bot config not found", - ) - - existing_persona_id = existing_slack_bot_config.persona_id - if existing_persona_id is not None: - persona = get_persona_by_id( - persona_id=existing_persona_id, - user=None, - db_session=db_session, - is_for_edit=False, - ) - - if not persona.name.startswith(SLACK_BOT_PERSONA_PREFIX): - # Don't update actual non-slackbot specific personas - # Since this one specified document sets, we have to create a new persona - # for this DanswerBot config - existing_persona_id = None - else: - existing_persona_id = existing_slack_bot_config.persona_id - - persona_id = create_slack_bot_persona( - db_session=db_session, - channel_names=channel_config["channel_names"], - document_set_ids=slack_bot_config_creation_request.document_sets, - existing_persona_id=existing_persona_id, - enable_auto_filters=slack_bot_config_creation_request.enable_auto_filters, - ).id - - slack_bot_config_model = update_slack_bot_config( - slack_bot_config_id=slack_bot_config_id, - 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, - enable_auto_filters=slack_bot_config_creation_request.enable_auto_filters, - ) - return SlackBotConfig.from_model(slack_bot_config_model) - - -@router.delete("/admin/slack-bot/config/{slack_bot_config_id}") -def delete_slack_bot_config( - slack_bot_config_id: int, - db_session: Session = Depends(get_session), - user: User | None = Depends(current_admin_user), -) -> None: - remove_slack_bot_config( - slack_bot_config_id=slack_bot_config_id, user=user, db_session=db_session - ) - - -@router.get("/admin/slack-bot/config") -def list_slack_bot_configs( - db_session: Session = Depends(get_session), - _: User | None = Depends(current_admin_user), -) -> list[SlackBotConfig]: - slack_bot_config_models = fetch_slack_bot_configs(db_session=db_session) - return [ - SlackBotConfig.from_model(slack_bot_config_model) - for slack_bot_config_model in slack_bot_config_models - ] - - -@router.put("/admin/slack-bot/tokens") -def put_tokens( - tokens: SlackBotTokens, - _: User | None = Depends(current_admin_user), -) -> None: - save_tokens(tokens=tokens) - - -@router.get("/admin/slack-bot/tokens") -def get_tokens(_: User | None = Depends(current_admin_user)) -> SlackBotTokens: - try: - return fetch_tokens() - except ConfigNotFoundError: - raise HTTPException(status_code=404, detail="No tokens found") diff --git a/backend/danswer/server/manage/standard_answer.py b/backend/danswer/server/manage/standard_answer.py deleted file mode 100644 index 69f9e8146df..00000000000 --- a/backend/danswer/server/manage/standard_answer.py +++ /dev/null @@ -1,139 +0,0 @@ -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) diff --git a/backend/danswer/server/manage/users.py b/backend/danswer/server/manage/users.py deleted file mode 100644 index d2fd981b5b5..00000000000 --- a/backend/danswer/server/manage/users.py +++ /dev/null @@ -1,379 +0,0 @@ -import re -from datetime import datetime -from datetime import timezone - -from email_validator import validate_email -from fastapi import APIRouter -from fastapi import Body -from fastapi import Depends -from fastapi import HTTPException -from fastapi import status -from pydantic import BaseModel -from sqlalchemy import Column -from sqlalchemy import desc -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.orm import Session - -from danswer.auth.invited_users import get_invited_users -from danswer.auth.invited_users import write_invited_users -from danswer.auth.noauth_user import fetch_no_auth_user -from danswer.auth.noauth_user import set_no_auth_user_preferences -from danswer.auth.schemas import UserRole -from danswer.auth.schemas import UserStatus -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.auth.users import optional_user -from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS -from danswer.configs.app_configs import VALID_EMAIL_DOMAINS -from danswer.configs.constants import AuthType -from danswer.db.engine import get_session -from danswer.db.models import AccessToken -from danswer.db.models import User -from danswer.db.users import get_user_by_email -from danswer.db.users import list_users -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.server.manage.models import AllUsersResponse -from danswer.server.manage.models import UserByEmail -from danswer.server.manage.models import UserInfo -from danswer.server.manage.models import UserRoleResponse -from danswer.server.manage.models import UserRoleUpdateRequest -from danswer.server.models import FullUserSnapshot -from danswer.server.models import InvitedUserSnapshot -from danswer.server.models import MinimalUserSnapshot -from danswer.utils.logger import setup_logger -from ee.danswer.db.api_key import is_api_key_email_address -from ee.danswer.db.user_group import remove_curator_status__no_commit - -logger = setup_logger() - -router = APIRouter() - - -USERS_PAGE_SIZE = 10 - - -@router.patch("/manage/set-user-role") -def set_user_role( - user_role_update_request: UserRoleUpdateRequest, - current_user: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - user_to_update = get_user_by_email( - email=user_role_update_request.user_email, db_session=db_session - ) - if not user_to_update: - raise HTTPException(status_code=404, detail="User not found") - - if user_role_update_request.new_role == UserRole.CURATOR: - raise HTTPException( - status_code=400, - detail="Curator role must be set via the User Group Menu", - ) - - if user_to_update.role == user_role_update_request.new_role: - return - - if current_user.id == user_to_update.id: - raise HTTPException( - status_code=400, - detail="An admin cannot demote themselves from admin role!", - ) - - if user_to_update.role == UserRole.CURATOR: - remove_curator_status__no_commit(db_session, user_to_update) - - user_to_update.role = user_role_update_request.new_role.value - - db_session.commit() - - -@router.get("/manage/users") -def list_all_users( - q: str | None = None, - accepted_page: int | None = None, - invited_page: int | None = None, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> AllUsersResponse: - if not q: - q = "" - - users = [ - user - for user in list_users(db_session, email_filter_string=q, user=user) - if not is_api_key_email_address(user.email) - ] - accepted_emails = {user.email for user in users} - invited_emails = get_invited_users() - if q: - invited_emails = [ - email for email in invited_emails if re.search(r"{}".format(q), email, re.I) - ] - - accepted_count = len(accepted_emails) - invited_count = len(invited_emails) - - # If any of q, accepted_page, or invited_page is None, return all users - if accepted_page is None or invited_page is None: - return AllUsersResponse( - accepted=[ - FullUserSnapshot( - id=user.id, - email=user.email, - role=user.role, - status=( - UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED - ), - ) - for user in users - ], - invited=[InvitedUserSnapshot(email=email) for email in invited_emails], - accepted_pages=1, - invited_pages=1, - ) - - # Otherwise, return paginated results - return AllUsersResponse( - accepted=[ - FullUserSnapshot( - id=user.id, - email=user.email, - role=user.role, - status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED, - ) - for user in users - ][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE], - invited=[InvitedUserSnapshot(email=email) for email in invited_emails][ - invited_page * USERS_PAGE_SIZE : (invited_page + 1) * USERS_PAGE_SIZE - ], - accepted_pages=accepted_count // USERS_PAGE_SIZE + 1, - invited_pages=invited_count // USERS_PAGE_SIZE + 1, - ) - - -@router.put("/manage/admin/users") -def bulk_invite_users( - emails: list[str] = Body(..., embed=True), - current_user: User | None = Depends(current_admin_user), -) -> int: - """emails are string validated. If any email fails validation, no emails are - invited and an exception is raised.""" - if current_user is None: - raise HTTPException( - status_code=400, detail="Auth is disabled, cannot invite users" - ) - - normalized_emails = [] - for email in emails: - email_info = validate_email(email) # can raise EmailNotValidError - normalized_emails.append(email_info.normalized) # type: ignore - all_emails = list(set(normalized_emails) | set(get_invited_users())) - return write_invited_users(all_emails) - - -@router.patch("/manage/admin/remove-invited-user") -def remove_invited_user( - user_email: UserByEmail, - _: User | None = Depends(current_admin_user), -) -> int: - user_emails = get_invited_users() - remaining_users = [user for user in user_emails if user != user_email.user_email] - return write_invited_users(remaining_users) - - -@router.patch("/manage/admin/deactivate-user") -def deactivate_user( - user_email: UserByEmail, - current_user: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - if current_user is None: - raise HTTPException( - status_code=400, detail="Auth is disabled, cannot deactivate user" - ) - - if current_user.email == user_email.user_email: - raise HTTPException(status_code=400, detail="You cannot deactivate yourself") - - user_to_deactivate = get_user_by_email( - email=user_email.user_email, db_session=db_session - ) - - if not user_to_deactivate: - raise HTTPException(status_code=404, detail="User not found") - - if user_to_deactivate.is_active is False: - logger.warning("{} is already deactivated".format(user_to_deactivate.email)) - - user_to_deactivate.is_active = False - db_session.add(user_to_deactivate) - db_session.commit() - - -@router.patch("/manage/admin/activate-user") -def activate_user( - user_email: UserByEmail, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - user_to_activate = get_user_by_email( - email=user_email.user_email, db_session=db_session - ) - if not user_to_activate: - raise HTTPException(status_code=404, detail="User not found") - - if user_to_activate.is_active is True: - logger.warning("{} is already activated".format(user_to_activate.email)) - - user_to_activate.is_active = True - db_session.add(user_to_activate) - db_session.commit() - - -@router.get("/manage/admin/valid-domains") -def get_valid_domains( - _: User | None = Depends(current_admin_user), -) -> list[str]: - return VALID_EMAIL_DOMAINS - - -"""Endpoints for all""" - - -@router.get("/users") -def list_all_users_basic_info( - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> list[MinimalUserSnapshot]: - users = list_users(db_session) - return [MinimalUserSnapshot(id=user.id, email=user.email) for user in users] - - -@router.get("/get-user-role") -async def get_user_role(user: User = Depends(current_user)) -> UserRoleResponse: - if user is None: - raise ValueError("Invalid or missing user.") - return UserRoleResponse(role=user.role) - - -def get_current_token_creation( - user: User | None, db_session: Session -) -> datetime | None: - if user is None: - return None - try: - result = db_session.execute( - select(AccessToken) - .where(AccessToken.user_id == user.id) # type: ignore - .order_by(desc(Column("created_at"))) - .limit(1) - ) - access_token = result.scalar_one_or_none() - - if access_token: - return access_token.created_at - else: - logger.error("No AccessToken found for user") - return None - - except Exception as e: - logger.error(f"Error fetching AccessToken: {e}") - return None - - -@router.get("/me") -def verify_user_logged_in( - user: User | None = Depends(optional_user), - db_session: Session = Depends(get_session), -) -> UserInfo: - # NOTE: this does not use `current_user` / `current_admin_user` because we don't want - # to enforce user verification here - the frontend always wants to get the info about - # the current user regardless of if they are currently verified - if user is None: - # if auth type is disabled, return a dummy user with preferences from - # the key-value store - if AUTH_TYPE == AuthType.DISABLED: - store = get_dynamic_config_store() - return fetch_no_auth_user(store) - - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="User Not Authenticated" - ) - - if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. User's OIDC token has expired.", - ) - - token_created_at = get_current_token_creation(user, db_session) - user_info = UserInfo.from_model( - user, - current_token_created_at=token_created_at, - expiry_length=SESSION_EXPIRE_TIME_SECONDS, - ) - - return user_info - - -"""APIs to adjust user preferences""" - - -class ChosenDefaultModelRequest(BaseModel): - default_model: str | None = None - - -@router.patch("/user/default-model") -def update_user_default_model( - request: ChosenDefaultModelRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - if user is None: - if AUTH_TYPE == AuthType.DISABLED: - store = get_dynamic_config_store() - no_auth_user = fetch_no_auth_user(store) - no_auth_user.preferences.default_model = request.default_model - set_no_auth_user_preferences(store, no_auth_user.preferences) - return - else: - raise RuntimeError("This should never happen") - - db_session.execute( - update(User) - .where(User.id == user.id) # type: ignore - .values(default_model=request.default_model) - ) - db_session.commit() - - -class ChosenAssistantsRequest(BaseModel): - chosen_assistants: list[int] - - -@router.patch("/user/assistant-list") -def update_user_assistant_list( - request: ChosenAssistantsRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - if user is None: - if AUTH_TYPE == AuthType.DISABLED: - store = get_dynamic_config_store() - - no_auth_user = fetch_no_auth_user(store) - no_auth_user.preferences.chosen_assistants = request.chosen_assistants - set_no_auth_user_preferences(store, no_auth_user.preferences) - return - else: - raise RuntimeError("This should never happen") - - db_session.execute( - update(User) - .where(User.id == user.id) # type: ignore - .values(chosen_assistants=request.chosen_assistants) - ) - db_session.commit() diff --git a/backend/danswer/server/middleware/latency_logging.py b/backend/danswer/server/middleware/latency_logging.py deleted file mode 100644 index ed269a545d1..00000000000 --- a/backend/danswer/server/middleware/latency_logging.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging -import time -from collections.abc import Awaitable -from collections.abc import Callable - -from fastapi import FastAPI -from fastapi import Request -from fastapi import Response - - -def add_latency_logging_middleware(app: FastAPI, logger: logging.LoggerAdapter) -> None: - @app.middleware("http") - async def log_latency( - request: Request, call_next: Callable[[Request], Awaitable[Response]] - ) -> Response: - start_time = time.monotonic() - response = await call_next(request) - process_time = time.monotonic() - start_time - logger.debug( - f"Path: {request.url.path} - Method: {request.method} - " - f"Status Code: {response.status_code} - Time: {process_time:.4f} secs" - ) - return response diff --git a/backend/danswer/server/models.py b/backend/danswer/server/models.py deleted file mode 100644 index 9c78851eb06..00000000000 --- a/backend/danswer/server/models.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Generic -from typing import Optional -from typing import TypeVar -from uuid import UUID - -from pydantic import BaseModel - -from danswer.auth.schemas import UserRole -from danswer.auth.schemas import UserStatus - - -DataT = TypeVar("DataT") - - -class StatusResponse(BaseModel, Generic[DataT]): - success: bool - message: Optional[str] = None - data: Optional[DataT] = None - - -class ApiKey(BaseModel): - api_key: str - - -class IdReturn(BaseModel): - id: int - - -class MinimalUserSnapshot(BaseModel): - id: UUID - email: str - - -class FullUserSnapshot(BaseModel): - id: UUID - email: str - role: UserRole - status: UserStatus - - -class InvitedUserSnapshot(BaseModel): - email: str - - -class DisplayPriorityRequest(BaseModel): - display_priority_map: dict[int, int] diff --git a/backend/danswer/server/query_and_chat/__init__.py b/backend/danswer/server/query_and_chat/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/server/query_and_chat/chat_backend.py b/backend/danswer/server/query_and_chat/chat_backend.py deleted file mode 100644 index a37758336a2..00000000000 --- a/backend/danswer/server/query_and_chat/chat_backend.py +++ /dev/null @@ -1,622 +0,0 @@ -import asyncio -import io -import uuid -from collections.abc import Callable -from collections.abc import Generator - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Request -from fastapi import Response -from fastapi import UploadFile -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.chat.chat_utils import create_chat_chain -from danswer.chat.process_message import stream_chat_message -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import FileOrigin -from danswer.configs.constants import MessageType -from danswer.db.chat import create_chat_session -from danswer.db.chat import create_new_chat_message -from danswer.db.chat import delete_chat_session -from danswer.db.chat import get_chat_message -from danswer.db.chat import get_chat_messages_by_session -from danswer.db.chat import get_chat_session_by_id -from danswer.db.chat import get_chat_sessions_by_user -from danswer.db.chat import get_or_create_root_message -from danswer.db.chat import set_as_latest_chat_message -from danswer.db.chat import translate_db_message_to_chat_message_detail -from danswer.db.chat import update_chat_session -from danswer.db.engine import get_session -from danswer.db.feedback import create_chat_message_feedback -from danswer.db.feedback import create_doc_retrieval_feedback -from danswer.db.models import User -from danswer.db.persona import get_persona_by_id -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.document_index.factory import get_default_document_index -from danswer.file_processing.extract_file_text import extract_file_text -from danswer.file_store.file_store import get_default_file_store -from danswer.file_store.models import ChatFileType -from danswer.file_store.models import FileDescriptor -from danswer.llm.answering.prompts.citations_prompt import ( - compute_max_document_tokens_for_persona, -) -from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.factory import get_default_llms -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.headers import get_litellm_additional_request_headers -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.secondary_llm_flows.chat_session_naming import ( - get_renamed_conversation_name, -) -from danswer.server.query_and_chat.models import ChatFeedbackRequest -from danswer.server.query_and_chat.models import ChatMessageIdentifier -from danswer.server.query_and_chat.models import ChatRenameRequest -from danswer.server.query_and_chat.models import ChatSessionCreationRequest -from danswer.server.query_and_chat.models import ChatSessionDetailResponse -from danswer.server.query_and_chat.models import ChatSessionDetails -from danswer.server.query_and_chat.models import ChatSessionsResponse -from danswer.server.query_and_chat.models import ChatSessionUpdateRequest -from danswer.server.query_and_chat.models import CreateChatMessageRequest -from danswer.server.query_and_chat.models import CreateChatSessionID -from danswer.server.query_and_chat.models import LLMOverride -from danswer.server.query_and_chat.models import PromptOverride -from danswer.server.query_and_chat.models import RenameChatSessionResponse -from danswer.server.query_and_chat.models import SearchFeedbackRequest -from danswer.server.query_and_chat.models import UpdateChatSessionThreadRequest -from danswer.server.query_and_chat.token_limit import check_token_rate_limits -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -router = APIRouter(prefix="/chat") - - -@router.get("/get-user-chat-sessions") -def get_user_chat_sessions( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatSessionsResponse: - user_id = user.id if user is not None else None - - try: - chat_sessions = get_chat_sessions_by_user( - user_id=user_id, deleted=False, db_session=db_session - ) - - except ValueError: - raise ValueError("Chat session does not exist or has been deleted") - - return ChatSessionsResponse( - sessions=[ - ChatSessionDetails( - id=chat.id, - name=chat.description, - persona_id=chat.persona_id, - time_created=chat.time_created.isoformat(), - shared_status=chat.shared_status, - folder_id=chat.folder_id, - current_alternate_model=chat.current_alternate_model, - ) - for chat in chat_sessions - ] - ) - - -@router.put("/update-chat-session-model") -def update_chat_session_model( - update_thread_req: UpdateChatSessionThreadRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - chat_session = get_chat_session_by_id( - chat_session_id=update_thread_req.chat_session_id, - user_id=user.id if user is not None else None, - db_session=db_session, - ) - chat_session.current_alternate_model = update_thread_req.new_alternate_model - - db_session.add(chat_session) - db_session.commit() - - -@router.get("/get-chat-session/{session_id}") -def get_chat_session( - session_id: int, - is_shared: bool = False, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatSessionDetailResponse: - user_id = user.id if user is not None else None - try: - chat_session = get_chat_session_by_id( - chat_session_id=session_id, - user_id=user_id, - db_session=db_session, - is_shared=is_shared, - ) - except ValueError: - raise ValueError("Chat session does not exist or has been deleted") - - # for chat-seeding: if the session is unassigned, assign it now. This is done here - # to avoid another back and forth between FE -> BE before starting the first - # message generation - if chat_session.user_id is None and user_id is not None: - chat_session.user_id = user_id - db_session.commit() - - session_messages = get_chat_messages_by_session( - chat_session_id=session_id, - user_id=user_id, - db_session=db_session, - # we already did a permission check above with the call to - # `get_chat_session_by_id`, so we can skip it here - skip_permission_check=True, - # we need the tool call objs anyways, so just fetch them in a single call - prefetch_tool_calls=True, - ) - - return ChatSessionDetailResponse( - chat_session_id=session_id, - description=chat_session.description, - persona_id=chat_session.persona_id, - persona_name=chat_session.persona.name, - current_alternate_model=chat_session.current_alternate_model, - messages=[ - translate_db_message_to_chat_message_detail( - msg, remove_doc_content=is_shared # if shared, don't leak doc content - ) - for msg in session_messages - ], - time_created=chat_session.time_created, - shared_status=chat_session.shared_status, - ) - - -@router.post("/create-chat-session") -def create_new_chat_session( - chat_session_creation_request: ChatSessionCreationRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> CreateChatSessionID: - user_id = user.id if user is not None else None - try: - new_chat_session = create_chat_session( - db_session=db_session, - description=chat_session_creation_request.description - or "", # Leave the naming till later to prevent delay - user_id=user_id, - persona_id=chat_session_creation_request.persona_id, - ) - except Exception as e: - logger.exception(e) - raise HTTPException(status_code=400, detail="Invalid Persona provided.") - - return CreateChatSessionID(chat_session_id=new_chat_session.id) - - -@router.put("/rename-chat-session") -def rename_chat_session( - rename_req: ChatRenameRequest, - request: Request, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> RenameChatSessionResponse: - name = rename_req.name - chat_session_id = rename_req.chat_session_id - user_id = user.id if user is not None else None - - if name: - update_chat_session( - db_session=db_session, - user_id=user_id, - chat_session_id=chat_session_id, - description=name, - ) - return RenameChatSessionResponse(new_name=name) - - final_msg, history_msgs = create_chat_chain( - chat_session_id=chat_session_id, db_session=db_session - ) - full_history = history_msgs + [final_msg] - - try: - llm, _ = get_default_llms( - additional_headers=get_litellm_additional_request_headers(request.headers) - ) - except GenAIDisabledException: - # This may be longer than what the LLM tends to produce but is the most - # clear thing we can do - return RenameChatSessionResponse(new_name=full_history[0].message) - - new_name = get_renamed_conversation_name(full_history=full_history, llm=llm) - - update_chat_session( - db_session=db_session, - user_id=user_id, - chat_session_id=chat_session_id, - description=new_name, - ) - - return RenameChatSessionResponse(new_name=new_name) - - -@router.patch("/chat-session/{session_id}") -def patch_chat_session( - session_id: int, - chat_session_update_req: ChatSessionUpdateRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user is not None else None - update_chat_session( - db_session=db_session, - user_id=user_id, - chat_session_id=session_id, - sharing_status=chat_session_update_req.sharing_status, - ) - return None - - -@router.delete("/delete-chat-session/{session_id}") -def delete_chat_session_by_id( - session_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user is not None else None - delete_chat_session(user_id, session_id, db_session) - - -async def is_disconnected(request: Request) -> Callable[[], bool]: - main_loop = asyncio.get_event_loop() - - def is_disconnected_sync() -> bool: - future = asyncio.run_coroutine_threadsafe(request.is_disconnected(), main_loop) - try: - return not future.result(timeout=0.01) - except asyncio.TimeoutError: - logger.error("Asyncio timed out") - return True - except Exception as e: - error_msg = str(e) - logger.critical( - f"An unexpected error occured with the disconnect check coroutine: {error_msg}" - ) - return True - - return is_disconnected_sync - - -@router.post("/send-message") -def handle_new_chat_message( - chat_message_req: CreateChatMessageRequest, - request: Request, - user: User | None = Depends(current_user), - _: None = Depends(check_token_rate_limits), - is_disconnected_func: Callable[[], bool] = Depends(is_disconnected), -) -> StreamingResponse: - """This endpoint is both used for all the following purposes: - - Sending a new message in the session - - Regenerating a message in the session (just send the same one again) - - Editing a message (similar to regenerating but sending a different message) - - Kicking off a seeded chat session (set `use_existing_user_message`) - To avoid extra overhead/latency, this assumes (and checks) that previous messages on the path - have already been set as latest""" - logger.debug(f"Received new chat message: {chat_message_req.message}") - - if ( - not chat_message_req.message - and chat_message_req.prompt_id is not None - and not chat_message_req.use_existing_user_message - ): - raise HTTPException(status_code=400, detail="Empty chat message is invalid") - - import json - - def stream_generator() -> Generator[str, None, None]: - try: - for packet in stream_chat_message( - new_msg_req=chat_message_req, - user=user, - use_existing_user_message=chat_message_req.use_existing_user_message, - litellm_additional_headers=get_litellm_additional_request_headers( - request.headers - ), - is_connected=is_disconnected_func, - ): - yield json.dumps(packet) if isinstance(packet, dict) else packet - - except Exception as e: - logger.exception(f"Error in chat message streaming: {e}") - yield json.dumps({"error": str(e)}) - - return StreamingResponse(stream_generator(), media_type="text/event-stream") - - -@router.put("/set-message-as-latest") -def set_message_as_latest( - message_identifier: ChatMessageIdentifier, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user is not None else None - - chat_message = get_chat_message( - chat_message_id=message_identifier.message_id, - user_id=user_id, - db_session=db_session, - ) - - set_as_latest_chat_message( - chat_message=chat_message, - user_id=user_id, - db_session=db_session, - ) - - -@router.post("/create-chat-message-feedback") -def create_chat_feedback( - feedback: ChatFeedbackRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - user_id = user.id if user else None - - create_chat_message_feedback( - is_positive=feedback.is_positive, - feedback_text=feedback.feedback_text, - predefined_feedback=feedback.predefined_feedback, - chat_message_id=feedback.chat_message_id, - user_id=user_id, - db_session=db_session, - ) - - -@router.post("/document-search-feedback") -def create_search_feedback( - feedback: SearchFeedbackRequest, - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - """This endpoint isn't protected - it does not check if the user has access to the document - Users could try changing boosts of arbitrary docs but this does not leak any data. - """ - - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - create_doc_retrieval_feedback( - message_id=feedback.message_id, - document_id=feedback.document_id, - document_rank=feedback.document_rank, - clicked=feedback.click, - feedback=feedback.search_feedback, - document_index=document_index, - db_session=db_session, - ) - - -class MaxSelectedDocumentTokens(BaseModel): - max_tokens: int - - -@router.get("/max-selected-document-tokens") -def get_max_document_tokens( - persona_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> MaxSelectedDocumentTokens: - try: - persona = get_persona_by_id( - persona_id=persona_id, - user=user, - db_session=db_session, - is_for_edit=False, - ) - except ValueError: - raise HTTPException(status_code=404, detail="Persona not found") - - return MaxSelectedDocumentTokens( - max_tokens=compute_max_document_tokens_for_persona(persona), - ) - - -"""Endpoints for chat seeding""" - - -class ChatSeedRequest(BaseModel): - # standard chat session stuff - persona_id: int - prompt_id: int | None = None - - # overrides / seeding - llm_override: LLMOverride | None = None - prompt_override: PromptOverride | None = None - description: str | None = None - message: str | None = None - - # TODO: support this - # initial_message_retrieval_options: RetrievalDetails | None = None - - -class ChatSeedResponse(BaseModel): - redirect_url: str - - -@router.post("/seed-chat-session") -def seed_chat( - chat_seed_request: ChatSeedRequest, - # NOTE: realistically, this will be an API key not an actual user - _: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatSeedResponse: - try: - new_chat_session = create_chat_session( - db_session=db_session, - description=chat_seed_request.description or "", - user_id=None, # this chat session is "unassigned" until a user visits the web UI - persona_id=chat_seed_request.persona_id, - llm_override=chat_seed_request.llm_override, - prompt_override=chat_seed_request.prompt_override, - ) - except Exception as e: - logger.exception(e) - raise HTTPException(status_code=400, detail="Invalid Persona provided.") - - if chat_seed_request.message is not None: - root_message = get_or_create_root_message( - chat_session_id=new_chat_session.id, db_session=db_session - ) - llm, fast_llm = get_llms_for_persona(persona=new_chat_session.persona) - - tokenizer = get_tokenizer( - model_name=llm.config.model_name, - provider_type=llm.config.model_provider, - ) - token_count = len(tokenizer.encode(chat_seed_request.message)) - - create_new_chat_message( - chat_session_id=new_chat_session.id, - parent_message=root_message, - prompt_id=chat_seed_request.prompt_id - or ( - new_chat_session.persona.prompts[0].id - if new_chat_session.persona.prompts - else None - ), - message=chat_seed_request.message, - token_count=token_count, - message_type=MessageType.USER, - db_session=db_session, - ) - - return ChatSeedResponse( - redirect_url=f"{WEB_DOMAIN}/chat?chatId={new_chat_session.id}&seeded=true" - ) - - -"""File upload""" - - -@router.post("/file") -def upload_files_for_chat( - files: list[UploadFile], - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> dict[str, list[FileDescriptor]]: - image_content_types = {"image/jpeg", "image/png", "image/webp"} - text_content_types = { - "text/plain", - "text/csv", - "text/markdown", - "text/x-markdown", - "text/x-config", - "text/tab-separated-values", - "application/json", - "application/xml", - "text/xml", - "application/x-yaml", - } - document_content_types = { - "application/pdf", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "message/rfc822", - "application/epub+zip", - } - - allowed_content_types = image_content_types.union(text_content_types).union( - document_content_types - ) - - for file in files: - if file.content_type not in allowed_content_types: - if file.content_type in image_content_types: - error_detail = "Unsupported image file type. Supported image types include .jpg, .jpeg, .png, .webp." - elif file.content_type in text_content_types: - error_detail = "Unsupported text file type. Supported text types include .txt, .csv, .md, .mdx, .conf, " - ".log, .tsv." - else: - error_detail = ( - "Unsupported document file type. Supported document types include .pdf, .docx, .pptx, .xlsx, " - ".json, .xml, .yml, .yaml, .eml, .epub." - ) - raise HTTPException(status_code=400, detail=error_detail) - - if ( - file.content_type in image_content_types - and file.size - and file.size > 20 * 1024 * 1024 - ): - raise HTTPException( - status_code=400, - detail="File size must be less than 20MB", - ) - - file_store = get_default_file_store(db_session) - - file_info: list[tuple[str, str | None, ChatFileType]] = [] - for file in files: - if file.content_type in image_content_types: - file_type = ChatFileType.IMAGE - elif file.content_type in document_content_types: - file_type = ChatFileType.DOC - else: - file_type = ChatFileType.PLAIN_TEXT - - # store the raw file - file_id = str(uuid.uuid4()) - file_store.save_file( - file_name=file_id, - content=file.file, - display_name=file.filename, - file_origin=FileOrigin.CHAT_UPLOAD, - file_type=file.content_type or file_type.value, - ) - - # if the file is a doc, extract text and store that so we don't need - # to re-extract it every time we send a message - if file_type == ChatFileType.DOC: - extracted_text = extract_file_text(file_name=file.filename, file=file.file) - text_file_id = str(uuid.uuid4()) - file_store.save_file( - file_name=text_file_id, - content=io.BytesIO(extracted_text.encode()), - display_name=file.filename, - file_origin=FileOrigin.CHAT_UPLOAD, - file_type="text/plain", - ) - # for DOC type, just return this for the FileDescriptor - # as we would always use this as the ID to attach to the - # message - file_info.append((text_file_id, file.filename, ChatFileType.PLAIN_TEXT)) - else: - file_info.append((file_id, file.filename, file_type)) - - return { - "files": [ - {"id": file_id, "type": file_type, "name": file_name} - for file_id, file_name, file_type in file_info - ] - } - - -@router.get("/file/{file_id}") -def fetch_chat_file( - file_id: str, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> Response: - file_store = get_default_file_store(db_session) - file_io = file_store.read_file(file_id, mode="b") - # NOTE: specifying "image/jpeg" here, but it still works for pngs - # TODO: do this properly - return Response(content=file_io.read(), media_type="image/jpeg") diff --git a/backend/danswer/server/query_and_chat/models.py b/backend/danswer/server/query_and_chat/models.py deleted file mode 100644 index 55d1094ea86..00000000000 --- a/backend/danswer/server/query_and_chat/models.py +++ /dev/null @@ -1,218 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import BaseModel -from pydantic import model_validator - -from danswer.chat.models import RetrievalDocs -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import MessageType -from danswer.configs.constants import SearchFeedbackType -from danswer.db.enums import ChatSessionSharedStatus -from danswer.file_store.models import FileDescriptor -from danswer.llm.override_models import LLMOverride -from danswer.llm.override_models import PromptOverride -from danswer.search.models import BaseFilters -from danswer.search.models import ChunkContext -from danswer.search.models import RetrievalDetails -from danswer.search.models import SearchDoc -from danswer.search.models import Tag -from danswer.tools.models import ToolCallFinalResult - - -class SourceTag(Tag): - source: DocumentSource - - -class TagResponse(BaseModel): - tags: list[SourceTag] - - -class SimpleQueryRequest(BaseModel): - query: str - - -class UpdateChatSessionThreadRequest(BaseModel): - # If not specified, use Danswer default persona - chat_session_id: int - new_alternate_model: str - - -class ChatSessionCreationRequest(BaseModel): - # If not specified, use Danswer default persona - persona_id: int = 0 - description: str | None = None - - -class CreateChatSessionID(BaseModel): - chat_session_id: int - - -class ChatFeedbackRequest(BaseModel): - chat_message_id: int - is_positive: bool | None = None - feedback_text: str | None = None - predefined_feedback: str | None = None - - @model_validator(mode="after") - def check_is_positive_or_feedback_text(self) -> "ChatFeedbackRequest": - if self.is_positive is None and self.feedback_text is None: - raise ValueError("Empty feedback received.") - return self - - -""" -Currently the different branches are generated by changing the search query - - [Empty Root Message] This allows the first message to be branched as well - / | \ -[First Message] [First Message Edit 1] [First Message Edit 2] - | | -[Second Message] [Second Message of Edit 1 Branch] -""" - - -class CreateChatMessageRequest(ChunkContext): - """Before creating messages, be sure to create a chat_session and get an id""" - - chat_session_id: int - # This is the primary-key (unique identifier) for the previous message of the tree - parent_message_id: int | None - # New message contents - message: str - # Files that we should attach to this message - file_descriptors: list[FileDescriptor] - # If no prompt provided, uses the largest prompt of the chat session - # but really this should be explicitly specified, only in the simplified APIs is this inferred - # Use prompt_id 0 to use the system default prompt which is Answer-Question - prompt_id: int | None - # If search_doc_ids provided, then retrieval options are unused - search_doc_ids: list[int] | None - retrieval_options: RetrievalDetails | None - # allows the caller to specify the exact search query they want to use - # will disable Query Rewording if specified - query_override: str | None = None - - # enables additional handling to ensure that we regenerate with a given user message ID - regenerate: bool | None = None - - # allows the caller to override the Persona / Prompt - # these do not persist in the chat thread details - llm_override: LLMOverride | None = None - prompt_override: PromptOverride | None = None - - # allow user to specify an alternate assistnat - alternate_assistant_id: int | None = None - - # used for seeded chats to kick off the generation of an AI answer - use_existing_user_message: bool = False - - @model_validator(mode="after") - def check_search_doc_ids_or_retrieval_options(self) -> "CreateChatMessageRequest": - if self.search_doc_ids is None and self.retrieval_options is None: - raise ValueError( - "Either search_doc_ids or retrieval_options must be provided, but not both or neither." - ) - return self - - -class ChatMessageIdentifier(BaseModel): - message_id: int - - -class ChatRenameRequest(BaseModel): - chat_session_id: int - name: str | None = None - - -class ChatSessionUpdateRequest(BaseModel): - sharing_status: ChatSessionSharedStatus - - -class RenameChatSessionResponse(BaseModel): - new_name: str # This is only really useful if the name is generated - - -class ChatSessionDetails(BaseModel): - id: int - name: str - persona_id: int - time_created: str - shared_status: ChatSessionSharedStatus - folder_id: int | None = None - current_alternate_model: str | None = None - - -class ChatSessionsResponse(BaseModel): - sessions: list[ChatSessionDetails] - - -class SearchFeedbackRequest(BaseModel): - message_id: int - document_id: str - document_rank: int - click: bool - search_feedback: SearchFeedbackType | None = None - - @model_validator(mode="after") - def check_click_or_search_feedback(self) -> "SearchFeedbackRequest": - click, feedback = self.click, self.search_feedback - - if click is False and feedback is None: - raise ValueError("Empty feedback received.") - return self - - -class ChatMessageDetail(BaseModel): - message_id: int - parent_message: int | None = None - latest_child_message: int | None = None - message: str - rephrased_query: str | None = None - context_docs: RetrievalDocs | None = None - message_type: MessageType - time_sent: datetime - overridden_model: str | None - alternate_assistant_id: int | None = None - # Dict mapping citation number to db_doc_id - chat_session_id: int | None = None - citations: dict[int, int] | None = None - files: list[FileDescriptor] - tool_calls: list[ToolCallFinalResult] - - def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore - initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore - initial_dict["time_sent"] = self.time_sent.isoformat() - return initial_dict - - -class SearchSessionDetailResponse(BaseModel): - search_session_id: int - description: str - documents: list[SearchDoc] - messages: list[ChatMessageDetail] - - -class ChatSessionDetailResponse(BaseModel): - chat_session_id: int - description: str - persona_id: int - persona_name: str - messages: list[ChatMessageDetail] - time_created: datetime - shared_status: ChatSessionSharedStatus - current_alternate_model: str | None - - -class QueryValidationResponse(BaseModel): - reasoning: str - answerable: bool - - -class AdminSearchRequest(BaseModel): - query: str - filters: BaseFilters - - -class AdminSearchResponse(BaseModel): - documents: list[SearchDoc] diff --git a/backend/danswer/server/query_and_chat/query_backend.py b/backend/danswer/server/query_and_chat/query_backend.py deleted file mode 100644 index 704b16d5eaa..00000000000 --- a/backend/danswer/server/query_and_chat/query_backend.py +++ /dev/null @@ -1,256 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session - -from danswer.auth.users import current_curator_or_admin_user -from danswer.auth.users import current_user -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import MessageType -from danswer.db.chat import get_chat_messages_by_session -from danswer.db.chat import get_chat_session_by_id -from danswer.db.chat import get_chat_sessions_by_user -from danswer.db.chat import get_first_messages_for_chat_sessions -from danswer.db.chat import get_search_docs_for_chat_message -from danswer.db.chat import translate_db_message_to_chat_message_detail -from danswer.db.chat import translate_db_search_doc_to_server_search_doc -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.search_settings import get_current_search_settings -from danswer.db.tag import get_tags_by_value_prefix_for_source_types -from danswer.document_index.factory import get_default_document_index -from danswer.document_index.vespa.index import VespaIndex -from danswer.one_shot_answer.answer_question import stream_search_answer -from danswer.one_shot_answer.models import DirectQARequest -from danswer.search.models import IndexFilters -from danswer.search.models import SearchDoc -from danswer.search.preprocessing.access_filters import build_access_filters_for_user -from danswer.search.utils import chunks_or_sections_to_search_docs -from danswer.secondary_llm_flows.query_validation import get_query_answerability -from danswer.secondary_llm_flows.query_validation import stream_query_answerability -from danswer.server.query_and_chat.models import AdminSearchRequest -from danswer.server.query_and_chat.models import AdminSearchResponse -from danswer.server.query_and_chat.models import ChatSessionDetails -from danswer.server.query_and_chat.models import ChatSessionsResponse -from danswer.server.query_and_chat.models import QueryValidationResponse -from danswer.server.query_and_chat.models import SearchSessionDetailResponse -from danswer.server.query_and_chat.models import SimpleQueryRequest -from danswer.server.query_and_chat.models import SourceTag -from danswer.server.query_and_chat.models import TagResponse -from danswer.server.query_and_chat.token_limit import check_token_rate_limits -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -admin_router = APIRouter(prefix="/admin") -basic_router = APIRouter(prefix="/query") - - -@admin_router.post("/search") -def admin_search( - question: AdminSearchRequest, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> AdminSearchResponse: - query = question.query - logger.notice(f"Received admin search query: {query}") - user_acl_filters = build_access_filters_for_user(user, db_session) - final_filters = IndexFilters( - source_type=question.filters.source_type, - document_set=question.filters.document_set, - time_cutoff=question.filters.time_cutoff, - tags=question.filters.tags, - access_control_list=user_acl_filters, - ) - search_settings = get_current_search_settings(db_session) - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, secondary_index_name=None - ) - if not isinstance(document_index, VespaIndex): - raise HTTPException( - status_code=400, - detail="Cannot use admin-search when using a non-Vespa document index", - ) - matching_chunks = document_index.admin_retrieval(query=query, filters=final_filters) - - documents = chunks_or_sections_to_search_docs(matching_chunks) - - # Deduplicate documents by id - deduplicated_documents: list[SearchDoc] = [] - seen_documents: set[str] = set() - for document in documents: - if document.document_id not in seen_documents: - deduplicated_documents.append(document) - seen_documents.add(document.document_id) - return AdminSearchResponse(documents=deduplicated_documents) - - -@basic_router.get("/valid-tags") -def get_tags( - match_pattern: str | None = None, - # If this is empty or None, then tags for all sources are considered - sources: list[DocumentSource] | None = None, - allow_prefix: bool = True, # This is currently the only option - limit: int = 50, - _: User = Depends(current_user), - db_session: Session = Depends(get_session), -) -> TagResponse: - if not allow_prefix: - raise NotImplementedError("Cannot disable prefix match for now") - - db_tags = get_tags_by_value_prefix_for_source_types( - tag_key_prefix=match_pattern, - tag_value_prefix=match_pattern, - sources=sources, - limit=limit, - db_session=db_session, - ) - server_tags = [ - SourceTag( - tag_key=db_tag.tag_key, tag_value=db_tag.tag_value, source=db_tag.source - ) - for db_tag in db_tags - ] - return TagResponse(tags=server_tags) - - -@basic_router.post("/query-validation") -def query_validation( - simple_query: SimpleQueryRequest, _: User = Depends(current_user) -) -> QueryValidationResponse: - # Note if weak model prompt is chosen, this check does not occur and will simply return that - # the query is valid, this is because weaker models cannot really handle this task well. - # Additionally, some weak model servers cannot handle concurrent inferences. - logger.notice(f"Validating query: {simple_query.query}") - reasoning, answerable = get_query_answerability(simple_query.query) - return QueryValidationResponse(reasoning=reasoning, answerable=answerable) - - -@basic_router.get("/user-searches") -def get_user_search_sessions( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatSessionsResponse: - user_id = user.id if user is not None else None - - try: - search_sessions = get_chat_sessions_by_user( - user_id=user_id, deleted=False, db_session=db_session, only_one_shot=True - ) - except ValueError: - raise HTTPException( - status_code=404, detail="Chat session does not exist or has been deleted" - ) - - search_session_ids = [chat.id for chat in search_sessions] - first_messages = get_first_messages_for_chat_sessions( - search_session_ids, db_session - ) - first_messages_dict = dict(first_messages) - - response = ChatSessionsResponse( - sessions=[ - ChatSessionDetails( - id=search.id, - name=first_messages_dict.get(search.id, search.description), - persona_id=search.persona_id, - time_created=search.time_created.isoformat(), - shared_status=search.shared_status, - folder_id=search.folder_id, - current_alternate_model=search.current_alternate_model, - ) - for search in search_sessions - ] - ) - return response - - -@basic_router.get("/search-session/{session_id}") -def get_search_session( - session_id: int, - is_shared: bool = False, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> SearchSessionDetailResponse: - user_id = user.id if user is not None else None - - try: - search_session = get_chat_session_by_id( - chat_session_id=session_id, - user_id=user_id, - db_session=db_session, - is_shared=is_shared, - ) - except ValueError: - raise ValueError("Search session does not exist or has been deleted") - - session_messages = get_chat_messages_by_session( - chat_session_id=session_id, - user_id=user_id, - db_session=db_session, - # we already did a permission check above with the call to - # `get_chat_session_by_id`, so we can skip it here - skip_permission_check=True, - # we need the tool call objs anyways, so just fetch them in a single call - prefetch_tool_calls=True, - ) - docs_response: list[SearchDoc] = [] - for message in session_messages: - if ( - message.message_type == MessageType.ASSISTANT - or message.message_type == MessageType.SYSTEM - ): - docs = get_search_docs_for_chat_message( - db_session=db_session, chat_message_id=message.id - ) - for doc in docs: - server_doc = translate_db_search_doc_to_server_search_doc(doc) - docs_response.append(server_doc) - - response = SearchSessionDetailResponse( - search_session_id=session_id, - description=search_session.description, - documents=docs_response, - messages=[ - translate_db_message_to_chat_message_detail( - msg, remove_doc_content=is_shared # if shared, don't leak doc content - ) - for msg in session_messages - ], - ) - return response - - -# NOTE No longer used, after search/chat redesign. -# No search responses are answered with a conversational generative AI response -@basic_router.post("/stream-query-validation") -def stream_query_validation( - simple_query: SimpleQueryRequest, _: User = Depends(current_user) -) -> StreamingResponse: - # Note if weak model prompt is chosen, this check does not occur and will simply return that - # the query is valid, this is because weaker models cannot really handle this task well. - # Additionally, some weak model servers cannot handle concurrent inferences. - logger.notice(f"Validating query: {simple_query.query}") - return StreamingResponse( - stream_query_answerability(simple_query.query), media_type="application/json" - ) - - -@basic_router.post("/stream-answer-with-quote") -def get_answer_with_quote( - query_request: DirectQARequest, - user: User = Depends(current_user), - _: None = Depends(check_token_rate_limits), -) -> StreamingResponse: - query = query_request.messages[0].message - - logger.notice(f"Received query for one shot answer with quotes: {query}") - - packets = stream_search_answer( - query_req=query_request, - user=user, - max_document_tokens=None, - max_history_tokens=0, - ) - return StreamingResponse(packets, media_type="application/json") diff --git a/backend/danswer/server/query_and_chat/token_limit.py b/backend/danswer/server/query_and_chat/token_limit.py deleted file mode 100644 index 3f5d76bac7f..00000000000 --- a/backend/danswer/server/query_and_chat/token_limit.py +++ /dev/null @@ -1,135 +0,0 @@ -from collections.abc import Sequence -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from functools import lru_cache - -from dateutil import tz -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.db.engine import get_session_context_manager -from danswer.db.models import ChatMessage -from danswer.db.models import ChatSession -from danswer.db.models import TokenRateLimit -from danswer.db.models import User -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import fetch_versioned_implementation -from ee.danswer.db.token_limit import fetch_all_global_token_rate_limits - - -logger = setup_logger() - - -TOKEN_BUDGET_UNIT = 1_000 - - -def check_token_rate_limits( - user: User | None = Depends(current_user), -) -> None: - # short circuit if no rate limits are set up - # NOTE: result of `any_rate_limit_exists` is cached, so this call is fast 99% of the time - if not any_rate_limit_exists(): - return - - versioned_rate_limit_strategy = fetch_versioned_implementation( - "danswer.server.query_and_chat.token_limit", "_check_token_rate_limits" - ) - return versioned_rate_limit_strategy(user) - - -def _check_token_rate_limits(_: User | None) -> None: - _user_is_rate_limited_by_global() - - -""" -Global rate limits -""" - - -def _user_is_rate_limited_by_global() -> None: - with get_session_context_manager() as db_session: - global_rate_limits = fetch_all_global_token_rate_limits( - db_session=db_session, enabled_only=True, ordered=False - ) - - if global_rate_limits: - global_cutoff_time = _get_cutoff_time(global_rate_limits) - global_usage = _fetch_global_usage(global_cutoff_time, db_session) - - if _is_rate_limited(global_rate_limits, global_usage): - raise HTTPException( - status_code=429, - detail="Token budget exceeded for organization. Try again later.", - ) - - -def _fetch_global_usage( - cutoff_time: datetime, db_session: Session -) -> Sequence[tuple[datetime, int]]: - """ - Fetch global token usage within the cutoff time, grouped by minute - """ - result = db_session.execute( - select( - func.date_trunc("minute", ChatMessage.time_sent), - func.sum(ChatMessage.token_count), - ) - .join(ChatSession, ChatMessage.chat_session_id == ChatSession.id) - .filter( - ChatMessage.time_sent >= cutoff_time, - ) - .group_by(func.date_trunc("minute", ChatMessage.time_sent)) - ).all() - - return [(row[0], row[1]) for row in result] - - -""" -Common functions -""" - - -def _get_cutoff_time(rate_limits: Sequence[TokenRateLimit]) -> datetime: - max_period_hours = max(rate_limit.period_hours for rate_limit in rate_limits) - return datetime.now(tz=timezone.utc) - timedelta(hours=max_period_hours) - - -def _is_rate_limited( - rate_limits: Sequence[TokenRateLimit], usage: Sequence[tuple[datetime, int]] -) -> bool: - """ - If at least one rate limit is exceeded, return True - """ - for rate_limit in rate_limits: - tokens_used = sum( - u_token_count - for u_date, u_token_count in usage - if u_date - >= datetime.now(tz=tz.UTC) - timedelta(hours=rate_limit.period_hours) - ) - - if tokens_used >= rate_limit.token_budget * TOKEN_BUDGET_UNIT: - return True - - return False - - -@lru_cache() -def any_rate_limit_exists() -> bool: - """Checks if any rate limit exists in the database. Is cached, so that if no rate limits - are setup, we don't have any effect on average query latency.""" - logger.debug("Checking for any rate limits...") - with get_session_context_manager() as db_session: - return ( - db_session.scalar( - select(TokenRateLimit.id).where( - TokenRateLimit.enabled == True # noqa: E712 - ) - ) - is not None - ) diff --git a/backend/danswer/server/settings/api.py b/backend/danswer/server/settings/api.py deleted file mode 100644 index 3330f6cc5ff..00000000000 --- a/backend/danswer/server/settings/api.py +++ /dev/null @@ -1,145 +0,0 @@ -from typing import cast - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_user -from danswer.auth.users import is_user_admin -from danswer.configs.constants import KV_REINDEX_KEY -from danswer.configs.constants import NotificationType -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.notification import create_notification -from danswer.db.notification import dismiss_all_notifications -from danswer.db.notification import dismiss_notification -from danswer.db.notification import get_notification_by_id -from danswer.db.notification import get_notifications -from danswer.db.notification import update_notification_last_shown -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.server.settings.models import Notification -from danswer.server.settings.models import Settings -from danswer.server.settings.models import UserSettings -from danswer.server.settings.store import load_settings -from danswer.server.settings.store import store_settings -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -admin_router = APIRouter(prefix="/admin/settings") -basic_router = APIRouter(prefix="/settings") - - -@admin_router.put("") -def put_settings( - settings: Settings, _: User | None = Depends(current_admin_user) -) -> None: - try: - settings.check_validity() - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - store_settings(settings) - - -@basic_router.get("") -def fetch_settings( - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> UserSettings: - """Settings and notifications are stuffed into this single endpoint to reduce number of - Postgres calls""" - general_settings = load_settings() - user_notifications = get_user_notifications(user, db_session) - - try: - kv_store = get_dynamic_config_store() - needs_reindexing = cast(bool, kv_store.load(KV_REINDEX_KEY)) - except ConfigNotFoundError: - needs_reindexing = False - - return UserSettings( - **general_settings.model_dump(), - notifications=user_notifications, - needs_reindexing=needs_reindexing - ) - - -@basic_router.post("/notifications/{notification_id}/dismiss") -def dismiss_notification_endpoint( - notification_id: int, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> None: - try: - notification = get_notification_by_id(notification_id, user, db_session) - except PermissionError: - raise HTTPException( - status_code=403, detail="Not authorized to dismiss this notification" - ) - except ValueError: - raise HTTPException(status_code=404, detail="Notification not found") - - dismiss_notification(notification, db_session) - - -def get_user_notifications( - user: User | None, db_session: Session -) -> list[Notification]: - """Get notifications for the user, currently the logic is very specific to the reindexing flag""" - is_admin = is_user_admin(user) - if not is_admin: - # Reindexing flag should only be shown to admins, basic users can't trigger it anyway - return [] - - kv_store = get_dynamic_config_store() - try: - needs_index = cast(bool, kv_store.load(KV_REINDEX_KEY)) - if not needs_index: - dismiss_all_notifications( - notif_type=NotificationType.REINDEX, db_session=db_session - ) - return [] - except ConfigNotFoundError: - # If something goes wrong and the flag is gone, better to not start a reindexing - # it's a heavyweight long running job and maybe this flag is cleaned up later - logger.warning("Could not find reindex flag") - return [] - - try: - # Need a transaction in order to prevent under-counting current notifications - db_session.begin() - - reindex_notifs = get_notifications( - user=user, notif_type=NotificationType.REINDEX, db_session=db_session - ) - - if not reindex_notifs: - notif = create_notification( - user=user, - notif_type=NotificationType.REINDEX, - db_session=db_session, - ) - db_session.flush() - db_session.commit() - return [Notification.from_model(notif)] - - if len(reindex_notifs) > 1: - logger.error("User has multiple reindex notifications") - - reindex_notif = reindex_notifs[0] - update_notification_last_shown( - notification=reindex_notif, db_session=db_session - ) - - db_session.commit() - return [Notification.from_model(reindex_notif)] - except SQLAlchemyError: - logger.exception("Error while processing notifications") - db_session.rollback() - return [] diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py deleted file mode 100644 index e999e7294e9..00000000000 --- a/backend/danswer/server/settings/models.py +++ /dev/null @@ -1,64 +0,0 @@ -from datetime import datetime -from enum import Enum - -from pydantic import BaseModel - -from danswer.configs.constants import NotificationType -from danswer.db.models import Notification as NotificationDBModel - - -class PageType(str, Enum): - CHAT = "chat" - SEARCH = "search" - - -class Notification(BaseModel): - id: int - notif_type: NotificationType - dismissed: bool - last_shown: datetime - first_shown: datetime - - @classmethod - def from_model(cls, notif: NotificationDBModel) -> "Notification": - return cls( - id=notif.id, - notif_type=notif.notif_type, - dismissed=notif.dismissed, - last_shown=notif.last_shown, - first_shown=notif.first_shown, - ) - - -class Settings(BaseModel): - """General settings""" - - chat_page_enabled: bool = True - search_page_enabled: bool = True - default_page: PageType = PageType.SEARCH - maximum_chat_retention_days: int | None = None - - def check_validity(self) -> None: - chat_page_enabled = self.chat_page_enabled - search_page_enabled = self.search_page_enabled - default_page = self.default_page - - if chat_page_enabled is False and search_page_enabled is False: - raise ValueError( - "One of `search_page_enabled` and `chat_page_enabled` must be True." - ) - - if default_page == PageType.CHAT and chat_page_enabled is False: - raise ValueError( - "The default page cannot be 'chat' if the chat page is disabled." - ) - - if default_page == PageType.SEARCH and search_page_enabled is False: - raise ValueError( - "The default page cannot be 'search' if the search page is disabled." - ) - - -class UserSettings(Settings): - notifications: list[Notification] - needs_reindexing: bool diff --git a/backend/danswer/server/settings/store.py b/backend/danswer/server/settings/store.py deleted file mode 100644 index 6f2872f40f9..00000000000 --- a/backend/danswer/server/settings/store.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import cast - -from danswer.configs.constants import KV_SETTINGS_KEY -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.server.settings.models import Settings - - -def load_settings() -> Settings: - dynamic_config_store = get_dynamic_config_store() - try: - settings = Settings(**cast(dict, dynamic_config_store.load(KV_SETTINGS_KEY))) - except ConfigNotFoundError: - settings = Settings() - dynamic_config_store.store(KV_SETTINGS_KEY, settings.model_dump()) - - return settings - - -def store_settings(settings: Settings) -> None: - get_dynamic_config_store().store(KV_SETTINGS_KEY, settings.model_dump()) diff --git a/backend/danswer/server/token_rate_limits/api.py b/backend/danswer/server/token_rate_limits/api.py deleted file mode 100644 index 245e3391410..00000000000 --- a/backend/danswer/server/token_rate_limits/api.py +++ /dev/null @@ -1,79 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -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.server.query_and_chat.token_limit import any_rate_limit_exists -from danswer.server.token_rate_limits.models import TokenRateLimitArgs -from danswer.server.token_rate_limits.models import TokenRateLimitDisplay -from ee.danswer.db.token_limit import delete_token_rate_limit -from ee.danswer.db.token_limit import fetch_all_global_token_rate_limits -from ee.danswer.db.token_limit import insert_global_token_rate_limit -from ee.danswer.db.token_limit import update_token_rate_limit - -router = APIRouter(prefix="/admin/token-rate-limits") - - -""" -Global Token Limit Settings -""" - - -@router.get("/global") -def get_global_token_limit_settings( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[TokenRateLimitDisplay]: - return [ - TokenRateLimitDisplay.from_db(token_rate_limit) - for token_rate_limit in fetch_all_global_token_rate_limits(db_session) - ] - - -@router.post("/global") -def create_global_token_limit_settings( - token_limit_settings: TokenRateLimitArgs, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> TokenRateLimitDisplay: - rate_limit_display = TokenRateLimitDisplay.from_db( - insert_global_token_rate_limit(db_session, token_limit_settings) - ) - # clear cache in case this was the first rate limit created - any_rate_limit_exists.cache_clear() - return rate_limit_display - - -""" -General Token Limit Settings -""" - - -@router.put("/rate-limit/{token_rate_limit_id}") -def update_token_limit_settings( - token_rate_limit_id: int, - token_limit_settings: TokenRateLimitArgs, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> TokenRateLimitDisplay: - return TokenRateLimitDisplay.from_db( - update_token_rate_limit( - db_session=db_session, - token_rate_limit_id=token_rate_limit_id, - token_rate_limit_settings=token_limit_settings, - ) - ) - - -@router.delete("/rate-limit/{token_rate_limit_id}") -def delete_token_limit_settings( - token_rate_limit_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - return delete_token_rate_limit( - db_session=db_session, - token_rate_limit_id=token_rate_limit_id, - ) diff --git a/backend/danswer/server/token_rate_limits/models.py b/backend/danswer/server/token_rate_limits/models.py deleted file mode 100644 index 351abe92e7e..00000000000 --- a/backend/danswer/server/token_rate_limits/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel - -from danswer.db.models import TokenRateLimit - - -class TokenRateLimitArgs(BaseModel): - enabled: bool - token_budget: int - period_hours: int - - -class TokenRateLimitDisplay(BaseModel): - token_id: int - enabled: bool - token_budget: int - period_hours: int - - @classmethod - def from_db(cls, token_rate_limit: TokenRateLimit) -> "TokenRateLimitDisplay": - return cls( - token_id=token_rate_limit.id, - enabled=token_rate_limit.enabled, - token_budget=token_rate_limit.token_budget, - period_hours=token_rate_limit.period_hours, - ) diff --git a/backend/danswer/server/utils.py b/backend/danswer/server/utils.py deleted file mode 100644 index bf535661878..00000000000 --- a/backend/danswer/server/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -import json -from typing import Any - - -def get_json_line(json_dict: dict) -> str: - return json.dumps(json_dict) + "\n" - - -def mask_string(sensitive_str: str) -> str: - return "****...**" + sensitive_str[-4:] - - -def mask_credential_dict(credential_dict: dict[str, Any]) -> dict[str, str]: - masked_creds = {} - for key, val in credential_dict.items(): - if not isinstance(val, str): - raise ValueError( - f"Unable to mask credentials of type other than string, cannot process request." - f"Recieved type: {type(val)}" - ) - - masked_creds[key] = mask_string(val) - return masked_creds diff --git a/backend/danswer/tools/built_in_tools.py b/backend/danswer/tools/built_in_tools.py deleted file mode 100644 index 99b2ae3bbb6..00000000000 --- a/backend/danswer/tools/built_in_tools.py +++ /dev/null @@ -1,191 +0,0 @@ -import os -from typing import Type -from typing_extensions import TypedDict # noreorder - -from sqlalchemy import not_ -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import Persona -from danswer.db.models import Tool as ToolDBModel -from danswer.tools.images.image_generation_tool import ImageGenerationTool -from danswer.tools.internet_search.internet_search_tool import InternetSearchTool -from danswer.tools.search.search_tool import SearchTool -from danswer.tools.tool import Tool -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class InCodeToolInfo(TypedDict): - cls: Type[Tool] - description: str - in_code_tool_id: str - display_name: str - - -BUILT_IN_TOOLS: list[InCodeToolInfo] = [ - InCodeToolInfo( - cls=SearchTool, - description="The Search Tool allows the Assistant to search through connected knowledge to help build an answer.", - in_code_tool_id=SearchTool.__name__, - display_name=SearchTool._DISPLAY_NAME, - ), - InCodeToolInfo( - cls=ImageGenerationTool, - description=( - "The Image Generation Tool allows the assistant to use DALL-E 3 to generate images. " - "The tool will be used when the user asks the assistant to generate an image." - ), - in_code_tool_id=ImageGenerationTool.__name__, - display_name=ImageGenerationTool._DISPLAY_NAME, - ), - # don't show the InternetSearchTool as an option if BING_API_KEY is not available - *( - [ - InCodeToolInfo( - cls=InternetSearchTool, - description=( - "The Internet Search Tool allows the assistant " - "to perform internet searches for up-to-date information." - ), - in_code_tool_id=InternetSearchTool.__name__, - display_name=InternetSearchTool._DISPLAY_NAME, - ) - ] - if os.environ.get("BING_API_KEY") - else [] - ), -] - - -def load_builtin_tools(db_session: Session) -> None: - existing_in_code_tools = db_session.scalars( - select(ToolDBModel).where(not_(ToolDBModel.in_code_tool_id.is_(None))) - ).all() - in_code_tool_id_to_tool = { - tool.in_code_tool_id: tool for tool in existing_in_code_tools - } - - # Add or update existing tools - for tool_info in BUILT_IN_TOOLS: - tool_name = tool_info["cls"].__name__ - tool = in_code_tool_id_to_tool.get(tool_info["in_code_tool_id"]) - if tool: - # Update existing tool - tool.name = tool_name - tool.description = tool_info["description"] - tool.display_name = tool_info["display_name"] - logger.notice(f"Updated tool: {tool_name}") - else: - # Add new tool - new_tool = ToolDBModel( - name=tool_name, - description=tool_info["description"], - display_name=tool_info["display_name"], - in_code_tool_id=tool_info["in_code_tool_id"], - ) - db_session.add(new_tool) - logger.notice(f"Added new tool: {tool_name}") - - # Remove tools that are no longer in BUILT_IN_TOOLS - built_in_ids = {tool_info["in_code_tool_id"] for tool_info in BUILT_IN_TOOLS} - for tool_id, tool in list(in_code_tool_id_to_tool.items()): - if tool_id not in built_in_ids: - db_session.delete(tool) - logger.notice(f"Removed tool no longer in built-in list: {tool.name}") - - db_session.commit() - logger.notice("All built-in tools are loaded/verified.") - - -def auto_add_search_tool_to_personas(db_session: Session) -> None: - """ - Automatically adds the SearchTool to all Persona objects in the database that have - `num_chunks` either unset or set to a value that isn't 0. This is done to migrate - Persona objects that were created before the concept of Tools were added. - """ - # Fetch the SearchTool from the database based on in_code_tool_id from BUILT_IN_TOOLS - search_tool_id = next( - ( - tool["in_code_tool_id"] - for tool in BUILT_IN_TOOLS - if tool["cls"].__name__ == SearchTool.__name__ - ), - None, - ) - if not search_tool_id: - raise RuntimeError("SearchTool not found in the BUILT_IN_TOOLS list.") - - search_tool = db_session.execute( - select(ToolDBModel).where(ToolDBModel.in_code_tool_id == search_tool_id) - ).scalar_one_or_none() - - if not search_tool: - raise RuntimeError("SearchTool not found in the database.") - - # Fetch all Personas that need the SearchTool added - personas_to_update = ( - db_session.execute( - select(Persona).where( - or_(Persona.num_chunks.is_(None), Persona.num_chunks != 0) - ) - ) - .scalars() - .all() - ) - - # Add the SearchTool to each relevant Persona - for persona in personas_to_update: - if search_tool not in persona.tools: - persona.tools.append(search_tool) - logger.notice(f"Added SearchTool to Persona ID: {persona.id}") - - # Commit changes to the database - db_session.commit() - logger.notice("Completed adding SearchTool to relevant Personas.") - - -_built_in_tools_cache: dict[int, Type[Tool]] | None = None - - -def refresh_built_in_tools_cache(db_session: Session) -> None: - global _built_in_tools_cache - _built_in_tools_cache = {} - all_tool_built_in_tools = ( - db_session.execute( - select(ToolDBModel).where(not_(ToolDBModel.in_code_tool_id.is_(None))) - ) - .scalars() - .all() - ) - for tool in all_tool_built_in_tools: - tool_info = next( - ( - item - for item in BUILT_IN_TOOLS - if item["in_code_tool_id"] == tool.in_code_tool_id - ), - None, - ) - if tool_info: - _built_in_tools_cache[tool.id] = tool_info["cls"] - - -def get_built_in_tool_by_id( - tool_id: int, db_session: Session, force_refresh: bool = False -) -> Type[Tool]: - global _built_in_tools_cache - if _built_in_tools_cache is None or force_refresh: - refresh_built_in_tools_cache(db_session) - - if _built_in_tools_cache is None: - raise RuntimeError( - "Built-in tools cache is None despite being refreshed. Should never happen." - ) - - if tool_id in _built_in_tools_cache: - return _built_in_tools_cache[tool_id] - else: - raise ValueError(f"No built-in tool found in the cache with ID {tool_id}") diff --git a/backend/danswer/tools/custom/base_tool_types.py b/backend/danswer/tools/custom/base_tool_types.py deleted file mode 100644 index 7bef9a572c5..00000000000 --- a/backend/danswer/tools/custom/base_tool_types.py +++ /dev/null @@ -1,2 +0,0 @@ -# should really be `JSON_ro`, but this causes issues with pydantic -ToolResultType = dict | list | str | int | float | bool diff --git a/backend/danswer/tools/custom/custom_tool.py b/backend/danswer/tools/custom/custom_tool.py deleted file mode 100644 index f7cbf236f2b..00000000000 --- a/backend/danswer/tools/custom/custom_tool.py +++ /dev/null @@ -1,243 +0,0 @@ -import json -from collections.abc import Generator -from typing import Any -from typing import cast - -import requests -from langchain_core.messages import HumanMessage -from langchain_core.messages import SystemMessage -from pydantic import BaseModel - -from danswer.dynamic_configs.interface import JSON_ro -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.tools.custom.base_tool_types import ToolResultType -from danswer.tools.custom.custom_tool_prompts import ( - SHOULD_USE_CUSTOM_TOOL_SYSTEM_PROMPT, -) -from danswer.tools.custom.custom_tool_prompts import SHOULD_USE_CUSTOM_TOOL_USER_PROMPT -from danswer.tools.custom.custom_tool_prompts import TOOL_ARG_SYSTEM_PROMPT -from danswer.tools.custom.custom_tool_prompts import TOOL_ARG_USER_PROMPT -from danswer.tools.custom.custom_tool_prompts import USE_TOOL -from danswer.tools.custom.openapi_parsing import MethodSpec -from danswer.tools.custom.openapi_parsing import openapi_to_method_specs -from danswer.tools.custom.openapi_parsing import openapi_to_url -from danswer.tools.custom.openapi_parsing import REQUEST_BODY -from danswer.tools.custom.openapi_parsing import validate_openapi_schema -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -CUSTOM_TOOL_RESPONSE_ID = "custom_tool_response" - - -class CustomToolCallSummary(BaseModel): - tool_name: str - tool_result: ToolResultType - - -class CustomTool(Tool): - def __init__(self, method_spec: MethodSpec, base_url: str) -> None: - self._base_url = base_url - self._method_spec = method_spec - self._tool_definition = self._method_spec.to_tool_definition() - - self._name = self._method_spec.name - self._description = self._method_spec.summary - - @property - def name(self) -> str: - return self._name - - @property - def description(self) -> str: - return self._description - - @property - def display_name(self) -> str: - return self._name - - """For LLMs which support explicit tool calling""" - - def tool_definition(self) -> dict: - return self._tool_definition - - def build_tool_message_content( - self, *args: ToolResponse - ) -> str | list[str | dict[str, Any]]: - response = cast(CustomToolCallSummary, args[0].response) - return json.dumps(response.tool_result) - - """For LLMs which do NOT support explicit tool calling""" - - def get_args_for_non_tool_calling_llm( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - force_run: bool = False, - ) -> dict[str, Any] | None: - if not force_run: - should_use_result = llm.invoke( - [ - SystemMessage(content=SHOULD_USE_CUSTOM_TOOL_SYSTEM_PROMPT), - HumanMessage( - content=SHOULD_USE_CUSTOM_TOOL_USER_PROMPT.format( - history=history, - query=query, - tool_name=self.name, - tool_description=self.description, - ) - ), - ] - ) - if cast(str, should_use_result.content).strip() != USE_TOOL: - return None - - args_result = llm.invoke( - [ - SystemMessage(content=TOOL_ARG_SYSTEM_PROMPT), - HumanMessage( - content=TOOL_ARG_USER_PROMPT.format( - history=history, - query=query, - tool_name=self.name, - tool_description=self.description, - tool_args=self.tool_definition()["function"]["parameters"], - ) - ), - ] - ) - args_result_str = cast(str, args_result.content) - - try: - return json.loads(args_result_str.strip()) - except json.JSONDecodeError: - pass - - # try removing ``` - try: - return json.loads(args_result_str.strip("```")) - except json.JSONDecodeError: - pass - - # try removing ```json - try: - return json.loads(args_result_str.strip("```").strip("json")) - except json.JSONDecodeError: - pass - - # pretend like nothing happened if not parse-able - logger.error( - f"Failed to parse args for '{self.name}' tool. Recieved: {args_result_str}" - ) - return None - - """Actual execution of the tool""" - - def run(self, **kwargs: Any) -> Generator[ToolResponse, None, None]: - request_body = kwargs.get(REQUEST_BODY) - - path_params = {} - for path_param_schema in self._method_spec.get_path_param_schemas(): - path_params[path_param_schema["name"]] = kwargs[path_param_schema["name"]] - - query_params = {} - for query_param_schema in self._method_spec.get_query_param_schemas(): - if query_param_schema["name"] in kwargs: - query_params[query_param_schema["name"]] = kwargs[ - query_param_schema["name"] - ] - - url = self._method_spec.build_url(self._base_url, path_params, query_params) - method = self._method_spec.method - - response = requests.request(method, url, json=request_body) - - yield ToolResponse( - id=CUSTOM_TOOL_RESPONSE_ID, - response=CustomToolCallSummary( - tool_name=self._name, tool_result=response.json() - ), - ) - - def final_result(self, *args: ToolResponse) -> JSON_ro: - return cast(CustomToolCallSummary, args[0].response).tool_result - - -def build_custom_tools_from_openapi_schema( - openapi_schema: dict[str, Any] -) -> list[CustomTool]: - url = openapi_to_url(openapi_schema) - method_specs = openapi_to_method_specs(openapi_schema) - return [CustomTool(method_spec, url) for method_spec in method_specs] - - -if __name__ == "__main__": - import openai - - openapi_schema = { - "openapi": "3.0.0", - "info": { - "version": "1.0.0", - "title": "Assistants API", - "description": "An API for managing assistants", - }, - "servers": [ - {"url": "http://localhost:8080"}, - ], - "paths": { - "/assistant/{assistant_id}": { - "get": { - "summary": "Get a specific Assistant", - "operationId": "getAssistant", - "parameters": [ - { - "name": "assistant_id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - }, - "post": { - "summary": "Create a new Assistant", - "operationId": "createAssistant", - "parameters": [ - { - "name": "assistant_id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "requestBody": { - "required": True, - "content": {"application/json": {"schema": {"type": "object"}}}, - }, - }, - } - }, - } - validate_openapi_schema(openapi_schema) - - tools = build_custom_tools_from_openapi_schema(openapi_schema) - - openai_client = openai.OpenAI() - response = openai_client.chat.completions.create( - model="gpt-4o", - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Can you fetch assistant with ID 10"}, - ], - tools=[tool.tool_definition() for tool in tools], # type: ignore - ) - choice = response.choices[0] - if choice.message.tool_calls: - print(choice.message.tool_calls) - for tool_response in tools[0].run( - **json.loads(choice.message.tool_calls[0].function.arguments) - ): - print(tool_response) diff --git a/backend/danswer/tools/custom/custom_tool_prompt_builder.py b/backend/danswer/tools/custom/custom_tool_prompt_builder.py deleted file mode 100644 index 8016363acc9..00000000000 --- a/backend/danswer/tools/custom/custom_tool_prompt_builder.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import cast - -from danswer.tools.custom.custom_tool import CustomToolCallSummary -from danswer.tools.models import ToolResponse - - -def build_user_message_for_custom_tool_for_non_tool_calling_llm( - query: str, - tool_name: str, - *args: ToolResponse, -) -> str: - tool_run_summary = cast(CustomToolCallSummary, args[0].response).tool_result - return f""" -Here's the result from the {tool_name} tool: - -{tool_run_summary} - -Now respond to the following: - -{query} -""".strip() diff --git a/backend/danswer/tools/custom/custom_tool_prompts.py b/backend/danswer/tools/custom/custom_tool_prompts.py deleted file mode 100644 index 14e8b007ef0..00000000000 --- a/backend/danswer/tools/custom/custom_tool_prompts.py +++ /dev/null @@ -1,57 +0,0 @@ -from danswer.prompts.constants import GENERAL_SEP_PAT - -DONT_USE_TOOL = "Don't use tool" -USE_TOOL = "Use tool" - - -"""Prompts to determine if we should use a custom tool or not.""" - - -SHOULD_USE_CUSTOM_TOOL_SYSTEM_PROMPT = ( - "You are a large language model whose only job is to determine if the system should call an " - "external tool to be able to answer the user's last message." -).strip() - -SHOULD_USE_CUSTOM_TOOL_USER_PROMPT = f""" -Given the conversation history and a follow up query, determine if the system should use the \ -'{{tool_name}}' tool to answer the user's query. The '{{tool_name}}' tool is a tool defined as: '{{tool_description}}'. - -Respond with "{USE_TOOL}" if you think the tool would be helpful in respnding to the users query. -Respond with "{DONT_USE_TOOL}" otherwise. - -Conversation History: -{GENERAL_SEP_PAT} -{{history}} -{GENERAL_SEP_PAT} - -If you are at all unsure, respond with {DONT_USE_TOOL}. -Respond with EXACTLY and ONLY "{DONT_USE_TOOL}" or "{USE_TOOL}" - -Follow up input: -{{query}} -""".strip() - - -"""Prompts to figure out the arguments to pass to a custom tool.""" - - -TOOL_ARG_SYSTEM_PROMPT = ( - "You are a large language model whose only job is to determine the arguments to pass to an " - "external tool." -).strip() - - -TOOL_ARG_USER_PROMPT = f""" -Given the following conversation and a follow up input, generate a \ -dictionary of arguments to pass to the '{{tool_name}}' tool. \ -The '{{tool_name}}' tool is a tool defined as: '{{tool_description}}'. \ -The expected arguments are: {{tool_args}}. - -Conversation: -{{history}} - -Follow up input: -{{query}} - -Respond with ONLY and EXACTLY a JSON object specifying the values of the arguments to pass to the tool. -""".strip() # noqa: F541 diff --git a/backend/danswer/tools/custom/openapi_parsing.py b/backend/danswer/tools/custom/openapi_parsing.py deleted file mode 100644 index b40ea170ceb..00000000000 --- a/backend/danswer/tools/custom/openapi_parsing.py +++ /dev/null @@ -1,225 +0,0 @@ -from typing import Any -from typing import cast - -from pydantic import BaseModel - -REQUEST_BODY = "requestBody" - - -class PathSpec(BaseModel): - path: str - methods: dict[str, Any] - - -class MethodSpec(BaseModel): - name: str - summary: str - path: str - method: str - spec: dict[str, Any] - - def get_request_body_schema(self) -> dict[str, Any]: - content = self.spec.get("requestBody", {}).get("content", {}) - if "application/json" in content: - return content["application/json"].get("schema") - - if content: - raise ValueError( - f"Unsupported content type: '{list(content.keys())[0]}'. " - f"Only 'application/json' is supported." - ) - - return {} - - def get_query_param_schemas(self) -> list[dict[str, Any]]: - return [ - param - for param in self.spec.get("parameters", []) - if "schema" in param and "in" in param and param["in"] == "query" - ] - - def get_path_param_schemas(self) -> list[dict[str, Any]]: - return [ - param - for param in self.spec.get("parameters", []) - if "schema" in param and "in" in param and param["in"] == "path" - ] - - def build_url( - self, base_url: str, path_params: dict[str, str], query_params: dict[str, str] - ) -> str: - url = f"{base_url}{self.path}" - try: - url = url.format(**path_params) - except KeyError as e: - raise ValueError(f"Missing path parameter: {e}") - if query_params: - url += "?" - for param, value in query_params.items(): - url += f"{param}={value}&" - url = url[:-1] - return url - - def to_tool_definition(self) -> dict[str, Any]: - tool_definition: Any = { - "type": "function", - "function": { - "name": self.name, - "description": self.summary, - "parameters": {"type": "object", "properties": {}}, - }, - } - - request_body_schema = self.get_request_body_schema() - if request_body_schema: - tool_definition["function"]["parameters"]["properties"][ - REQUEST_BODY - ] = request_body_schema - - query_param_schemas = self.get_query_param_schemas() - if query_param_schemas: - tool_definition["function"]["parameters"]["properties"].update( - {param["name"]: param["schema"] for param in query_param_schemas} - ) - - path_param_schemas = self.get_path_param_schemas() - if path_param_schemas: - tool_definition["function"]["parameters"]["properties"].update( - {param["name"]: param["schema"] for param in path_param_schemas} - ) - return tool_definition - - def validate_spec(self) -> None: - # Validate url construction - path_param_schemas = self.get_path_param_schemas() - dummy_path_dict = {param["name"]: "value" for param in path_param_schemas} - query_param_schemas = self.get_query_param_schemas() - dummy_query_dict = {param["name"]: "value" for param in query_param_schemas} - self.build_url("", dummy_path_dict, dummy_query_dict) - - # Make sure request body doesn't throw an exception - self.get_request_body_schema() - - # Ensure the method is valid - if not self.method: - raise ValueError("HTTP method is not specified.") - if self.method.upper() not in ["GET", "POST", "PUT", "DELETE", "PATCH"]: - raise ValueError(f"HTTP method '{self.method}' is not supported.") - - -"""Path-level utils""" - - -def openapi_to_path_specs(openapi_spec: dict[str, Any]) -> list[PathSpec]: - path_specs = [] - - for path, methods in openapi_spec.get("paths", {}).items(): - path_specs.append(PathSpec(path=path, methods=methods)) - - return path_specs - - -"""Method-level utils""" - - -def openapi_to_method_specs(openapi_spec: dict[str, Any]) -> list[MethodSpec]: - path_specs = openapi_to_path_specs(openapi_spec) - - method_specs = [] - for path_spec in path_specs: - for method_name, method in path_spec.methods.items(): - name = method.get("operationId") - if not name: - raise ValueError( - f"Operation ID is not specified for {method_name.upper()} {path_spec.path}" - ) - - summary = method.get("summary") or method.get("description") - if not summary: - raise ValueError( - f"Summary is not specified for {method_name.upper()} {path_spec.path}" - ) - - method_specs.append( - MethodSpec( - name=name, - summary=summary, - path=path_spec.path, - method=method_name, - spec=method, - ) - ) - - if not method_specs: - raise ValueError("No methods found in OpenAPI schema") - - return method_specs - - -def openapi_to_url(openapi_schema: dict[str, dict | str]) -> str: - """ - Extract URLs from the servers section of an OpenAPI schema. - - Args: - openapi_schema (Dict[str, Union[Dict, str, List]]): The OpenAPI schema in dictionary format. - - Returns: - List[str]: A list of base URLs. - """ - urls: list[str] = [] - - servers = cast(list[dict[str, Any]], openapi_schema.get("servers", [])) - for server in servers: - url = server.get("url") - if url: - urls.append(url) - - if len(urls) != 1: - raise ValueError( - f"Expected exactly one URL in OpenAPI schema, but found {urls}" - ) - - return urls[0] - - -def validate_openapi_schema(schema: dict[str, Any]) -> None: - """ - Validate the given JSON schema as an OpenAPI schema. - - Parameters: - - schema (dict): The JSON schema to validate. - - Returns: - - bool: True if the schema is valid, False otherwise. - """ - - # check basic structure - if "info" not in schema: - raise ValueError("`info` section is required in OpenAPI schema") - - info = schema["info"] - if "title" not in info: - raise ValueError("`title` is required in `info` section of OpenAPI schema") - if "description" not in info: - raise ValueError( - "`description` is required in `info` section of OpenAPI schema" - ) - - if "openapi" not in schema: - raise ValueError( - "`openapi` field which specifies OpenAPI schema version is required" - ) - openapi_version = schema["openapi"] - if not openapi_version.startswith("3."): - raise ValueError(f"OpenAPI version '{openapi_version}' is not supported") - - if "paths" not in schema: - raise ValueError("`paths` section is required in OpenAPI schema") - - url = openapi_to_url(schema) - if not url: - raise ValueError("OpenAPI schema does not contain a valid URL in `servers`") - - method_specs = openapi_to_method_specs(schema) - for method_spec in method_specs: - method_spec.validate_spec() diff --git a/backend/danswer/tools/force.py b/backend/danswer/tools/force.py deleted file mode 100644 index 445175f3e72..00000000000 --- a/backend/danswer/tools/force.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - -from danswer.tools.tool import Tool - - -class ForceUseTool(BaseModel): - # Could be not a forced usage of the tool but still have args, in which case - # if the tool is called, then those args are applied instead of what the LLM - # wanted to call it with - force_use: bool - tool_name: str - args: dict[str, Any] | None = None - - def build_openai_tool_choice_dict(self) -> dict[str, Any]: - """Build dict in the format that OpenAI expects which tells them to use this tool.""" - return {"type": "function", "function": {"name": self.tool_name}} - - -def filter_tools_for_force_tool_use( - tools: list[Tool], force_use_tool: ForceUseTool -) -> list[Tool]: - if not force_use_tool.force_use: - return tools - - return [tool for tool in tools if tool.name == force_use_tool.tool_name] diff --git a/backend/danswer/tools/images/image_generation_tool.py b/backend/danswer/tools/images/image_generation_tool.py deleted file mode 100644 index fe839b7d68c..00000000000 --- a/backend/danswer/tools/images/image_generation_tool.py +++ /dev/null @@ -1,259 +0,0 @@ -import json -from collections.abc import Generator -from enum import Enum -from typing import Any -from typing import cast - -from litellm import image_generation # type: ignore -from pydantic import BaseModel - -from danswer.chat.chat_utils import combine_message_chain -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.dynamic_configs.interface import JSON_ro -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.headers import build_llm_extra_headers -from danswer.llm.interfaces import LLM -from danswer.llm.utils import build_content_with_imgs -from danswer.llm.utils import message_to_string -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.utils.logger import setup_logger -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - - -logger = setup_logger() - - -IMAGE_GENERATION_RESPONSE_ID = "image_generation_response" - -YES_IMAGE_GENERATION = "Yes Image Generation" -SKIP_IMAGE_GENERATION = "Skip Image Generation" - -IMAGE_GENERATION_TEMPLATE = f""" -Given the conversation history and a follow up query, determine if the system should call \ -an external image generation tool to better answer the latest user input. -Your default response is {SKIP_IMAGE_GENERATION}. - -Respond "{YES_IMAGE_GENERATION}" if: -- The user is asking for an image to be generated. - -Conversation History: -{GENERAL_SEP_PAT} -{{chat_history}} -{GENERAL_SEP_PAT} - -If you are at all unsure, respond with {SKIP_IMAGE_GENERATION}. -Respond with EXACTLY and ONLY "{YES_IMAGE_GENERATION}" or "{SKIP_IMAGE_GENERATION}" - -Follow Up Input: -{{final_query}} -""".strip() - - -class ImageGenerationResponse(BaseModel): - revised_prompt: str - url: str - - -class ImageShape(str, Enum): - SQUARE = "square" - PORTRAIT = "portrait" - LANDSCAPE = "landscape" - - -class ImageGenerationTool(Tool): - _NAME = "run_image_generation" - _DESCRIPTION = "Generate an image from a prompt." - _DISPLAY_NAME = "Image Generation Tool" - - def __init__( - self, - api_key: str, - api_base: str | None, - api_version: str | None, - model: str = "dall-e-3", - num_imgs: int = 2, - additional_headers: dict[str, str] | None = None, - ) -> None: - self.api_key = api_key - self.api_base = api_base - self.api_version = api_version - - self.model = model - self.num_imgs = num_imgs - - self.additional_headers = additional_headers - - @property - def name(self) -> str: - return self._NAME - - @property - def description(self) -> str: - return self._DESCRIPTION - - @property - def display_name(self) -> str: - return self._DISPLAY_NAME - - def tool_definition(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": "Prompt used to generate the image", - }, - "shape": { - "type": "string", - "description": "Optional. Image shape: 'square', 'portrait', or 'landscape'", - "enum": [shape.value for shape in ImageShape], - }, - }, - "required": ["prompt"], - }, - }, - } - - def get_args_for_non_tool_calling_llm( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - force_run: bool = False, - ) -> dict[str, Any] | None: - args = {"prompt": query} - if force_run: - return args - - history_str = combine_message_chain( - messages=history, token_limit=GEN_AI_HISTORY_CUTOFF - ) - prompt = IMAGE_GENERATION_TEMPLATE.format( - chat_history=history_str, - final_query=query, - ) - use_image_generation_tool_output = message_to_string(llm.invoke(prompt)) - - logger.debug( - f"Evaluated if should use ImageGenerationTool: {use_image_generation_tool_output}" - ) - if ( - YES_IMAGE_GENERATION.split()[0] - ).lower() in use_image_generation_tool_output.lower(): - return args - - return None - - def build_tool_message_content( - self, *args: ToolResponse - ) -> str | list[str | dict[str, Any]]: - generation_response = args[0] - image_generations = cast( - list[ImageGenerationResponse], generation_response.response - ) - - return build_content_with_imgs( - json.dumps( - [ - { - "revised_prompt": image_generation.revised_prompt, - "url": image_generation.url, - } - for image_generation in image_generations - ] - ), - # NOTE: we can't pass in the image URLs here, since OpenAI doesn't allow - # Tool messages to contain images - # img_urls=[image_generation.url for image_generation in image_generations], - ) - - def _generate_image( - self, prompt: str, shape: ImageShape - ) -> ImageGenerationResponse: - if shape == ImageShape.LANDSCAPE: - size = "1792x1024" - elif shape == ImageShape.PORTRAIT: - size = "1024x1792" - else: - size = "1024x1024" - - try: - response = image_generation( - prompt=prompt, - model=self.model, - api_key=self.api_key, - # need to pass in None rather than empty str - api_base=self.api_base or None, - api_version=self.api_version or None, - size=size, - n=1, - extra_headers=build_llm_extra_headers(self.additional_headers), - ) - return ImageGenerationResponse( - revised_prompt=response.data[0]["revised_prompt"], - url=response.data[0]["url"], - ) - except Exception as e: - logger.debug(f"Error occured during image generation: {e}") - - error_message = str(e) - if "OpenAIException" in str(type(e)): - if ( - "Your request was rejected as a result of our safety system" - in error_message - ): - raise ValueError( - "The image generation request was rejected due to OpenAI's content policy. Please try a different prompt." - ) - elif "Invalid image URL" in error_message: - raise ValueError("Invalid image URL provided for image generation.") - elif "invalid_request_error" in error_message: - raise ValueError( - "Invalid request for image generation. Please check your input." - ) - - raise ValueError( - "An error occurred during image generation. Please try again later." - ) - - def run(self, **kwargs: str) -> Generator[ToolResponse, None, None]: - prompt = cast(str, kwargs["prompt"]) - shape = ImageShape(kwargs.get("shape", ImageShape.SQUARE)) - - # dalle3 only supports 1 image at a time, which is why we have to - # parallelize this via threading - results = cast( - list[ImageGenerationResponse], - run_functions_tuples_in_parallel( - [ - ( - self._generate_image, - ( - prompt, - shape, - ), - ) - for _ in range(self.num_imgs) - ] - ), - ) - yield ToolResponse( - id=IMAGE_GENERATION_RESPONSE_ID, - response=results, - ) - - def final_result(self, *args: ToolResponse) -> JSON_ro: - image_generation_responses = cast( - list[ImageGenerationResponse], args[0].response - ) - return [ - image_generation_response.model_dump() - for image_generation_response in image_generation_responses - ] diff --git a/backend/danswer/tools/images/prompt.py b/backend/danswer/tools/images/prompt.py deleted file mode 100644 index bb729bfcd1c..00000000000 --- a/backend/danswer/tools/images/prompt.py +++ /dev/null @@ -1,21 +0,0 @@ -from langchain_core.messages import HumanMessage - -from danswer.llm.utils import build_content_with_imgs - - -IMG_GENERATION_SUMMARY_PROMPT = """ -You have just created the attached images in response to the following query: "{query}". - -Can you please summarize them in a sentence or two? Do NOT include image urls or bulleted lists. -""" - - -def build_image_generation_user_prompt( - query: str, img_urls: list[str] | None = None -) -> HumanMessage: - return HumanMessage( - content=build_content_with_imgs( - message=IMG_GENERATION_SUMMARY_PROMPT.format(query=query).strip(), - img_urls=img_urls, - ) - ) diff --git a/backend/danswer/tools/internet_search/internet_search_tool.py b/backend/danswer/tools/internet_search/internet_search_tool.py deleted file mode 100644 index 2640afcdf83..00000000000 --- a/backend/danswer/tools/internet_search/internet_search_tool.py +++ /dev/null @@ -1,233 +0,0 @@ -import json -from collections.abc import Generator -from datetime import datetime -from typing import Any -from typing import cast - -import httpx - -from danswer.chat.chat_utils import combine_message_chain -from danswer.chat.models import LlmDoc -from danswer.configs.constants import DocumentSource -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.dynamic_configs.interface import JSON_ro -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.llm.utils import message_to_string -from danswer.prompts.chat_prompts import INTERNET_SEARCH_QUERY_REPHRASE -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.search.models import SearchDoc -from danswer.secondary_llm_flows.query_expansion import history_based_query_rephrase -from danswer.tools.internet_search.models import InternetSearchResponse -from danswer.tools.internet_search.models import InternetSearchResult -from danswer.tools.search.search_tool import FINAL_CONTEXT_DOCUMENTS -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -INTERNET_SEARCH_RESPONSE_ID = "internet_search_response" - -YES_INTERNET_SEARCH = "Yes Internet Search" -SKIP_INTERNET_SEARCH = "Skip Internet Search" - -INTERNET_SEARCH_TEMPLATE = f""" -Given the conversation history and a follow up query, determine if the system should call \ -an external internet search tool to better answer the latest user input. -Your default response is {SKIP_INTERNET_SEARCH}. - -Respond "{YES_INTERNET_SEARCH}" if: -- The user is asking for information that requires an internet search. - -Conversation History: -{GENERAL_SEP_PAT} -{{chat_history}} -{GENERAL_SEP_PAT} - -If you are at all unsure, respond with {SKIP_INTERNET_SEARCH}. -Respond with EXACTLY and ONLY "{YES_INTERNET_SEARCH}" or "{SKIP_INTERNET_SEARCH}" - -Follow Up Input: -{{final_query}} -""".strip() - - -def llm_doc_from_internet_search_result(result: InternetSearchResult) -> LlmDoc: - return LlmDoc( - document_id=result.link, - content=result.snippet, - blurb=result.snippet, - semantic_identifier=result.link, - source_type=DocumentSource.WEB, - metadata={}, - updated_at=datetime.now(), - link=result.link, - source_links={0: result.link}, - ) - - -def internet_search_response_to_search_docs( - internet_search_response: InternetSearchResponse, -) -> list[SearchDoc]: - return [ - SearchDoc( - document_id=doc.link, - chunk_ind=-1, - semantic_identifier=doc.title, - link=doc.link, - blurb=doc.snippet, - source_type=DocumentSource.NOT_APPLICABLE, - boost=0, - hidden=False, - metadata={}, - score=None, - match_highlights=[], - updated_at=None, - primary_owners=[], - secondary_owners=[], - is_internet=True, - ) - for doc in internet_search_response.internet_results - ] - - -class InternetSearchTool(Tool): - _NAME = "run_internet_search" - _DISPLAY_NAME = "[Beta] Internet Search Tool" - _DESCRIPTION = "Perform an internet search for up-to-date information." - - def __init__(self, api_key: str, num_results: int = 10) -> None: - self.api_key = api_key - self.host = "https://api.bing.microsoft.com/v7.0" - self.headers = { - "Ocp-Apim-Subscription-Key": api_key, - "Content-Type": "application/json", - } - self.num_results = num_results - self.client = httpx.Client() - - @property - def name(self) -> str: - return self._NAME - - @property - def description(self) -> str: - return self._DESCRIPTION - - @property - def display_name(self) -> str: - return self._DISPLAY_NAME - - def tool_definition(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "internet_search_query": { - "type": "string", - "description": "Query to search on the internet", - }, - }, - "required": ["internet_search_query"], - }, - }, - } - - def check_if_needs_internet_search( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - ) -> bool: - history_str = combine_message_chain( - messages=history, token_limit=GEN_AI_HISTORY_CUTOFF - ) - prompt = INTERNET_SEARCH_TEMPLATE.format( - chat_history=history_str, - final_query=query, - ) - use_internet_search_output = message_to_string(llm.invoke(prompt)) - - logger.debug( - f"Evaluated if should use internet search: {use_internet_search_output}" - ) - - return ( - YES_INTERNET_SEARCH.split()[0] - ).lower() in use_internet_search_output.lower() - - def get_args_for_non_tool_calling_llm( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - force_run: bool = False, - ) -> dict[str, Any] | None: - if not force_run and not self.check_if_needs_internet_search( - query, history, llm - ): - return None - - rephrased_query = history_based_query_rephrase( - query=query, - history=history, - llm=llm, - prompt_template=INTERNET_SEARCH_QUERY_REPHRASE, - ) - return { - "internet_search_query": rephrased_query, - } - - def build_tool_message_content( - self, *args: ToolResponse - ) -> str | list[str | dict[str, Any]]: - search_response = cast(InternetSearchResponse, args[0].response) - return json.dumps(search_response.model_dump()) - - def _perform_search(self, query: str) -> InternetSearchResponse: - response = self.client.get( - f"{self.host}/search", - headers=self.headers, - params={"q": query, "count": self.num_results}, - ) - results = response.json() - - return InternetSearchResponse( - revised_query=query, - internet_results=[ - InternetSearchResult( - title=result["name"], - link=result["url"], - snippet=result["snippet"], - ) - for result in results["webPages"]["value"][: self.num_results] - ], - ) - - def run(self, **kwargs: str) -> Generator[ToolResponse, None, None]: - query = cast(str, kwargs["internet_search_query"]) - - results = self._perform_search(query) - yield ToolResponse( - id=INTERNET_SEARCH_RESPONSE_ID, - response=results, - ) - - llm_docs = [ - llm_doc_from_internet_search_result(result) - for result in results.internet_results - ] - - yield ToolResponse( - id=FINAL_CONTEXT_DOCUMENTS, - response=llm_docs, - ) - - def final_result(self, *args: ToolResponse) -> JSON_ro: - search_response = cast(InternetSearchResponse, args[0].response) - return search_response.model_dump() diff --git a/backend/danswer/tools/internet_search/models.py b/backend/danswer/tools/internet_search/models.py deleted file mode 100644 index e6db4179b7f..00000000000 --- a/backend/danswer/tools/internet_search/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from pydantic import BaseModel - - -class InternetSearchResult(BaseModel): - title: str - link: str - snippet: str - - -class InternetSearchResponse(BaseModel): - revised_query: str - internet_results: list[InternetSearchResult] diff --git a/backend/danswer/tools/message.py b/backend/danswer/tools/message.py deleted file mode 100644 index b0259c29b2a..00000000000 --- a/backend/danswer/tools/message.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from typing import Any - -from langchain_core.messages.ai import AIMessage -from langchain_core.messages.tool import ToolCall -from langchain_core.messages.tool import ToolMessage -from pydantic.v1 import BaseModel as BaseModel__v1 - -from danswer.natural_language_processing.utils import BaseTokenizer - -# Langchain has their own version of pydantic which is version 1 - - -def build_tool_message( - tool_call: ToolCall, tool_content: str | list[str | dict[str, Any]] -) -> ToolMessage: - return ToolMessage( - tool_call_id=tool_call["id"] or "", - name=tool_call["name"], - content=tool_content, - ) - - -class ToolCallSummary(BaseModel__v1): - tool_call_request: AIMessage - tool_call_result: ToolMessage - - -def tool_call_tokens( - tool_call_summary: ToolCallSummary, llm_tokenizer: BaseTokenizer -) -> int: - request_tokens = len( - llm_tokenizer.encode( - json.dumps(tool_call_summary.tool_call_request.tool_calls[0]["args"]) - ) - ) - result_tokens = len( - llm_tokenizer.encode(json.dumps(tool_call_summary.tool_call_result.content)) - ) - - return request_tokens + result_tokens diff --git a/backend/danswer/tools/models.py b/backend/danswer/tools/models.py deleted file mode 100644 index 052e4293a53..00000000000 --- a/backend/danswer/tools/models.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Any - -from pydantic import BaseModel -from pydantic import model_validator - - -class ToolResponse(BaseModel): - id: str | None = None - response: Any = None - - -class ToolCallKickoff(BaseModel): - tool_name: str - tool_args: dict[str, Any] - - -class ToolRunnerResponse(BaseModel): - tool_run_kickoff: ToolCallKickoff | None = None - tool_response: ToolResponse | None = None - tool_message_content: str | list[str | dict[str, Any]] | None = None - - @model_validator(mode="after") - def validate_tool_runner_response(self) -> "ToolRunnerResponse": - fields = ["tool_response", "tool_message_content", "tool_run_kickoff"] - provided = sum(1 for field in fields if getattr(self, field) is not None) - - if provided != 1: - raise ValueError( - "Exactly one of 'tool_response', 'tool_message_content', " - "or 'tool_run_kickoff' must be provided" - ) - - return self - - -class ToolCallFinalResult(ToolCallKickoff): - tool_result: Any = ( - None # we would like to use JSON_ro, but can't due to its recursive nature - ) diff --git a/backend/danswer/tools/search/search_tool.py b/backend/danswer/tools/search/search_tool.py deleted file mode 100644 index 13d3a304b06..00000000000 --- a/backend/danswer/tools/search/search_tool.py +++ /dev/null @@ -1,356 +0,0 @@ -import json -from collections.abc import Generator -from typing import Any -from typing import cast - -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.chat.chat_utils import llm_doc_from_inference_section -from danswer.chat.models import DanswerContext -from danswer.chat.models import DanswerContexts -from danswer.chat.models import LlmDoc -from danswer.chat.models import SectionRelevancePiece -from danswer.configs.chat_configs import CONTEXT_CHUNKS_ABOVE -from danswer.configs.chat_configs import CONTEXT_CHUNKS_BELOW -from danswer.configs.model_configs import GEN_AI_MODEL_FALLBACK_MAX_TOKENS -from danswer.db.models import Persona -from danswer.db.models import User -from danswer.dynamic_configs.interface import JSON_ro -from danswer.llm.answering.models import ContextualPruningConfig -from danswer.llm.answering.models import DocumentPruningConfig -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.answering.models import PromptConfig -from danswer.llm.answering.prompts.citations_prompt import compute_max_llm_input_tokens -from danswer.llm.answering.prune_and_merge import prune_and_merge_sections -from danswer.llm.answering.prune_and_merge import prune_sections -from danswer.llm.interfaces import LLM -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import QueryFlow -from danswer.search.enums import SearchType -from danswer.search.models import IndexFilters -from danswer.search.models import InferenceSection -from danswer.search.models import RetrievalDetails -from danswer.search.models import SearchRequest -from danswer.search.pipeline import SearchPipeline -from danswer.secondary_llm_flows.choose_search import check_if_need_search -from danswer.secondary_llm_flows.query_expansion import history_based_query_rephrase -from danswer.tools.search.search_utils import llm_doc_to_dict -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -SEARCH_RESPONSE_SUMMARY_ID = "search_response_summary" -SEARCH_DOC_CONTENT_ID = "search_doc_content" -SECTION_RELEVANCE_LIST_ID = "section_relevance_list" -FINAL_CONTEXT_DOCUMENTS = "final_context_documents" -SEARCH_EVALUATION_ID = "llm_doc_eval" - - -class SearchResponseSummary(BaseModel): - top_sections: list[InferenceSection] - rephrased_query: str | None = None - predicted_flow: QueryFlow | None - predicted_search: SearchType | None - final_filters: IndexFilters - recency_bias_multiplier: float - - -SEARCH_TOOL_DESCRIPTION = """ -Runs a semantic search over the user's knowledge base. The default behavior is to use this tool. \ -The only scenario where you should not use this tool is if: - -- There is sufficient information in chat history to FULLY and ACCURATELY answer the query AND \ -additional information or details would provide little or no value. -- The query is some form of request that does not require additional information to handle. - -HINT: if you are unfamiliar with the user input OR think the user input is a typo, use this tool. -""" - - -class SearchTool(Tool): - _NAME = "run_search" - _DISPLAY_NAME = "Search Tool" - _DESCRIPTION = SEARCH_TOOL_DESCRIPTION - - def __init__( - self, - db_session: Session, - user: User | None, - persona: Persona, - retrieval_options: RetrievalDetails | None, - prompt_config: PromptConfig, - llm: LLM, - fast_llm: LLM, - pruning_config: DocumentPruningConfig, - evaluation_type: LLMEvaluationType, - # if specified, will not actually run a search and will instead return these - # sections. Used when the user selects specific docs to talk to - selected_sections: list[InferenceSection] | None = None, - chunks_above: int | None = None, - chunks_below: int | None = None, - full_doc: bool = False, - bypass_acl: bool = False, - ) -> None: - self.user = user - self.persona = persona - self.retrieval_options = retrieval_options - self.prompt_config = prompt_config - self.llm = llm - self.fast_llm = fast_llm - self.evaluation_type = evaluation_type - - self.selected_sections = selected_sections - - self.full_doc = full_doc - self.bypass_acl = bypass_acl - self.db_session = db_session - - self.chunks_above = ( - chunks_above - if chunks_above is not None - else ( - persona.chunks_above - if persona.chunks_above is not None - else CONTEXT_CHUNKS_ABOVE - ) - ) - self.chunks_below = ( - chunks_below - if chunks_below is not None - else ( - persona.chunks_below - if persona.chunks_below is not None - else CONTEXT_CHUNKS_BELOW - ) - ) - - # For small context models, don't include additional surrounding context - # The 3 here for at least minimum 1 above, 1 below and 1 for the middle chunk - max_llm_tokens = compute_max_llm_input_tokens(self.llm.config) - if max_llm_tokens < 3 * GEN_AI_MODEL_FALLBACK_MAX_TOKENS: - self.chunks_above = 0 - self.chunks_below = 0 - - num_chunk_multiple = self.chunks_above + self.chunks_below + 1 - - self.contextual_pruning_config = ( - ContextualPruningConfig.from_doc_pruning_config( - num_chunk_multiple=num_chunk_multiple, doc_pruning_config=pruning_config - ) - ) - - @property - def name(self) -> str: - return self._NAME - - @property - def description(self) -> str: - return self._DESCRIPTION - - @property - def display_name(self) -> str: - return self._DISPLAY_NAME - - """For explicit tool calling""" - - def tool_definition(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "What to search for", - }, - }, - "required": ["query"], - }, - }, - } - - def build_tool_message_content( - self, *args: ToolResponse - ) -> str | list[str | dict[str, Any]]: - final_context_docs_response = next( - response for response in args if response.id == FINAL_CONTEXT_DOCUMENTS - ) - final_context_docs = cast(list[LlmDoc], final_context_docs_response.response) - - return json.dumps( - { - "search_results": [ - llm_doc_to_dict(doc, ind) - for ind, doc in enumerate(final_context_docs) - ] - } - ) - - """For LLMs that don't support tool calling""" - - def get_args_for_non_tool_calling_llm( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - force_run: bool = False, - ) -> dict[str, Any] | None: - if not force_run and not check_if_need_search( - query=query, history=history, llm=llm - ): - return None - - rephrased_query = history_based_query_rephrase( - query=query, history=history, llm=llm - ) - return {"query": rephrased_query} - - """Actual tool execution""" - - def _build_response_for_specified_sections( - self, query: str - ) -> Generator[ToolResponse, None, None]: - if self.selected_sections is None: - raise ValueError("Sections must be specified") - - yield ToolResponse( - id=SEARCH_RESPONSE_SUMMARY_ID, - response=SearchResponseSummary( - rephrased_query=None, - top_sections=[], - predicted_flow=None, - predicted_search=None, - final_filters=IndexFilters(access_control_list=None), # dummy filters - recency_bias_multiplier=1.0, - ), - ) - - # Build selected sections for specified documents - selected_sections = [ - SectionRelevancePiece( - relevant=True, - document_id=section.center_chunk.document_id, - chunk_id=section.center_chunk.chunk_id, - ) - for section in self.selected_sections - ] - - yield ToolResponse( - id=SECTION_RELEVANCE_LIST_ID, - response=selected_sections, - ) - - final_context_sections = prune_and_merge_sections( - sections=self.selected_sections, - section_relevance_list=None, - prompt_config=self.prompt_config, - llm_config=self.llm.config, - question=query, - contextual_pruning_config=self.contextual_pruning_config, - ) - - llm_docs = [ - llm_doc_from_inference_section(section) - for section in final_context_sections - ] - - yield ToolResponse(id=FINAL_CONTEXT_DOCUMENTS, response=llm_docs) - - def run(self, **kwargs: str) -> Generator[ToolResponse, None, None]: - query = cast(str, kwargs["query"]) - - if self.selected_sections: - yield from self._build_response_for_specified_sections(query) - return - - search_pipeline = SearchPipeline( - search_request=SearchRequest( - query=query, - evaluation_type=self.evaluation_type, - human_selected_filters=( - self.retrieval_options.filters if self.retrieval_options else None - ), - persona=self.persona, - offset=( - self.retrieval_options.offset if self.retrieval_options else None - ), - limit=self.retrieval_options.limit if self.retrieval_options else None, - chunks_above=self.chunks_above, - chunks_below=self.chunks_below, - full_doc=self.full_doc, - enable_auto_detect_filters=( - self.retrieval_options.enable_auto_detect_filters - if self.retrieval_options - else None - ), - ), - user=self.user, - llm=self.llm, - fast_llm=self.fast_llm, - bypass_acl=self.bypass_acl, - db_session=self.db_session, - prompt_config=self.prompt_config, - ) - - yield ToolResponse( - id=SEARCH_RESPONSE_SUMMARY_ID, - response=SearchResponseSummary( - rephrased_query=query, - top_sections=search_pipeline.final_context_sections, - predicted_flow=search_pipeline.predicted_flow, - predicted_search=search_pipeline.predicted_search_type, - final_filters=search_pipeline.search_query.filters, - recency_bias_multiplier=search_pipeline.search_query.recency_bias_multiplier, - ), - ) - - yield ToolResponse( - id=SEARCH_DOC_CONTENT_ID, - response=DanswerContexts( - contexts=[ - DanswerContext( - content=section.combined_content, - document_id=section.center_chunk.document_id, - semantic_identifier=section.center_chunk.semantic_identifier, - blurb=section.center_chunk.blurb, - ) - for section in search_pipeline.reranked_sections - ] - ), - ) - - yield ToolResponse( - id=SECTION_RELEVANCE_LIST_ID, - response=search_pipeline.section_relevance, - ) - - pruned_sections = prune_sections( - sections=search_pipeline.final_context_sections, - section_relevance_list=search_pipeline.section_relevance_list, - prompt_config=self.prompt_config, - llm_config=self.llm.config, - question=query, - contextual_pruning_config=self.contextual_pruning_config, - ) - - llm_docs = [ - llm_doc_from_inference_section(section) for section in pruned_sections - ] - - yield ToolResponse(id=FINAL_CONTEXT_DOCUMENTS, response=llm_docs) - - def final_result(self, *args: ToolResponse) -> JSON_ro: - final_docs = cast( - list[LlmDoc], - next(arg.response for arg in args if arg.id == FINAL_CONTEXT_DOCUMENTS), - ) - # NOTE: need to do this json.loads(doc.json()) stuff because there are some - # subfields that are not serializable by default (datetime) - # this forces pydantic to make them JSON serializable for us - return [json.loads(doc.json()) for doc in final_docs] diff --git a/backend/danswer/tools/search/search_utils.py b/backend/danswer/tools/search/search_utils.py deleted file mode 100644 index 5076632a694..00000000000 --- a/backend/danswer/tools/search/search_utils.py +++ /dev/null @@ -1,31 +0,0 @@ -from danswer.chat.models import LlmDoc -from danswer.prompts.prompt_utils import clean_up_source -from danswer.search.models import InferenceSection - - -def llm_doc_to_dict(llm_doc: LlmDoc, doc_num: int) -> dict: - doc_dict = { - "document_number": doc_num + 1, - "title": llm_doc.semantic_identifier, - "content": llm_doc.content, - "source": clean_up_source(llm_doc.source_type), - "metadata": llm_doc.metadata, - } - if llm_doc.updated_at: - doc_dict["updated_at"] = llm_doc.updated_at.strftime("%B %d, %Y %H:%M") - return doc_dict - - -def section_to_dict(section: InferenceSection, section_num: int) -> dict: - doc_dict = { - "document_number": section_num + 1, - "title": section.center_chunk.semantic_identifier, - "content": section.combined_content, - "source": clean_up_source(section.center_chunk.source_type), - "metadata": section.center_chunk.metadata, - } - if section.center_chunk.updated_at: - doc_dict["updated_at"] = section.center_chunk.updated_at.strftime( - "%B %d, %Y %H:%M" - ) - return doc_dict diff --git a/backend/danswer/tools/tool.py b/backend/danswer/tools/tool.py deleted file mode 100644 index 81b9b457178..00000000000 --- a/backend/danswer/tools/tool.py +++ /dev/null @@ -1,63 +0,0 @@ -import abc -from collections.abc import Generator -from typing import Any - -from danswer.dynamic_configs.interface import JSON_ro -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.tools.models import ToolResponse - - -class Tool(abc.ABC): - @property - @abc.abstractmethod - def name(self) -> str: - raise NotImplementedError - - @property - @abc.abstractmethod - def description(self) -> str: - raise NotImplementedError - - @property - @abc.abstractmethod - def display_name(self) -> str: - raise NotImplementedError - - """For LLMs which support explicit tool calling""" - - @abc.abstractmethod - def tool_definition(self) -> dict: - raise NotImplementedError - - @abc.abstractmethod - def build_tool_message_content( - self, *args: ToolResponse - ) -> str | list[str | dict[str, Any]]: - raise NotImplementedError - - """For LLMs which do NOT support explicit tool calling""" - - @abc.abstractmethod - def get_args_for_non_tool_calling_llm( - self, - query: str, - history: list[PreviousMessage], - llm: LLM, - force_run: bool = False, - ) -> dict[str, Any] | None: - raise NotImplementedError - - """Actual execution of the tool""" - - @abc.abstractmethod - def run(self, **kwargs: Any) -> Generator[ToolResponse, None, None]: - raise NotImplementedError - - @abc.abstractmethod - def final_result(self, *args: ToolResponse) -> JSON_ro: - """ - This is the "final summary" result of the tool. - It is the result that will be stored in the database. - """ - raise NotImplementedError diff --git a/backend/danswer/tools/tool_runner.py b/backend/danswer/tools/tool_runner.py deleted file mode 100644 index f962c214a03..00000000000 --- a/backend/danswer/tools/tool_runner.py +++ /dev/null @@ -1,54 +0,0 @@ -from collections.abc import Generator -from typing import Any - -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.tools.models import ToolCallFinalResult -from danswer.tools.models import ToolCallKickoff -from danswer.tools.tool import Tool -from danswer.tools.tool import ToolResponse -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel - - -class ToolRunner: - def __init__(self, tool: Tool, args: dict[str, Any]): - self.tool = tool - self.args = args - - self._tool_responses: list[ToolResponse] | None = None - - def kickoff(self) -> ToolCallKickoff: - return ToolCallKickoff(tool_name=self.tool.name, tool_args=self.args) - - def tool_responses(self) -> Generator[ToolResponse, None, None]: - if self._tool_responses is not None: - yield from self._tool_responses - return - - tool_responses: list[ToolResponse] = [] - for tool_response in self.tool.run(**self.args): - yield tool_response - tool_responses.append(tool_response) - - self._tool_responses = tool_responses - - def tool_message_content(self) -> str | list[str | dict[str, Any]]: - tool_responses = list(self.tool_responses()) - return self.tool.build_tool_message_content(*tool_responses) - - def tool_final_result(self) -> ToolCallFinalResult: - return ToolCallFinalResult( - tool_name=self.tool.name, - tool_args=self.args, - tool_result=self.tool.final_result(*self.tool_responses()), - ) - - -def check_which_tools_should_run_for_non_tool_calling_llm( - tools: list[Tool], query: str, history: list[PreviousMessage], llm: LLM -) -> list[dict[str, Any] | None]: - tool_args_list = [ - (tool.get_args_for_non_tool_calling_llm, (query, history, llm)) - for tool in tools - ] - return run_functions_tuples_in_parallel(tool_args_list) diff --git a/backend/danswer/tools/tool_selection.py b/backend/danswer/tools/tool_selection.py deleted file mode 100644 index dc8d697c2ad..00000000000 --- a/backend/danswer/tools/tool_selection.py +++ /dev/null @@ -1,78 +0,0 @@ -import re -from typing import Any - -from danswer.chat.chat_utils import combine_message_chain -from danswer.configs.model_configs import GEN_AI_HISTORY_CUTOFF -from danswer.llm.answering.models import PreviousMessage -from danswer.llm.interfaces import LLM -from danswer.llm.utils import message_to_string -from danswer.prompts.constants import GENERAL_SEP_PAT -from danswer.tools.tool import Tool -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -SINGLE_TOOL_SELECTION_PROMPT = f""" -You are an expert at selecting the most useful tool to run for answering the query. -You will be given a numbered list of tools and their arguments, a message history, and a query. -You will select a single tool that will be most useful for answering the query. -Respond with only the number corresponding to the tool you want to use. - -Conversation History: -{GENERAL_SEP_PAT} -{{chat_history}} -{GENERAL_SEP_PAT} - -Query: -{{query}} - -Tools: -{{tool_list}} - -Respond with EXACTLY and ONLY the number corresponding to the tool you want to use. - -Your selection: -""" - - -def select_single_tool_for_non_tool_calling_llm( - tools_and_args: list[tuple[Tool, dict[str, Any]]], - history: list[PreviousMessage], - query: str, - llm: LLM, -) -> tuple[Tool, dict[str, Any]] | None: - if len(tools_and_args) == 1: - return tools_and_args[0] - - tool_list_str = "\n".join( - f"""```{ind}: {tool.name} ({args}) - {tool.description}```""" - for ind, (tool, args) in enumerate(tools_and_args) - ).lstrip() - - history_str = combine_message_chain( - messages=history, - token_limit=GEN_AI_HISTORY_CUTOFF, - ) - prompt = SINGLE_TOOL_SELECTION_PROMPT.format( - tool_list=tool_list_str, chat_history=history_str, query=query - ) - output = message_to_string(llm.invoke(prompt)) - try: - # First try to match the number - number_match = re.search(r"\d+", output) - if number_match: - tool_ind = int(number_match.group()) - return tools_and_args[tool_ind] - - # If that fails, try to match the tool name - for tool, args in tools_and_args: - if tool.name.lower() in output.lower(): - return tool, args - - # If that fails, return the first tool - return tools_and_args[0] - - except Exception: - logger.error(f"Failed to select single tool for non-tool-calling LLM: {output}") - return None diff --git a/backend/danswer/tools/utils.py b/backend/danswer/tools/utils.py deleted file mode 100644 index 9e20105edef..00000000000 --- a/backend/danswer/tools/utils.py +++ /dev/null @@ -1,28 +0,0 @@ -import json - -from danswer.natural_language_processing.utils import BaseTokenizer -from danswer.tools.tool import Tool - - -OPEN_AI_TOOL_CALLING_MODELS = { - "gpt-3.5-turbo", - "gpt-4-turbo", - "gpt-4", - "gpt-4o", - "gpt-4o-mini", -} - - -def explicit_tool_calling_supported(model_provider: str, model_name: str) -> bool: - if model_provider == "openai" and model_name in OPEN_AI_TOOL_CALLING_MODELS: - return True - - return False - - -def compute_tool_tokens(tool: Tool, llm_tokenizer: BaseTokenizer) -> int: - return len(llm_tokenizer.encode(json.dumps(tool.tool_definition()))) - - -def compute_all_tool_tokens(tools: list[Tool], llm_tokenizer: BaseTokenizer) -> int: - return sum(compute_tool_tokens(tool, llm_tokenizer) for tool in tools) diff --git a/backend/danswer/utils/__init__.py b/backend/danswer/utils/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/danswer/utils/batching.py b/backend/danswer/utils/batching.py deleted file mode 100644 index 0200f72250a..00000000000 --- a/backend/danswer/utils/batching.py +++ /dev/null @@ -1,23 +0,0 @@ -from collections.abc import Callable -from collections.abc import Generator -from collections.abc import Iterable -from itertools import islice -from typing import TypeVar - -T = TypeVar("T") - - -def batch_generator( - items: Iterable[T], - batch_size: int, - pre_batch_yield: Callable[[list[T]], None] | None = None, -) -> Generator[list[T], None, None]: - iterable = iter(items) - while True: - batch = list(islice(iterable, batch_size)) - if not batch: - return - - if pre_batch_yield: - pre_batch_yield(batch) - yield batch diff --git a/backend/danswer/utils/callbacks.py b/backend/danswer/utils/callbacks.py deleted file mode 100644 index ffb46a2cf6a..00000000000 --- a/backend/danswer/utils/callbacks.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Generic -from typing import TypeVar - -T = TypeVar("T") - - -class MetricsHander(Generic[T]): - def __init__(self) -> None: - self.metrics: T | None = None - - def record_metric(self, metrics: T) -> None: - self.metrics = metrics diff --git a/backend/danswer/utils/encryption.py b/backend/danswer/utils/encryption.py deleted file mode 100644 index 0f21d84d092..00000000000 --- a/backend/danswer/utils/encryption.py +++ /dev/null @@ -1,31 +0,0 @@ -from danswer.configs.app_configs import ENCRYPTION_KEY_SECRET -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import fetch_versioned_implementation - -logger = setup_logger() - - -def _encrypt_string(input_str: str) -> bytes: - if ENCRYPTION_KEY_SECRET: - logger.warning("MIT version of Danswer does not support encryption of secrets.") - return input_str.encode() - - -def _decrypt_bytes(input_bytes: bytes) -> str: - # No need to double warn. If you wish to learn more about encryption features - # refer to the Danswer EE code - return input_bytes.decode() - - -def encrypt_string_to_bytes(intput_str: str) -> bytes: - versioned_encryption_fn = fetch_versioned_implementation( - "danswer.utils.encryption", "_encrypt_string" - ) - return versioned_encryption_fn(intput_str) - - -def decrypt_bytes_to_string(intput_bytes: bytes) -> str: - versioned_decryption_fn = fetch_versioned_implementation( - "danswer.utils.encryption", "_decrypt_bytes" - ) - return versioned_decryption_fn(intput_bytes) diff --git a/backend/danswer/utils/logger.py b/backend/danswer/utils/logger.py deleted file mode 100644 index a7751ca3dc7..00000000000 --- a/backend/danswer/utils/logger.py +++ /dev/null @@ -1,154 +0,0 @@ -import logging -import os -from collections.abc import MutableMapping -from logging.handlers import RotatingFileHandler -from typing import Any - -from shared_configs.configs import DEV_LOGGING_ENABLED -from shared_configs.configs import LOG_FILE_NAME -from shared_configs.configs import LOG_LEVEL -from shared_configs.configs import SLACK_CHANNEL_ID - - -logging.addLevelName(logging.INFO + 5, "NOTICE") - - -class IndexAttemptSingleton: - """Used to tell if this process is an indexing job, and if so what is the - unique identifier for this indexing attempt. For things like the API server, - main background job (scheduler), etc. this will not be used.""" - - _INDEX_ATTEMPT_ID: None | int = None - - @classmethod - def get_index_attempt_id(cls) -> None | int: - return cls._INDEX_ATTEMPT_ID - - @classmethod - def set_index_attempt_id(cls, index_attempt_id: int) -> None: - cls._INDEX_ATTEMPT_ID = index_attempt_id - - -def get_log_level_from_str(log_level_str: str = LOG_LEVEL) -> int: - log_level_dict = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "WARNING": logging.WARNING, - "NOTICE": logging.getLevelName("NOTICE"), - "INFO": logging.INFO, - "DEBUG": logging.DEBUG, - "NOTSET": logging.NOTSET, - } - - return log_level_dict.get(log_level_str.upper(), logging.getLevelName("NOTICE")) - - -class DanswerLoggingAdapter(logging.LoggerAdapter): - def process( - self, msg: str, kwargs: MutableMapping[str, Any] - ) -> tuple[str, MutableMapping[str, Any]]: - # If this is an indexing job, add the attempt ID to the log message - # This helps filter the logs for this specific indexing - attempt_id = IndexAttemptSingleton.get_index_attempt_id() - if attempt_id is not None: - msg = f"[Attempt ID: {attempt_id}] {msg}" - - # For Slack Bot, logs the channel relevant to the request - channel_id = self.extra.get(SLACK_CHANNEL_ID) if self.extra else None - if channel_id: - msg = f"[Channel ID: {channel_id}] {msg}" - - return msg, kwargs - - def notice(self, msg: Any, *args: Any, **kwargs: Any) -> None: - # Stacklevel is set to 2 to point to the actual caller of notice instead of here - self.log( - logging.getLevelName("NOTICE"), str(msg), *args, **kwargs, stacklevel=2 - ) - - -class ColoredFormatter(logging.Formatter): - """Custom formatter to add colors to log levels.""" - - COLORS = { - "CRITICAL": "\033[91m", # Red - "ERROR": "\033[91m", # Red - "WARNING": "\033[93m", # Yellow - "NOTICE": "\033[94m", # Blue - "INFO": "\033[92m", # Green - "DEBUG": "\033[96m", # Light Green - "NOTSET": "\033[91m", # Reset - } - - def format(self, record: logging.LogRecord) -> str: - levelname = record.levelname - if levelname in self.COLORS: - prefix = self.COLORS[levelname] - suffix = "\033[0m" - formatted_message = super().format(record) - # Ensure the levelname with colon is 9 characters long - # accounts for the extra characters for coloring - level_display = f"{prefix}{levelname}{suffix}:" - return f"{level_display.ljust(18)} {formatted_message}" - return super().format(record) - - -def get_standard_formatter() -> ColoredFormatter: - """Returns a standard colored logging formatter.""" - return ColoredFormatter( - "%(asctime)s %(filename)30s %(lineno)4s: %(message)s", - datefmt="%m/%d/%Y %I:%M:%S %p", - ) - - -def setup_logger( - name: str = __name__, - log_level: int = get_log_level_from_str(), - extra: MutableMapping[str, Any] | None = None, -) -> DanswerLoggingAdapter: - logger = logging.getLogger(name) - - # If the logger already has handlers, assume it was already configured and return it. - if logger.handlers: - return DanswerLoggingAdapter(logger, extra=extra) - - logger.setLevel(log_level) - - formatter = get_standard_formatter() - - handler = logging.StreamHandler() - handler.setLevel(log_level) - handler.setFormatter(formatter) - - logger.addHandler(handler) - - uvicorn_logger = logging.getLogger("uvicorn.access") - if uvicorn_logger: - uvicorn_logger.handlers = [] - uvicorn_logger.addHandler(handler) - uvicorn_logger.setLevel(log_level) - - is_containerized = os.path.exists("/.dockerenv") - if LOG_FILE_NAME and (is_containerized or DEV_LOGGING_ENABLED): - log_levels = ["debug", "info", "notice"] - for level in log_levels: - file_name = ( - f"/var/log/{LOG_FILE_NAME}_{level}.log" - if is_containerized - else f"./log/{LOG_FILE_NAME}_{level}.log" - ) - file_handler = RotatingFileHandler( - file_name, - maxBytes=25 * 1024 * 1024, # 25 MB - backupCount=5, # Keep 5 backup files - ) - file_handler.setLevel(get_log_level_from_str(level)) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - if uvicorn_logger: - uvicorn_logger.addHandler(file_handler) - - logger.notice = lambda msg, *args, **kwargs: logger.log(logging.getLevelName("NOTICE"), msg, *args, **kwargs) # type: ignore - - return DanswerLoggingAdapter(logger, extra=extra) diff --git a/backend/danswer/utils/sitemap.py b/backend/danswer/utils/sitemap.py deleted file mode 100644 index ababbec4575..00000000000 --- a/backend/danswer/utils/sitemap.py +++ /dev/null @@ -1,39 +0,0 @@ -from datetime import datetime -from urllib import robotparser - -from usp.tree import sitemap_tree_for_homepage # type: ignore - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def test_url(rp: robotparser.RobotFileParser | None, url: str) -> bool: - if not rp: - return True - else: - return rp.can_fetch("*", url) - - -def init_robots_txt(site: str) -> robotparser.RobotFileParser: - ts = datetime.now().timestamp() - robots_url = f"{site}/robots.txt?ts={ts}" - rp = robotparser.RobotFileParser() - rp.set_url(robots_url) - rp.read() - return rp - - -def list_pages_for_site(site: str) -> list[str]: - rp: robotparser.RobotFileParser | None = None - try: - rp = init_robots_txt(site) - except Exception: - logger.warning("Failed to load robots.txt") - - tree = sitemap_tree_for_homepage(site) - - pages = [page.url for page in tree.all_pages() if test_url(rp, page.url)] - pages = list(dict.fromkeys(pages)) - - return pages diff --git a/backend/danswer/utils/telemetry.py b/backend/danswer/utils/telemetry.py deleted file mode 100644 index 80fcba65a16..00000000000 --- a/backend/danswer/utils/telemetry.py +++ /dev/null @@ -1,66 +0,0 @@ -import threading -import uuid -from enum import Enum -from typing import cast - -import requests - -from danswer.configs.app_configs import DISABLE_TELEMETRY -from danswer.configs.constants import KV_CUSTOMER_UUID_KEY -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError - -DANSWER_TELEMETRY_ENDPOINT = "https://telemetry.danswer.ai/anonymous_telemetry" - - -class RecordType(str, Enum): - VERSION = "version" - SIGN_UP = "sign_up" - USAGE = "usage" - LATENCY = "latency" - FAILURE = "failure" - - -def get_or_generate_uuid() -> str: - kv_store = get_dynamic_config_store() - try: - return cast(str, kv_store.load(KV_CUSTOMER_UUID_KEY)) - except ConfigNotFoundError: - customer_id = str(uuid.uuid4()) - kv_store.store(KV_CUSTOMER_UUID_KEY, customer_id, encrypt=True) - return customer_id - - -def optional_telemetry( - record_type: RecordType, data: dict, user_id: str | None = None -) -> None: - if DISABLE_TELEMETRY: - return - - try: - - def telemetry_logic() -> None: - try: - payload = { - "data": data, - "record": record_type, - # If None then it's a flow that doesn't include a user - # For cases where the User itself is None, a string is provided instead - "user_id": user_id, - "customer_uuid": get_or_generate_uuid(), - } - requests.post( - DANSWER_TELEMETRY_ENDPOINT, - headers={"Content-Type": "application/json"}, - json=payload, - ) - except Exception: - # This way it silences all thread level logging as well - pass - - # Run in separate thread to have minimal overhead in main flows - thread = threading.Thread(target=telemetry_logic, daemon=True) - thread.start() - except Exception: - # Should never interfere with normal functions of Danswer - pass diff --git a/backend/danswer/utils/text_processing.py b/backend/danswer/utils/text_processing.py deleted file mode 100644 index b0fbcdfa1e9..00000000000 --- a/backend/danswer/utils/text_processing.py +++ /dev/null @@ -1,98 +0,0 @@ -import codecs -import json -import re -import string -from urllib.parse import quote - - -ESCAPE_SEQUENCE_RE = re.compile( - r""" - ( \\U........ # 8-digit hex escapes - | \\u.... # 4-digit hex escapes - | \\x.. # 2-digit hex escapes - | \\[0-7]{1,3} # Octal escapes - | \\N\{[^}]+\} # Unicode characters by name - | \\[\\'"abfnrtv] # Single-character escapes - )""", - re.UNICODE | re.VERBOSE, -) - - -def decode_escapes(s: str) -> str: - def decode_match(match: re.Match) -> str: - return codecs.decode(match.group(0), "unicode-escape") - - return ESCAPE_SEQUENCE_RE.sub(decode_match, s) - - -def make_url_compatible(s: str) -> str: - s_with_underscores = s.replace(" ", "_") - return quote(s_with_underscores, safe="") - - -def has_unescaped_quote(s: str) -> bool: - pattern = r'(? str: - return re.sub(r"(? str: - return re.sub(r"\s", " ", s) - - -def extract_embedded_json(s: str) -> dict: - first_brace_index = s.find("{") - last_brace_index = s.rfind("}") - - if first_brace_index == -1 or last_brace_index == -1: - raise ValueError("No valid json found") - - return json.loads(s[first_brace_index : last_brace_index + 1], strict=False) - - -def clean_up_code_blocks(model_out_raw: str) -> str: - return model_out_raw.strip().strip("```").strip().replace("\\xa0", "") - - -def clean_model_quote(quote: str, trim_length: int) -> str: - quote_clean = quote.strip() - if quote_clean[0] == '"': - quote_clean = quote_clean[1:] - if quote_clean[-1] == '"': - quote_clean = quote_clean[:-1] - if trim_length > 0: - quote_clean = quote_clean[:trim_length] - return quote_clean - - -def shared_precompare_cleanup(text: str) -> str: - """LLMs models sometime restructure whitespaces or edits special characters to fit a more likely - distribution of characters found in its training data, but this hurts exact quote matching - """ - text = text.lower() - - # \s: matches any whitespace character (spaces, tabs, newlines, etc.) - # |: acts as an OR. - # \*: matches the asterisk character. - # \\": matches the \" sequence. - # [.,:`"#-]: matches any character inside the square brackets. - text = re.sub(r'\s|\*|\\"|[.,:`"#-]', "", text) - - return text - - -def is_valid_email(text: str) -> bool: - """Can use a library instead if more detailed checks are needed""" - regex = r"^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" - - if re.match(regex, text): - return True - else: - return False - - -def count_punctuation(text: str) -> int: - return sum(1 for char in text if char in string.punctuation) diff --git a/backend/danswer/utils/threadpool_concurrency.py b/backend/danswer/utils/threadpool_concurrency.py deleted file mode 100644 index d8fc40a7d94..00000000000 --- a/backend/danswer/utils/threadpool_concurrency.py +++ /dev/null @@ -1,108 +0,0 @@ -import uuid -from collections.abc import Callable -from concurrent.futures import as_completed -from concurrent.futures import ThreadPoolExecutor -from typing import Any -from typing import Generic -from typing import TypeVar - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -R = TypeVar("R") - - -def run_functions_tuples_in_parallel( - functions_with_args: list[tuple[Callable, tuple]], - allow_failures: bool = False, - max_workers: int | None = None, -) -> list[Any]: - """ - Executes multiple functions in parallel and returns a list of the results for each function. - - Args: - functions_with_args: List of tuples each containing the function callable and a tuple of arguments. - allow_failures: if set to True, then the function result will just be None - max_workers: Max number of worker threads - - Returns: - dict: A dictionary mapping function names to their results or error messages. - """ - workers = ( - min(max_workers, len(functions_with_args)) - if max_workers is not None - else len(functions_with_args) - ) - - if workers <= 0: - return [] - - results = [] - with ThreadPoolExecutor(max_workers=workers) as executor: - future_to_index = { - executor.submit(func, *args): i - for i, (func, args) in enumerate(functions_with_args) - } - - for future in as_completed(future_to_index): - index = future_to_index[future] - try: - results.append((index, future.result())) - except Exception as e: - logger.exception(f"Function at index {index} failed due to {e}") - results.append((index, None)) - - if not allow_failures: - raise - - results.sort(key=lambda x: x[0]) - return [result for index, result in results] - - -class FunctionCall(Generic[R]): - """ - Container for run_functions_in_parallel, fetch the results from the output of - run_functions_in_parallel via the FunctionCall.result_id. - """ - - def __init__( - self, func: Callable[..., R], args: tuple = (), kwargs: dict | None = None - ): - self.func = func - self.args = args - self.kwargs = kwargs if kwargs is not None else {} - self.result_id = str(uuid.uuid4()) - - def execute(self) -> R: - return self.func(*self.args, **self.kwargs) - - -def run_functions_in_parallel( - function_calls: list[FunctionCall], - allow_failures: bool = False, -) -> dict[str, Any]: - """ - Executes a list of FunctionCalls in parallel and stores the results in a dictionary where the keys - are the result_id of the FunctionCall and the values are the results of the call. - """ - results = {} - - with ThreadPoolExecutor(max_workers=len(function_calls)) as executor: - future_to_id = { - executor.submit(func_call.execute): func_call.result_id - for func_call in function_calls - } - - for future in as_completed(future_to_id): - result_id = future_to_id[future] - try: - results[result_id] = future.result() - except Exception as e: - logger.exception(f"Function with ID {result_id} failed due to {e}") - results[result_id] = None - - if not allow_failures: - raise - - return results diff --git a/backend/danswer/utils/timing.py b/backend/danswer/utils/timing.py deleted file mode 100644 index 0d4eb7a14d4..00000000000 --- a/backend/danswer/utils/timing.py +++ /dev/null @@ -1,86 +0,0 @@ -import time -from collections.abc import Callable -from collections.abc import Generator -from collections.abc import Iterator -from functools import wraps -from typing import Any -from typing import cast -from typing import TypeVar - -from danswer.utils.logger import setup_logger -from danswer.utils.telemetry import optional_telemetry -from danswer.utils.telemetry import RecordType - -logger = setup_logger() - -F = TypeVar("F", bound=Callable) -FG = TypeVar("FG", bound=Callable[..., Generator | Iterator]) - - -def log_function_time( - func_name: str | None = None, - print_only: bool = False, - debug_only: bool = False, - include_args: bool = False, -) -> Callable[[F], F]: - def decorator(func: F) -> F: - @wraps(func) - def wrapped_func(*args: Any, **kwargs: Any) -> Any: - start_time = time.time() - user = kwargs.get("user") - result = func(*args, **kwargs) - elapsed_time = time.time() - start_time - elapsed_time_str = f"{elapsed_time:.3f}" - log_name = func_name or func.__name__ - args_str = f" args={args} kwargs={kwargs}" if include_args else "" - final_log = f"{log_name}{args_str} took {elapsed_time_str} seconds" - if debug_only: - logger.debug(final_log) - else: - # These are generally more important logs so the level is a bit higher - logger.notice(final_log) - - if not print_only: - optional_telemetry( - record_type=RecordType.LATENCY, - data={"function": log_name, "latency": str(elapsed_time_str)}, - user_id=str(user.id) if user else "Unknown", - ) - - return result - - return cast(F, wrapped_func) - - return decorator - - -def log_generator_function_time( - func_name: str | None = None, print_only: bool = False -) -> Callable[[FG], FG]: - def decorator(func: FG) -> FG: - @wraps(func) - def wrapped_func(*args: Any, **kwargs: Any) -> Any: - start_time = time.time() - user = kwargs.get("user") - gen = func(*args, **kwargs) - try: - value = next(gen) - while True: - yield value - value = next(gen) - except StopIteration: - pass - finally: - elapsed_time_str = str(time.time() - start_time) - log_name = func_name or func.__name__ - logger.info(f"{log_name} took {elapsed_time_str} seconds") - if not print_only: - optional_telemetry( - record_type=RecordType.LATENCY, - data={"function": log_name, "latency": str(elapsed_time_str)}, - user_id=str(user.id) if user else "Unknown", - ) - - return cast(FG, wrapped_func) - - return decorator diff --git a/backend/danswer/utils/variable_functionality.py b/backend/danswer/utils/variable_functionality.py deleted file mode 100644 index 97c6592601e..00000000000 --- a/backend/danswer/utils/variable_functionality.py +++ /dev/null @@ -1,76 +0,0 @@ -import functools -import importlib -from typing import Any -from typing import TypeVar - -from danswer.configs.app_configs import ENTERPRISE_EDITION_ENABLED -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -class DanswerVersion: - def __init__(self) -> None: - self._is_ee = False - - def set_ee(self) -> None: - self._is_ee = True - - def get_is_ee_version(self) -> bool: - return self._is_ee - - -global_version = DanswerVersion() - - -def set_is_ee_based_on_env_variable() -> None: - if ENTERPRISE_EDITION_ENABLED and not global_version.get_is_ee_version(): - logger.notice("Enterprise Edition enabled") - global_version.set_ee() - - -@functools.lru_cache(maxsize=128) -def fetch_versioned_implementation(module: str, attribute: str) -> Any: - logger.debug("Fetching versioned implementation for %s.%s", module, attribute) - is_ee = global_version.get_is_ee_version() - - module_full = f"ee.{module}" if is_ee else module - try: - return getattr(importlib.import_module(module_full), attribute) - except ModuleNotFoundError as e: - logger.warning( - "Failed to fetch versioned implementation for %s.%s: %s", - module_full, - attribute, - e, - ) - - if is_ee: - if "ee.danswer" not in str(e): - # If it's a non Danswer related import failure, this is likely because - # a dependent library has not been installed. Should raise this failure - # instead of letting the server start up - raise e - - # Use the MIT version as a fallback, this allows us to develop MIT - # versions independently and later add additional EE functionality - # similar to feature flagging - return getattr(importlib.import_module(module), attribute) - - raise - - -T = TypeVar("T") - - -def fetch_versioned_implementation_with_fallback( - module: str, attribute: str, fallback: T -) -> T: - try: - return fetch_versioned_implementation(module, attribute) - except Exception: - return fallback - - -def noop_fallback(*args: Any, **kwargs: Any) -> None: - pass diff --git a/backend/ee/LICENSE b/backend/ee/LICENSE deleted file mode 100644 index 1f2d7c15faa..00000000000 --- a/backend/ee/LICENSE +++ /dev/null @@ -1,36 +0,0 @@ -The DanswerAI Enterprise license (the “Enterprise License”) -Copyright (c) 2023-present DanswerAI, Inc. - -With regard to the Danswer Software: - -This software and associated documentation files (the "Software") may only be -used in production, if you (and any entity that you represent) have agreed to, -and are in compliance with, the DanswerAI Subscription Terms of Service, available -at https://danswer.ai/terms (the “Enterprise Terms”), or other -agreement governing the use of the Software, as agreed by you and DanswerAI, -and otherwise have a valid Danswer Enterprise license for the -correct number of user seats. Subject to the foregoing sentence, you are free to -modify this Software and publish patches to the Software. You agree that DanswerAI -and/or its licensors (as applicable) retain all right, title and interest in and -to all such modifications and/or patches, and all such modifications and/or -patches may only be used, copied, modified, displayed, distributed, or otherwise -exploited with a valid Danswer Enterprise license for the correct -number of user seats. Notwithstanding the foregoing, you may copy and modify -the Software for development and testing purposes, without requiring a -subscription. You agree that DanswerAI and/or its licensors (as applicable) retain -all right, title and interest in and to all such modifications. You are not -granted any other rights beyond what is expressly stated herein. Subject to the -foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, -and/or sell the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -For all third party components incorporated into the Danswer Software, those -components are licensed under the original license provided by the owner of the -applicable component. diff --git a/backend/ee/__init__.py b/backend/ee/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/__init__.py b/backend/ee/danswer/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/access/access.py b/backend/ee/danswer/access/access.py deleted file mode 100644 index c2b05ee881f..00000000000 --- a/backend/ee/danswer/access/access.py +++ /dev/null @@ -1,51 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.access.access import ( - _get_access_for_documents as get_access_for_documents_without_groups, -) -from danswer.access.access import _get_acl_for_user as get_acl_for_user_without_groups -from danswer.access.models import DocumentAccess -from danswer.access.utils import prefix_user_group -from danswer.db.models import User -from ee.danswer.db.user_group import fetch_user_groups_for_documents -from ee.danswer.db.user_group import fetch_user_groups_for_user - - -def _get_access_for_documents( - document_ids: list[str], - db_session: Session, -) -> dict[str, DocumentAccess]: - non_ee_access_dict = get_access_for_documents_without_groups( - document_ids=document_ids, - db_session=db_session, - ) - user_group_info = { - document_id: group_names - for document_id, group_names in fetch_user_groups_for_documents( - db_session=db_session, - document_ids=document_ids, - ) - } - - return { - document_id: DocumentAccess( - user_ids=non_ee_access.user_ids, - user_groups=user_group_info.get(document_id, []), # type: ignore - is_public=non_ee_access.is_public, - ) - for document_id, non_ee_access in non_ee_access_dict.items() - } - - -def _get_acl_for_user(user: User | None, db_session: Session) -> set[str]: - """Returns a list of ACL entries that the user has access to. This is meant to be - used downstream to filter out documents that the user does not have access to. The - user should have access to a document if at least one entry in the document's ACL - matches one entry in the returned set. - - NOTE: is imported in danswer.access.access by `fetch_versioned_implementation` - DO NOT REMOVE.""" - user_groups = fetch_user_groups_for_user(db_session, user.id) if user else [] - return set( - [prefix_user_group(user_group.name) for user_group in user_groups] - ).union(get_acl_for_user_without_groups(user, db_session)) diff --git a/backend/ee/danswer/auth/__init__.py b/backend/ee/danswer/auth/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/auth/api_key.py b/backend/ee/danswer/auth/api_key.py deleted file mode 100644 index d4f99d13891..00000000000 --- a/backend/ee/danswer/auth/api_key.py +++ /dev/null @@ -1,53 +0,0 @@ -import secrets -import uuid - -from fastapi import Request -from passlib.hash import sha256_crypt -from pydantic import BaseModel - -from danswer.auth.schemas import UserRole -from ee.danswer.configs.app_configs import API_KEY_HASH_ROUNDS - - -_API_KEY_HEADER_NAME = "Authorization" -_BEARER_PREFIX = "Bearer " -_API_KEY_PREFIX = "dn_" -_API_KEY_LEN = 192 - - -class ApiKeyDescriptor(BaseModel): - api_key_id: int - api_key_display: str - api_key: str | None = None # only present on initial creation - api_key_name: str | None = None - api_key_role: UserRole - - user_id: uuid.UUID - - -def generate_api_key() -> str: - return _API_KEY_PREFIX + secrets.token_urlsafe(_API_KEY_LEN) - - -def hash_api_key(api_key: str) -> str: - # NOTE: no salt is needed, as the API key is randomly generated - # and overlaps are impossible - return sha256_crypt.hash(api_key, salt="", rounds=API_KEY_HASH_ROUNDS) - - -def build_displayable_api_key(api_key: str) -> str: - if api_key.startswith(_API_KEY_PREFIX): - api_key = api_key[len(_API_KEY_PREFIX) :] - - return _API_KEY_PREFIX + api_key[:4] + "********" + api_key[-4:] - - -def get_hashed_api_key_from_request(request: Request) -> str | None: - raw_api_key_header = request.headers.get(_API_KEY_HEADER_NAME) - if raw_api_key_header is None: - return None - - if raw_api_key_header.startswith(_BEARER_PREFIX): - raw_api_key_header = raw_api_key_header[len(_BEARER_PREFIX) :].strip() - - return hash_api_key(raw_api_key_header) diff --git a/backend/ee/danswer/auth/users.py b/backend/ee/danswer/auth/users.py deleted file mode 100644 index 18dff6ab064..00000000000 --- a/backend/ee/danswer/auth/users.py +++ /dev/null @@ -1,70 +0,0 @@ -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Request -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.constants import AuthType -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.utils.logger import setup_logger -from ee.danswer.auth.api_key import get_hashed_api_key_from_request -from ee.danswer.db.api_key import fetch_user_for_api_key -from ee.danswer.db.saml import get_saml_account -from ee.danswer.server.seeding import get_seed_config -from ee.danswer.utils.secrets import extract_hashed_cookie - -logger = setup_logger() - - -def verify_auth_setting() -> None: - # All the Auth flows are valid for EE version - logger.notice(f"Using Auth Type: {AUTH_TYPE.value}") - - -async def optional_user_( - request: Request, - user: User | None, - db_session: Session, -) -> User | None: - # Check if the user has a session cookie from SAML - if AUTH_TYPE == AuthType.SAML: - saved_cookie = extract_hashed_cookie(request) - - if saved_cookie: - saml_account = get_saml_account(cookie=saved_cookie, db_session=db_session) - user = saml_account.user if saml_account else None - - # check if an API key is present - if user is None: - hashed_api_key = get_hashed_api_key_from_request(request) - if hashed_api_key: - user = fetch_user_for_api_key(hashed_api_key, db_session) - - return user - - -def api_key_dep( - request: Request, db_session: Session = Depends(get_session) -) -> User | None: - if AUTH_TYPE == AuthType.DISABLED: - return None - - hashed_api_key = get_hashed_api_key_from_request(request) - if not hashed_api_key: - raise HTTPException(status_code=401, detail="Missing API key") - - if hashed_api_key: - user = fetch_user_for_api_key(hashed_api_key, db_session) - - if user is None: - raise HTTPException(status_code=401, detail="Invalid API key") - - return user - - -def get_default_admin_user_emails_() -> list[str]: - seed_config = get_seed_config() - if seed_config and seed_config.admin_user_emails: - return seed_config.admin_user_emails - return [] diff --git a/backend/ee/danswer/background/celery/celery_app.py b/backend/ee/danswer/background/celery/celery_app.py deleted file mode 100644 index 403adbd74e1..00000000000 --- a/backend/ee/danswer/background/celery/celery_app.py +++ /dev/null @@ -1,131 +0,0 @@ -from datetime import timedelta -from typing import Any - -from celery.signals import beat_init -from celery.signals import worker_init -from sqlalchemy.orm import Session - -from danswer.background.celery.celery_app import celery_app -from danswer.background.task_utils import build_celery_task_wrapper -from danswer.configs.app_configs import JOB_TIMEOUT -from danswer.configs.constants import POSTGRES_CELERY_BEAT_APP_NAME -from danswer.configs.constants import POSTGRES_CELERY_WORKER_APP_NAME -from danswer.db.chat import delete_chat_sessions_older_than -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.engine import init_sqlalchemy_engine -from danswer.server.settings.store import load_settings -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import global_version -from ee.danswer.background.celery_utils import should_perform_chat_ttl_check -from ee.danswer.background.celery_utils import should_sync_user_groups -from ee.danswer.background.task_name_builders import name_chat_ttl_task -from ee.danswer.background.task_name_builders import name_user_group_sync_task -from ee.danswer.db.user_group import fetch_user_groups -from ee.danswer.server.reporting.usage_export_generation import create_new_usage_report -from ee.danswer.user_groups.sync import sync_user_groups - -logger = setup_logger() - -# mark as EE for all tasks in this file -global_version.set_ee() - - -@build_celery_task_wrapper(name_user_group_sync_task) -@celery_app.task(soft_time_limit=JOB_TIMEOUT) -def sync_user_group_task(user_group_id: int) -> None: - with Session(get_sqlalchemy_engine()) as db_session: - # actual sync logic - try: - sync_user_groups(user_group_id=user_group_id, db_session=db_session) - except Exception as e: - logger.exception(f"Failed to sync user group - {e}") - - -@build_celery_task_wrapper(name_chat_ttl_task) -@celery_app.task(soft_time_limit=JOB_TIMEOUT) -def perform_ttl_management_task(retention_limit_days: int) -> None: - with Session(get_sqlalchemy_engine()) as db_session: - delete_chat_sessions_older_than(retention_limit_days, db_session) - - -##### -# Periodic Tasks -##### - - -@celery_app.task( - name="check_ttl_management_task", - soft_time_limit=JOB_TIMEOUT, -) -def check_ttl_management_task() -> None: - """Runs periodically to check if any ttl tasks should be run and adds them - to the queue""" - settings = load_settings() - retention_limit_days = settings.maximum_chat_retention_days - with Session(get_sqlalchemy_engine()) as db_session: - if should_perform_chat_ttl_check(retention_limit_days, db_session): - perform_ttl_management_task.apply_async( - kwargs=dict(retention_limit_days=retention_limit_days), - ) - - -@celery_app.task( - name="check_for_user_groups_sync_task", - soft_time_limit=JOB_TIMEOUT, -) -def check_for_user_groups_sync_task() -> None: - """Runs periodically to check if any user groups are out of sync - Creates a task to sync the user group if needed""" - with Session(get_sqlalchemy_engine()) as db_session: - # check if any document sets are not synced - user_groups = fetch_user_groups(db_session=db_session, only_current=False) - for user_group in user_groups: - if should_sync_user_groups(user_group, db_session): - logger.info(f"User Group {user_group.id} is not synced. Syncing now!") - sync_user_group_task.apply_async( - kwargs=dict(user_group_id=user_group.id), - ) - - -@celery_app.task( - name="autogenerate_usage_report_task", - soft_time_limit=JOB_TIMEOUT, -) -def autogenerate_usage_report_task() -> None: - """This generates usage report under the /admin/generate-usage/report endpoint""" - with Session(get_sqlalchemy_engine()) as db_session: - create_new_usage_report( - db_session=db_session, - user_id=None, - period=None, - ) - - -@beat_init.connect -def on_beat_init(sender: Any, **kwargs: Any) -> None: - init_sqlalchemy_engine(POSTGRES_CELERY_BEAT_APP_NAME) - - -@worker_init.connect -def on_worker_init(sender: Any, **kwargs: Any) -> None: - init_sqlalchemy_engine(POSTGRES_CELERY_WORKER_APP_NAME) - - -##### -# Celery Beat (Periodic Tasks) Settings -##### -celery_app.conf.beat_schedule = { - "check-for-user-group-sync": { - "task": "check_for_user_groups_sync_task", - "schedule": timedelta(seconds=5), - }, - "autogenerate_usage_report": { - "task": "autogenerate_usage_report_task", - "schedule": timedelta(days=30), # TODO: change this to config flag - }, - "check-ttl-management": { - "task": "check_ttl_management_task", - "schedule": timedelta(hours=1), - }, - **(celery_app.conf.beat_schedule or {}), -} diff --git a/backend/ee/danswer/background/celery_utils.py b/backend/ee/danswer/background/celery_utils.py deleted file mode 100644 index 0134f6642f7..00000000000 --- a/backend/ee/danswer/background/celery_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.db.models import UserGroup -from danswer.db.tasks import check_task_is_live_and_not_timed_out -from danswer.db.tasks import get_latest_task -from danswer.utils.logger import setup_logger -from ee.danswer.background.task_name_builders import name_chat_ttl_task -from ee.danswer.background.task_name_builders import name_user_group_sync_task - -logger = setup_logger() - - -def should_sync_user_groups(user_group: UserGroup, db_session: Session) -> bool: - if user_group.is_up_to_date: - return False - task_name = name_user_group_sync_task(user_group.id) - latest_sync = get_latest_task(task_name, db_session) - - if latest_sync and check_task_is_live_and_not_timed_out(latest_sync, db_session): - logger.info("TTL check is already being performed. Skipping.") - return False - return True - - -def should_perform_chat_ttl_check( - retention_limit_days: int | None, db_session: Session -) -> bool: - # TODO: make this a check for None and add behavior for 0 day TTL - if not retention_limit_days: - return False - - task_name = name_chat_ttl_task(retention_limit_days) - latest_task = get_latest_task(task_name, db_session) - if not latest_task: - return True - - if latest_task and check_task_is_live_and_not_timed_out(latest_task, db_session): - logger.info("TTL check is already being performed. Skipping.") - return False - return True diff --git a/backend/ee/danswer/background/permission_sync.py b/backend/ee/danswer/background/permission_sync.py deleted file mode 100644 index c14094b6042..00000000000 --- a/backend/ee/danswer/background/permission_sync.py +++ /dev/null @@ -1,224 +0,0 @@ -import logging -import time -from datetime import datetime - -import dask -from dask.distributed import Client -from dask.distributed import Future -from distributed import LocalCluster -from sqlalchemy.orm import Session - -from danswer.background.indexing.dask_utils import ResourceLogger -from danswer.background.indexing.job_client import SimpleJob -from danswer.background.indexing.job_client import SimpleJobClient -from danswer.configs.app_configs import CLEANUP_INDEXING_JOBS_TIMEOUT -from danswer.configs.app_configs import DASK_JOB_CLIENT_ENABLED -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import POSTGRES_PERMISSIONS_APP_NAME -from danswer.db.engine import get_sqlalchemy_engine -from danswer.db.engine import init_sqlalchemy_engine -from danswer.db.models import PermissionSyncStatus -from danswer.utils.logger import setup_logger -from ee.danswer.configs.app_configs import NUM_PERMISSION_WORKERS -from ee.danswer.connectors.factory import CONNECTOR_PERMISSION_FUNC_MAP -from ee.danswer.db.connector import fetch_sources_with_connectors -from ee.danswer.db.connector_credential_pair import get_cc_pairs_by_source -from ee.danswer.db.permission_sync import create_perm_sync -from ee.danswer.db.permission_sync import expire_perm_sync_timed_out -from ee.danswer.db.permission_sync import get_perm_sync_attempt -from ee.danswer.db.permission_sync import mark_all_inprogress_permission_sync_failed -from shared_configs.configs import LOG_LEVEL - -logger = setup_logger() - -# If the indexing dies, it's most likely due to resource constraints, -# restarting just delays the eventual failure, not useful to the user -dask.config.set({"distributed.scheduler.allowed-failures": 0}) - - -def cleanup_perm_sync_jobs( - existing_jobs: dict[tuple[int, int | DocumentSource], Future | SimpleJob], - # Just reusing the same timeout, fine for now - timeout_hours: int = CLEANUP_INDEXING_JOBS_TIMEOUT, -) -> dict[tuple[int, int | DocumentSource], Future | SimpleJob]: - existing_jobs_copy = existing_jobs.copy() - - with Session(get_sqlalchemy_engine()) as db_session: - # clean up completed jobs - for (attempt_id, details), job in existing_jobs.items(): - perm_sync_attempt = get_perm_sync_attempt( - attempt_id=attempt_id, db_session=db_session - ) - - # do nothing for ongoing jobs that haven't been stopped - if ( - not job.done() - and perm_sync_attempt.status == PermissionSyncStatus.IN_PROGRESS - ): - continue - - if job.status == "error": - logger.error(job.exception()) - - job.release() - del existing_jobs_copy[(attempt_id, details)] - - # clean up in-progress jobs that were never completed - expire_perm_sync_timed_out( - timeout_hours=timeout_hours, - db_session=db_session, - ) - - return existing_jobs_copy - - -def create_group_sync_jobs( - existing_jobs: dict[tuple[int, int | DocumentSource], Future | SimpleJob], - client: Client | SimpleJobClient, -) -> dict[tuple[int, int | DocumentSource], Future | SimpleJob]: - """Creates new relational DB group permission sync job for each source that: - - has permission sync enabled - - has at least 1 connector (enabled or paused) - - has no sync already running - """ - existing_jobs_copy = existing_jobs.copy() - sources_w_runs = [ - key[1] - for key in existing_jobs_copy.keys() - if isinstance(key[1], DocumentSource) - ] - with Session(get_sqlalchemy_engine()) as db_session: - sources_w_connector = fetch_sources_with_connectors(db_session) - for source_type in sources_w_connector: - if source_type not in CONNECTOR_PERMISSION_FUNC_MAP: - continue - if source_type in sources_w_runs: - continue - - db_group_fnc, _ = CONNECTOR_PERMISSION_FUNC_MAP[source_type] - perm_sync = create_perm_sync( - source_type=source_type, - group_update=True, - cc_pair_id=None, - db_session=db_session, - ) - - run = client.submit(db_group_fnc, pure=False) - - logger.info( - f"Kicked off group permission sync for source type {source_type}" - ) - - if run: - existing_jobs_copy[(perm_sync.id, source_type)] = run - - return existing_jobs_copy - - -def create_connector_perm_sync_jobs( - existing_jobs: dict[tuple[int, int | DocumentSource], Future | SimpleJob], - client: Client | SimpleJobClient, -) -> dict[tuple[int, int | DocumentSource], Future | SimpleJob]: - """Update Document Index ACL sync job for each cc-pair where: - - source type has permission sync enabled - - has no sync already running - """ - existing_jobs_copy = existing_jobs.copy() - cc_pairs_w_runs = [ - key[1] - for key in existing_jobs_copy.keys() - if isinstance(key[1], DocumentSource) - ] - with Session(get_sqlalchemy_engine()) as db_session: - sources_w_connector = fetch_sources_with_connectors(db_session) - for source_type in sources_w_connector: - if source_type not in CONNECTOR_PERMISSION_FUNC_MAP: - continue - - _, index_sync_fnc = CONNECTOR_PERMISSION_FUNC_MAP[source_type] - - cc_pairs = get_cc_pairs_by_source(source_type, db_session) - - for cc_pair in cc_pairs: - if cc_pair.id in cc_pairs_w_runs: - continue - - perm_sync = create_perm_sync( - source_type=source_type, - group_update=False, - cc_pair_id=cc_pair.id, - db_session=db_session, - ) - - run = client.submit(index_sync_fnc, cc_pair.id, pure=False) - - logger.info(f"Kicked off ACL sync for cc-pair {cc_pair.id}") - - if run: - existing_jobs_copy[(perm_sync.id, cc_pair.id)] = run - - return existing_jobs_copy - - -def permission_loop(delay: int = 60, num_workers: int = NUM_PERMISSION_WORKERS) -> None: - client: Client | SimpleJobClient - if DASK_JOB_CLIENT_ENABLED: - cluster_primary = LocalCluster( - n_workers=num_workers, - threads_per_worker=1, - # there are warning about high memory usage + "Event loop unresponsive" - # which are not relevant to us since our workers are expected to use a - # lot of memory + involve CPU intensive tasks that will not relinquish - # the event loop - silence_logs=logging.ERROR, - ) - client = Client(cluster_primary) - if LOG_LEVEL.lower() == "debug": - client.register_worker_plugin(ResourceLogger()) - else: - client = SimpleJobClient(n_workers=num_workers) - - existing_jobs: dict[tuple[int, int | DocumentSource], Future | SimpleJob] = {} - engine = get_sqlalchemy_engine() - - with Session(engine) as db_session: - # Any jobs still in progress on restart must have died - mark_all_inprogress_permission_sync_failed(db_session) - - while True: - start = time.time() - start_time_utc = datetime.utcfromtimestamp(start).strftime("%Y-%m-%d %H:%M:%S") - logger.info(f"Running Permission Sync, current UTC time: {start_time_utc}") - - if existing_jobs: - logger.debug( - "Found existing permission sync jobs: " - f"{[(attempt_id, job.status) for attempt_id, job in existing_jobs.items()]}" - ) - - try: - # TODO turn this on when it works - """ - existing_jobs = cleanup_perm_sync_jobs(existing_jobs=existing_jobs) - existing_jobs = create_group_sync_jobs( - existing_jobs=existing_jobs, client=client - ) - existing_jobs = create_connector_perm_sync_jobs( - existing_jobs=existing_jobs, client=client - ) - """ - except Exception as e: - logger.exception(f"Failed to run update due to {e}") - sleep_time = delay - (time.time() - start) - if sleep_time > 0: - time.sleep(sleep_time) - - -def update__main() -> None: - logger.notice("Starting Permission Syncing Loop") - init_sqlalchemy_engine(POSTGRES_PERMISSIONS_APP_NAME) - permission_loop() - - -if __name__ == "__main__": - update__main() diff --git a/backend/ee/danswer/background/task_name_builders.py b/backend/ee/danswer/background/task_name_builders.py deleted file mode 100644 index 4f1046adbbb..00000000000 --- a/backend/ee/danswer/background/task_name_builders.py +++ /dev/null @@ -1,6 +0,0 @@ -def name_user_group_sync_task(user_group_id: int) -> str: - return f"user_group_sync_task__{user_group_id}" - - -def name_chat_ttl_task(retention_limit_days: int) -> str: - return f"chat_ttl_{retention_limit_days}_days" diff --git a/backend/ee/danswer/configs/__init__.py b/backend/ee/danswer/configs/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/configs/app_configs.py b/backend/ee/danswer/configs/app_configs.py deleted file mode 100644 index 1430a499136..00000000000 --- a/backend/ee/danswer/configs/app_configs.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -# Applicable for OIDC Auth -OPENID_CONFIG_URL = os.environ.get("OPENID_CONFIG_URL", "") - -# Applicable for SAML Auth -SAML_CONF_DIR = os.environ.get("SAML_CONF_DIR") or "/app/ee/danswer/configs/saml_config" - - -##### -# API Key Configs -##### -# refers to the rounds described here: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.sha256_crypt.html -_API_KEY_HASH_ROUNDS_RAW = os.environ.get("API_KEY_HASH_ROUNDS") -API_KEY_HASH_ROUNDS = ( - int(_API_KEY_HASH_ROUNDS_RAW) if _API_KEY_HASH_ROUNDS_RAW else None -) - - -##### -# Auto Permission Sync -##### -NUM_PERMISSION_WORKERS = int(os.environ.get("NUM_PERMISSION_WORKERS") or 2) diff --git a/backend/ee/danswer/configs/saml_config/template.settings.json b/backend/ee/danswer/configs/saml_config/template.settings.json deleted file mode 100644 index e3c828944af..00000000000 --- a/backend/ee/danswer/configs/saml_config/template.settings.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "strict": true, - "debug": false, - "idp": { - "entityId": "", - "singleSignOnService": { - "url": " https://trial-1234567.okta.com/home/trial-1234567_danswer/somevalues/somevalues", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - "x509cert": "" - }, - "sp": { - "entityId": "", - "assertionConsumerService": { - "url": "http://127.0.0.1:3000/auth/saml/callback", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - }, - "x509cert": "" - } -} diff --git a/backend/ee/danswer/connectors/__init__.py b/backend/ee/danswer/connectors/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/connectors/confluence/__init__.py b/backend/ee/danswer/connectors/confluence/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/connectors/confluence/perm_sync.py b/backend/ee/danswer/connectors/confluence/perm_sync.py deleted file mode 100644 index 2985b47b0d1..00000000000 --- a/backend/ee/danswer/connectors/confluence/perm_sync.py +++ /dev/null @@ -1,12 +0,0 @@ -from danswer.utils.logger import setup_logger - - -logger = setup_logger() - - -def confluence_update_db_group() -> None: - logger.debug("Not yet implemented group sync for confluence, no-op") - - -def confluence_update_index_acl(cc_pair_id: int) -> None: - logger.debug("Not yet implemented ACL sync for confluence, no-op") diff --git a/backend/ee/danswer/connectors/factory.py b/backend/ee/danswer/connectors/factory.py deleted file mode 100644 index 52f9324948b..00000000000 --- a/backend/ee/danswer/connectors/factory.py +++ /dev/null @@ -1,8 +0,0 @@ -from danswer.configs.constants import DocumentSource -from ee.danswer.connectors.confluence.perm_sync import confluence_update_db_group -from ee.danswer.connectors.confluence.perm_sync import confluence_update_index_acl - - -CONNECTOR_PERMISSION_FUNC_MAP = { - DocumentSource.CONFLUENCE: (confluence_update_db_group, confluence_update_index_acl) -} diff --git a/backend/ee/danswer/db/__init__.py b/backend/ee/danswer/db/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/db/analytics.py b/backend/ee/danswer/db/analytics.py deleted file mode 100644 index e0eff7850e4..00000000000 --- a/backend/ee/danswer/db/analytics.py +++ /dev/null @@ -1,172 +0,0 @@ -import datetime -from collections.abc import Sequence -from uuid import UUID - -from sqlalchemy import case -from sqlalchemy import cast -from sqlalchemy import Date -from sqlalchemy import func -from sqlalchemy import or_ -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.constants import MessageType -from danswer.db.models import ChatMessage -from danswer.db.models import ChatMessageFeedback -from danswer.db.models import ChatSession - - -def fetch_query_analytics( - start: datetime.datetime, - end: datetime.datetime, - db_session: Session, -) -> Sequence[tuple[int, int, int, datetime.date]]: - stmt = ( - select( - func.count(ChatMessage.id), - func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)), - func.sum( - case( - (ChatMessageFeedback.is_positive == False, 1), else_=0 # noqa: E712 - ) - ), - cast(ChatMessage.time_sent, Date), - ) - .join( - ChatMessageFeedback, - ChatMessageFeedback.chat_message_id == ChatMessage.id, - isouter=True, - ) - .where( - ChatMessage.time_sent >= start, - ) - .where( - ChatMessage.time_sent <= end, - ) - .where(ChatMessage.message_type == MessageType.ASSISTANT) - .group_by(cast(ChatMessage.time_sent, Date)) - .order_by(cast(ChatMessage.time_sent, Date)) - ) - - return db_session.execute(stmt).all() # type: ignore - - -def fetch_per_user_query_analytics( - start: datetime.datetime, - end: datetime.datetime, - db_session: Session, -) -> Sequence[tuple[int, int, int, datetime.date, UUID]]: - stmt = ( - select( - func.count(ChatMessage.id), - func.sum(case((ChatMessageFeedback.is_positive, 1), else_=0)), - func.sum( - case( - (ChatMessageFeedback.is_positive == False, 1), else_=0 # noqa: E712 - ) - ), - cast(ChatMessage.time_sent, Date), - ChatSession.user_id, - ) - .join(ChatSession, ChatSession.id == ChatMessage.chat_session_id) - .where( - ChatMessage.time_sent >= start, - ) - .where( - ChatMessage.time_sent <= end, - ) - .where(ChatMessage.message_type == MessageType.ASSISTANT) - .group_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id) - .order_by(cast(ChatMessage.time_sent, Date), ChatSession.user_id) - ) - - return db_session.execute(stmt).all() # type: ignore - - -def fetch_danswerbot_analytics( - start: datetime.datetime, - end: datetime.datetime, - db_session: Session, -) -> Sequence[tuple[int, int, datetime.date]]: - """Gets the: - Date of each set of aggregated statistics - Number of DanswerBot Queries (Chat Sessions) - Number of instances of Negative feedback OR Needing additional help - (only counting the last feedback) - """ - # Get every chat session in the time range which is a Danswerbot flow - # along with the first Assistant message which is the response to the user question. - # Generally there should not be more than one AI message per chat session of this type - subquery_first_ai_response = ( - db_session.query( - ChatMessage.chat_session_id.label("chat_session_id"), - func.min(ChatMessage.id).label("chat_message_id"), - ) - .join(ChatSession, ChatSession.id == ChatMessage.chat_session_id) - .where( - ChatSession.time_created >= start, - ChatSession.time_created <= end, - ChatSession.danswerbot_flow.is_(True), - ) - .where( - ChatMessage.message_type == MessageType.ASSISTANT, - ) - .group_by(ChatMessage.chat_session_id) - .subquery() - ) - - # Get the chat message ids and most recent feedback for each of those chat messages, - # not including the messages that have no feedback - subquery_last_feedback = ( - db_session.query( - ChatMessageFeedback.chat_message_id.label("chat_message_id"), - func.max(ChatMessageFeedback.id).label("max_feedback_id"), - ) - .group_by(ChatMessageFeedback.chat_message_id) - .subquery() - ) - - results = ( - db_session.query( - func.count(ChatSession.id).label("total_sessions"), - # Need to explicitly specify this as False to handle the NULL case so the cases without - # feedback aren't counted against Danswerbot - func.sum( - case( - ( - or_( - ChatMessageFeedback.is_positive.is_(False), - ChatMessageFeedback.required_followup, - ), - 1, - ), - else_=0, - ) - ).label("negative_answer"), - cast(ChatSession.time_created, Date).label("session_date"), - ) - .join( - subquery_first_ai_response, - ChatSession.id == subquery_first_ai_response.c.chat_session_id, - ) - # Combine the chat sessions with latest feedback to get the latest feedback for the first AI - # message of the chat session where the chat session is Danswerbot type and within the time - # range specified. Left/outer join used here to ensure that if no feedback, a null is used - # for the feedback id - .outerjoin( - subquery_last_feedback, - subquery_first_ai_response.c.chat_message_id - == subquery_last_feedback.c.chat_message_id, - ) - # Join the actual feedback table to get the feedback info for the sums - # Outer join because the "last feedback" may be null - .outerjoin( - ChatMessageFeedback, - ChatMessageFeedback.id == subquery_last_feedback.c.max_feedback_id, - ) - .group_by(cast(ChatSession.time_created, Date)) - .order_by(cast(ChatSession.time_created, Date)) - .all() - ) - - return results diff --git a/backend/ee/danswer/db/api_key.py b/backend/ee/danswer/db/api_key.py deleted file mode 100644 index c38f32a0f84..00000000000 --- a/backend/ee/danswer/db/api_key.py +++ /dev/null @@ -1,169 +0,0 @@ -import uuid - -from fastapi_users.password import PasswordHelper -from sqlalchemy import select -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import Session - -from danswer.configs.constants import DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN -from danswer.configs.constants import DANSWER_API_KEY_PREFIX -from danswer.configs.constants import UNNAMED_KEY_PLACEHOLDER -from danswer.db.models import ApiKey -from danswer.db.models import User -from ee.danswer.auth.api_key import ApiKeyDescriptor -from ee.danswer.auth.api_key import build_displayable_api_key -from ee.danswer.auth.api_key import generate_api_key -from ee.danswer.auth.api_key import hash_api_key -from ee.danswer.server.api_key.models import APIKeyArgs - - -def is_api_key_email_address(email: str) -> bool: - return email.endswith(f"{DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN}") - - -def fetch_api_keys(db_session: Session) -> list[ApiKeyDescriptor]: - api_keys = ( - db_session.scalars(select(ApiKey).options(joinedload(ApiKey.user))) - .unique() - .all() - ) - return [ - ApiKeyDescriptor( - api_key_id=api_key.id, - api_key_role=api_key.user.role, - api_key_display=api_key.api_key_display, - api_key_name=api_key.name, - user_id=api_key.user_id, - ) - for api_key in api_keys - ] - - -def fetch_user_for_api_key(hashed_api_key: str, db_session: Session) -> User | None: - api_key = db_session.scalar( - select(ApiKey).where(ApiKey.hashed_api_key == hashed_api_key) - ) - if api_key is None: - return None - - return db_session.scalar(select(User).where(User.id == api_key.user_id)) # type: ignore - - -def get_api_key_fake_email( - name: str, - unique_id: str, -) -> str: - return f"{DANSWER_API_KEY_PREFIX}{name}@{unique_id}{DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN}" - - -def insert_api_key( - db_session: Session, api_key_args: APIKeyArgs, user_id: uuid.UUID | None -) -> ApiKeyDescriptor: - std_password_helper = PasswordHelper() - api_key = generate_api_key() - api_key_user_id = uuid.uuid4() - - display_name = api_key_args.name or UNNAMED_KEY_PLACEHOLDER - api_key_user_row = User( - id=api_key_user_id, - email=get_api_key_fake_email(display_name, str(api_key_user_id)), - # a random password for the "user" - hashed_password=std_password_helper.hash(std_password_helper.generate()), - is_active=True, - is_superuser=False, - is_verified=True, - role=api_key_args.role, - ) - db_session.add(api_key_user_row) - - api_key_row = ApiKey( - name=api_key_args.name, - hashed_api_key=hash_api_key(api_key), - api_key_display=build_displayable_api_key(api_key), - user_id=api_key_user_id, - owner_id=user_id, - ) - db_session.add(api_key_row) - - db_session.commit() - return ApiKeyDescriptor( - api_key_id=api_key_row.id, - api_key_role=api_key_user_row.role, - api_key_display=api_key_row.api_key_display, - api_key=api_key, - api_key_name=api_key_args.name, - user_id=api_key_user_id, - ) - - -def update_api_key( - db_session: Session, api_key_id: int, api_key_args: APIKeyArgs -) -> ApiKeyDescriptor: - existing_api_key = db_session.scalar(select(ApiKey).where(ApiKey.id == api_key_id)) - if existing_api_key is None: - raise ValueError(f"API key with id {api_key_id} does not exist") - - existing_api_key.name = api_key_args.name - api_key_user = db_session.scalar( - select(User).where(User.id == existing_api_key.user_id) # type: ignore - ) - if api_key_user is None: - raise RuntimeError("API Key does not have associated user.") - - email_name = api_key_args.name or UNNAMED_KEY_PLACEHOLDER - api_key_user.email = get_api_key_fake_email(email_name, str(api_key_user.id)) - api_key_user.role = api_key_args.role - db_session.commit() - - return ApiKeyDescriptor( - api_key_id=existing_api_key.id, - api_key_display=existing_api_key.api_key_display, - api_key_name=api_key_args.name, - api_key_role=api_key_user.role, - user_id=existing_api_key.user_id, - ) - - -def regenerate_api_key(db_session: Session, api_key_id: int) -> ApiKeyDescriptor: - """NOTE: currently, any admin can regenerate any API key.""" - existing_api_key = db_session.scalar(select(ApiKey).where(ApiKey.id == api_key_id)) - if existing_api_key is None: - raise ValueError(f"API key with id {api_key_id} does not exist") - - api_key_user = db_session.scalar( - select(User).where(User.id == existing_api_key.user_id) # type: ignore - ) - if api_key_user is None: - raise RuntimeError("API Key does not have associated user.") - - new_api_key = generate_api_key() - existing_api_key.hashed_api_key = hash_api_key(new_api_key) - existing_api_key.api_key_display = build_displayable_api_key(new_api_key) - db_session.commit() - - return ApiKeyDescriptor( - api_key_id=existing_api_key.id, - api_key_display=existing_api_key.api_key_display, - api_key=new_api_key, - api_key_name=existing_api_key.name, - api_key_role=api_key_user.role, - user_id=existing_api_key.user_id, - ) - - -def remove_api_key(db_session: Session, api_key_id: int) -> None: - existing_api_key = db_session.scalar(select(ApiKey).where(ApiKey.id == api_key_id)) - if existing_api_key is None: - raise ValueError(f"API key with id {api_key_id} does not exist") - - user_associated_with_key = db_session.scalar( - select(User).where(User.id == existing_api_key.user_id) # type: ignore - ) - if user_associated_with_key is None: - raise ValueError( - f"User associated with API key with id {api_key_id} does not exist. This should not happen." - ) - - db_session.delete(existing_api_key) - db_session.delete(user_associated_with_key) - db_session.commit() diff --git a/backend/ee/danswer/db/connector.py b/backend/ee/danswer/db/connector.py deleted file mode 100644 index 44505f51510..00000000000 --- a/backend/ee/danswer/db/connector.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlalchemy import distinct -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.models import Connector -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def fetch_sources_with_connectors(db_session: Session) -> list[DocumentSource]: - sources = db_session.query(distinct(Connector.source)).all() # type: ignore - - document_sources = [source[0] for source in sources] - - return document_sources diff --git a/backend/ee/danswer/db/connector_credential_pair.py b/backend/ee/danswer/db/connector_credential_pair.py deleted file mode 100644 index a2172913476..00000000000 --- a/backend/ee/danswer/db/connector_credential_pair.py +++ /dev/null @@ -1,45 +0,0 @@ -from sqlalchemy import delete -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.connector_credential_pair import get_connector_credential_pair -from danswer.db.models import Connector -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import UserGroup__ConnectorCredentialPair -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def _delete_connector_credential_pair_user_groups_relationship__no_commit( - db_session: Session, connector_id: int, credential_id: int -) -> None: - cc_pair = get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - if cc_pair is None: - raise ValueError( - f"ConnectorCredentialPair with connector_id: {connector_id} " - f"and credential_id: {credential_id} not found" - ) - - stmt = delete(UserGroup__ConnectorCredentialPair).where( - UserGroup__ConnectorCredentialPair.cc_pair_id == cc_pair.id, - ) - db_session.execute(stmt) - - -def get_cc_pairs_by_source( - source_type: DocumentSource, - db_session: Session, -) -> list[ConnectorCredentialPair]: - cc_pairs = ( - db_session.query(ConnectorCredentialPair) - .join(ConnectorCredentialPair.connector) - .filter(Connector.source == source_type) - .all() - ) - - return cc_pairs diff --git a/backend/ee/danswer/db/document.py b/backend/ee/danswer/db/document.py deleted file mode 100644 index 5a368ea170e..00000000000 --- a/backend/ee/danswer/db/document.py +++ /dev/null @@ -1,14 +0,0 @@ -from collections.abc import Sequence - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.models import Document - - -def fetch_documents_from_ids( - db_session: Session, document_ids: list[str] -) -> Sequence[Document]: - return db_session.scalars( - select(Document).where(Document.id.in_(document_ids)) - ).all() diff --git a/backend/ee/danswer/db/document_set.py b/backend/ee/danswer/db/document_set.py deleted file mode 100644 index bcbd06874da..00000000000 --- a/backend/ee/danswer/db/document_set.py +++ /dev/null @@ -1,123 +0,0 @@ -from uuid import UUID - -from sqlalchemy.orm import Session - -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import DocumentSet -from danswer.db.models import DocumentSet__ConnectorCredentialPair -from danswer.db.models import DocumentSet__User -from danswer.db.models import DocumentSet__UserGroup -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup - - -def make_doc_set_private( - document_set_id: int, - user_ids: list[UUID] | None, - group_ids: list[int] | None, - db_session: Session, -) -> None: - db_session.query(DocumentSet__User).filter( - DocumentSet__User.document_set_id == document_set_id - ).delete(synchronize_session="fetch") - db_session.query(DocumentSet__UserGroup).filter( - DocumentSet__UserGroup.document_set_id == document_set_id - ).delete(synchronize_session="fetch") - - if user_ids: - for user_uuid in user_ids: - db_session.add( - DocumentSet__User(document_set_id=document_set_id, user_id=user_uuid) - ) - - if group_ids: - for group_id in group_ids: - db_session.add( - DocumentSet__UserGroup( - document_set_id=document_set_id, user_group_id=group_id - ) - ) - - -def delete_document_set_privacy__no_commit( - document_set_id: int, db_session: Session -) -> None: - db_session.query(DocumentSet__User).filter( - DocumentSet__User.document_set_id == document_set_id - ).delete(synchronize_session="fetch") - - db_session.query(DocumentSet__UserGroup).filter( - DocumentSet__UserGroup.document_set_id == document_set_id - ).delete(synchronize_session="fetch") - - -def fetch_document_sets( - user_id: UUID | None, - db_session: Session, - include_outdated: bool = True, # Parameter only for versioned implementation, unused -) -> list[tuple[DocumentSet, list[ConnectorCredentialPair]]]: - assert user_id is not None - - # Public document sets - public_document_sets = ( - db_session.query(DocumentSet) - .filter(DocumentSet.is_public == True) # noqa - .all() - ) - - # Document sets via shared user relationships - shared_document_sets = ( - db_session.query(DocumentSet) - .join(DocumentSet__User, DocumentSet.id == DocumentSet__User.document_set_id) - .filter(DocumentSet__User.user_id == user_id) - .all() - ) - - # Document sets via groups - # First, find the user groups the user belongs to - user_groups = ( - db_session.query(UserGroup) - .join(User__UserGroup, UserGroup.id == User__UserGroup.user_group_id) - .filter(User__UserGroup.user_id == user_id) - .all() - ) - - group_document_sets = [] - for group in user_groups: - group_document_sets.extend( - db_session.query(DocumentSet) - .join( - DocumentSet__UserGroup, - DocumentSet.id == DocumentSet__UserGroup.document_set_id, - ) - .filter(DocumentSet__UserGroup.user_group_id == group.id) - .all() - ) - - # Combine and deduplicate document sets from all sources - all_document_sets = list( - set(public_document_sets + shared_document_sets + group_document_sets) - ) - - document_set_with_cc_pairs: list[ - tuple[DocumentSet, list[ConnectorCredentialPair]] - ] = [] - - for document_set in all_document_sets: - # Fetch the associated ConnectorCredentialPairs - cc_pairs = ( - db_session.query(ConnectorCredentialPair) - .join( - DocumentSet__ConnectorCredentialPair, - ConnectorCredentialPair.id - == DocumentSet__ConnectorCredentialPair.connector_credential_pair_id, - ) - .filter( - DocumentSet__ConnectorCredentialPair.document_set_id == document_set.id, - ) - .all() - ) - - document_set_with_cc_pairs.append((document_set, cc_pairs)) # type: ignore - - return document_set_with_cc_pairs diff --git a/backend/ee/danswer/db/permission_sync.py b/backend/ee/danswer/db/permission_sync.py deleted file mode 100644 index 7642bb65321..00000000000 --- a/backend/ee/danswer/db/permission_sync.py +++ /dev/null @@ -1,72 +0,0 @@ -from datetime import timedelta - -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.exc import NoResultFound -from sqlalchemy.orm import Session - -from danswer.configs.constants import DocumentSource -from danswer.db.models import PermissionSyncRun -from danswer.db.models import PermissionSyncStatus -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def mark_all_inprogress_permission_sync_failed( - db_session: Session, -) -> None: - stmt = ( - update(PermissionSyncRun) - .where(PermissionSyncRun.status == PermissionSyncStatus.IN_PROGRESS) - .values(status=PermissionSyncStatus.FAILED) - ) - db_session.execute(stmt) - db_session.commit() - - -def get_perm_sync_attempt(attempt_id: int, db_session: Session) -> PermissionSyncRun: - stmt = select(PermissionSyncRun).where(PermissionSyncRun.id == attempt_id) - try: - return db_session.scalars(stmt).one() - except NoResultFound: - raise ValueError(f"No PermissionSyncRun found with id {attempt_id}") - - -def expire_perm_sync_timed_out( - timeout_hours: int, - db_session: Session, -) -> None: - cutoff_time = func.now() - timedelta(hours=timeout_hours) - - update_stmt = ( - update(PermissionSyncRun) - .where( - PermissionSyncRun.status == PermissionSyncStatus.IN_PROGRESS, - PermissionSyncRun.updated_at < cutoff_time, - ) - .values(status=PermissionSyncStatus.FAILED, error_msg="timed out") - ) - - db_session.execute(update_stmt) - db_session.commit() - - -def create_perm_sync( - source_type: DocumentSource, - group_update: bool, - cc_pair_id: int | None, - db_session: Session, -) -> PermissionSyncRun: - new_run = PermissionSyncRun( - source_type=source_type, - status=PermissionSyncStatus.IN_PROGRESS, - group_update=group_update, - cc_pair_id=cc_pair_id, - ) - - db_session.add(new_run) - db_session.commit() - - return new_run diff --git a/backend/ee/danswer/db/persona.py b/backend/ee/danswer/db/persona.py deleted file mode 100644 index a7e257278c9..00000000000 --- a/backend/ee/danswer/db/persona.py +++ /dev/null @@ -1,32 +0,0 @@ -from uuid import UUID - -from sqlalchemy.orm import Session - -from danswer.db.models import Persona__User -from danswer.db.models import Persona__UserGroup - - -def make_persona_private( - persona_id: int, - user_ids: list[UUID] | None, - group_ids: list[int] | None, - db_session: Session, -) -> None: - db_session.query(Persona__User).filter( - Persona__User.persona_id == persona_id - ).delete(synchronize_session="fetch") - db_session.query(Persona__UserGroup).filter( - Persona__UserGroup.persona_id == persona_id - ).delete(synchronize_session="fetch") - - if user_ids: - for user_uuid in user_ids: - db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid)) - - if group_ids: - for group_id in group_ids: - db_session.add( - Persona__UserGroup(persona_id=persona_id, user_group_id=group_id) - ) - - db_session.commit() diff --git a/backend/ee/danswer/db/query_history.py b/backend/ee/danswer/db/query_history.py deleted file mode 100644 index 868afef23ce..00000000000 --- a/backend/ee/danswer/db/query_history.py +++ /dev/null @@ -1,59 +0,0 @@ -import datetime -from typing import Literal - -from sqlalchemy import asc -from sqlalchemy import BinaryExpression -from sqlalchemy import ColumnElement -from sqlalchemy import desc -from sqlalchemy.orm import contains_eager -from sqlalchemy.orm import joinedload -from sqlalchemy.orm import Session - -from danswer.db.models import ChatMessage -from danswer.db.models import ChatSession - -SortByOptions = Literal["time_sent"] - - -def fetch_chat_sessions_eagerly_by_time( - start: datetime.datetime, - end: datetime.datetime, - db_session: Session, - limit: int | None = 500, - initial_id: int | None = None, -) -> list[ChatSession]: - id_order = desc(ChatSession.id) # type: ignore - time_order = desc(ChatSession.time_created) # type: ignore - message_order = asc(ChatMessage.id) # type: ignore - - filters: list[ColumnElement | BinaryExpression] = [ - ChatSession.time_created.between(start, end) - ] - if initial_id: - filters.append(ChatSession.id < initial_id) - subquery = ( - db_session.query(ChatSession.id, ChatSession.time_created) - .filter(*filters) - .order_by(id_order, time_order) - .distinct(ChatSession.id) - .limit(limit) - .subquery() - ) - - query = ( - db_session.query(ChatSession) - .join(subquery, ChatSession.id == subquery.c.id) # type: ignore - .outerjoin(ChatMessage, ChatSession.id == ChatMessage.chat_session_id) - .options( - joinedload(ChatSession.user), - joinedload(ChatSession.persona), - contains_eager(ChatSession.messages).joinedload( - ChatMessage.chat_message_feedbacks - ), - ) - .order_by(time_order, message_order) - ) - - chat_sessions = query.all() - - return chat_sessions diff --git a/backend/ee/danswer/db/saml.py b/backend/ee/danswer/db/saml.py deleted file mode 100644 index 6689a7a7e14..00000000000 --- a/backend/ee/danswer/db/saml.py +++ /dev/null @@ -1,65 +0,0 @@ -import datetime -from typing import cast -from uuid import UUID - -from sqlalchemy import and_ -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS -from danswer.db.models import SamlAccount -from danswer.db.models import User - - -def upsert_saml_account( - user_id: UUID, - cookie: str, - db_session: Session, - expiration_offset: int = SESSION_EXPIRE_TIME_SECONDS, -) -> datetime.datetime: - expires_at = func.now() + datetime.timedelta(seconds=expiration_offset) - - existing_saml_acc = ( - db_session.query(SamlAccount) - .filter(SamlAccount.user_id == user_id) - .one_or_none() - ) - - if existing_saml_acc: - existing_saml_acc.encrypted_cookie = cookie - existing_saml_acc.expires_at = cast(datetime.datetime, expires_at) - existing_saml_acc.updated_at = func.now() - saml_acc = existing_saml_acc - else: - saml_acc = SamlAccount( - user_id=user_id, - encrypted_cookie=cookie, - expires_at=expires_at, - ) - db_session.add(saml_acc) - - db_session.commit() - - return saml_acc.expires_at - - -def get_saml_account(cookie: str, db_session: Session) -> SamlAccount | None: - stmt = ( - select(SamlAccount) - .join(User, User.id == SamlAccount.user_id) # type: ignore - .where( - and_( - SamlAccount.encrypted_cookie == cookie, - SamlAccount.expires_at > func.now(), - ) - ) - ) - - result = db_session.execute(stmt) - return result.scalar_one_or_none() - - -def expire_saml_account(saml_account: SamlAccount, db_session: Session) -> None: - saml_account.expires_at = func.now() - db_session.commit() diff --git a/backend/ee/danswer/db/token_limit.py b/backend/ee/danswer/db/token_limit.py deleted file mode 100644 index 95dd0011853..00000000000 --- a/backend/ee/danswer/db/token_limit.py +++ /dev/null @@ -1,226 +0,0 @@ -from collections.abc import Sequence - -from sqlalchemy import exists -from sqlalchemy import Row -from sqlalchemy import Select -from sqlalchemy import select -from sqlalchemy.orm import aliased -from sqlalchemy.orm import Session - -from danswer.configs.constants import TokenRateLimitScope -from danswer.db.models import TokenRateLimit -from danswer.db.models import TokenRateLimit__UserGroup -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup -from danswer.db.models import UserRole -from danswer.server.token_rate_limits.models import TokenRateLimitArgs - - -def _add_user_filters( - stmt: Select, user: User | None, get_editable: bool = True -) -> Select: - # If user is None, assume the user is an admin or auth is disabled - if user is None or user.role == UserRole.ADMIN: - return stmt - - TRLimit_UG = aliased(TokenRateLimit__UserGroup) - User__UG = aliased(User__UserGroup) - - """ - Here we select token_rate_limits by relation: - User -> User__UserGroup -> TokenRateLimit__UserGroup -> - TokenRateLimit - """ - stmt = stmt.outerjoin(TRLimit_UG).outerjoin( - User__UG, - User__UG.user_group_id == TRLimit_UG.user_group_id, - ) - - """ - Filter token_rate_limits by: - - if the user is in the user_group that owns the token_rate_limit - - if the user is not a global_curator, they must also have a curator relationship - to the user_group - - if editing is being done, we also filter out token_rate_limits that are owned by groups - that the user isn't a curator for - - if we are not editing, we show all token_rate_limits in the groups the user curates - """ - where_clause = User__UG.user_id == user.id - if user.role == UserRole.CURATOR and get_editable: - where_clause &= User__UG.is_curator == True # noqa: E712 - if get_editable: - user_groups = select(User__UG.user_group_id).where(User__UG.user_id == user.id) - if user.role == UserRole.CURATOR: - user_groups = user_groups.where( - User__UserGroup.is_curator == True # noqa: E712 - ) - where_clause &= ( - ~exists() - .where(TRLimit_UG.rate_limit_id == TokenRateLimit.id) - .where(~TRLimit_UG.user_group_id.in_(user_groups)) - .correlate(TokenRateLimit) - ) - - return stmt.where(where_clause) - - -def fetch_all_user_token_rate_limits( - db_session: Session, - enabled_only: bool = False, - ordered: bool = True, -) -> Sequence[TokenRateLimit]: - query = select(TokenRateLimit).where( - TokenRateLimit.scope == TokenRateLimitScope.USER - ) - - if enabled_only: - query = query.where(TokenRateLimit.enabled.is_(True)) - - if ordered: - query = query.order_by(TokenRateLimit.created_at.desc()) - - return db_session.scalars(query).all() - - -def fetch_all_global_token_rate_limits( - db_session: Session, - enabled_only: bool = False, - ordered: bool = True, -) -> Sequence[TokenRateLimit]: - query = select(TokenRateLimit).where( - TokenRateLimit.scope == TokenRateLimitScope.GLOBAL - ) - - if enabled_only: - query = query.where(TokenRateLimit.enabled.is_(True)) - - if ordered: - query = query.order_by(TokenRateLimit.created_at.desc()) - - token_rate_limits = db_session.scalars(query).all() - return token_rate_limits - - -def fetch_user_group_token_rate_limits( - db_session: Session, - group_id: int, - user: User | None = None, - enabled_only: bool = False, - ordered: bool = True, - get_editable: bool = True, -) -> Sequence[TokenRateLimit]: - stmt = select(TokenRateLimit) - stmt = stmt.where(User__UserGroup.user_group_id == group_id) - stmt = _add_user_filters(stmt, user, get_editable) - - if enabled_only: - stmt = stmt.where(TokenRateLimit.enabled.is_(True)) - - if ordered: - stmt = stmt.order_by(TokenRateLimit.created_at.desc()) - - return db_session.scalars(stmt).all() - - -def fetch_all_user_group_token_rate_limits_by_group( - db_session: Session, -) -> Sequence[Row[tuple[TokenRateLimit, str]]]: - query = ( - select(TokenRateLimit, UserGroup.name) - .join( - TokenRateLimit__UserGroup, - TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id, - ) - .join(UserGroup, UserGroup.id == TokenRateLimit__UserGroup.user_group_id) - ) - - return db_session.execute(query).all() - - -def insert_user_token_rate_limit( - db_session: Session, - token_rate_limit_settings: TokenRateLimitArgs, -) -> TokenRateLimit: - token_limit = TokenRateLimit( - enabled=token_rate_limit_settings.enabled, - token_budget=token_rate_limit_settings.token_budget, - period_hours=token_rate_limit_settings.period_hours, - scope=TokenRateLimitScope.USER, - ) - db_session.add(token_limit) - db_session.commit() - - return token_limit - - -def insert_global_token_rate_limit( - db_session: Session, - token_rate_limit_settings: TokenRateLimitArgs, -) -> TokenRateLimit: - token_limit = TokenRateLimit( - enabled=token_rate_limit_settings.enabled, - token_budget=token_rate_limit_settings.token_budget, - period_hours=token_rate_limit_settings.period_hours, - scope=TokenRateLimitScope.GLOBAL, - ) - db_session.add(token_limit) - db_session.commit() - - return token_limit - - -def insert_user_group_token_rate_limit( - db_session: Session, - token_rate_limit_settings: TokenRateLimitArgs, - group_id: int, -) -> TokenRateLimit: - token_limit = TokenRateLimit( - enabled=token_rate_limit_settings.enabled, - token_budget=token_rate_limit_settings.token_budget, - period_hours=token_rate_limit_settings.period_hours, - scope=TokenRateLimitScope.USER_GROUP, - ) - db_session.add(token_limit) - db_session.flush() - - rate_limit = TokenRateLimit__UserGroup( - rate_limit_id=token_limit.id, user_group_id=group_id - ) - db_session.add(rate_limit) - db_session.commit() - - return token_limit - - -def update_token_rate_limit( - db_session: Session, - token_rate_limit_id: int, - token_rate_limit_settings: TokenRateLimitArgs, -) -> TokenRateLimit: - token_limit = db_session.get(TokenRateLimit, token_rate_limit_id) - if token_limit is None: - raise ValueError(f"TokenRateLimit with id '{token_rate_limit_id}' not found") - - token_limit.enabled = token_rate_limit_settings.enabled - token_limit.token_budget = token_rate_limit_settings.token_budget - token_limit.period_hours = token_rate_limit_settings.period_hours - db_session.commit() - - return token_limit - - -def delete_token_rate_limit( - db_session: Session, - token_rate_limit_id: int, -) -> None: - token_limit = db_session.get(TokenRateLimit, token_rate_limit_id) - if token_limit is None: - raise ValueError(f"TokenRateLimit with id '{token_rate_limit_id}' not found") - - db_session.query(TokenRateLimit__UserGroup).filter( - TokenRateLimit__UserGroup.rate_limit_id == token_rate_limit_id - ).delete() - - db_session.delete(token_limit) - db_session.commit() diff --git a/backend/ee/danswer/db/usage_export.py b/backend/ee/danswer/db/usage_export.py deleted file mode 100644 index bf53362e97e..00000000000 --- a/backend/ee/danswer/db/usage_export.py +++ /dev/null @@ -1,108 +0,0 @@ -import uuid -from collections.abc import Generator -from datetime import datetime -from typing import IO - -from fastapi_users_db_sqlalchemy import UUID_ID -from sqlalchemy.orm import Session - -from danswer.configs.constants import MessageType -from danswer.db.models import UsageReport -from danswer.file_store.file_store import get_default_file_store -from ee.danswer.db.query_history import fetch_chat_sessions_eagerly_by_time -from ee.danswer.server.reporting.usage_export_models import ChatMessageSkeleton -from ee.danswer.server.reporting.usage_export_models import FlowType -from ee.danswer.server.reporting.usage_export_models import UsageReportMetadata - - -# Gets skeletons of all message -def get_empty_chat_messages_entries__paginated( - db_session: Session, - period: tuple[datetime, datetime], - limit: int | None = 1, - initial_id: int | None = None, -) -> list[ChatMessageSkeleton]: - chat_sessions = fetch_chat_sessions_eagerly_by_time( - period[0], period[1], db_session, limit=limit, initial_id=initial_id - ) - - message_skeletons: list[ChatMessageSkeleton] = [] - for chat_session in chat_sessions: - if chat_session.one_shot: - flow_type = FlowType.SEARCH - elif chat_session.danswerbot_flow: - flow_type = FlowType.SLACK - else: - flow_type = FlowType.CHAT - - for message in chat_session.messages: - # only count user messages - if message.message_type != MessageType.USER: - continue - - message_skeletons.append( - ChatMessageSkeleton( - message_id=chat_session.id, - chat_session_id=chat_session.id, - user_id=str(chat_session.user_id) if chat_session.user_id else None, - flow_type=flow_type, - time_sent=message.time_sent, - ) - ) - - return message_skeletons - - -def get_all_empty_chat_message_entries( - db_session: Session, - period: tuple[datetime, datetime], -) -> Generator[list[ChatMessageSkeleton], None, None]: - initial_id = None - while True: - message_skeletons = get_empty_chat_messages_entries__paginated( - db_session, period, initial_id=initial_id - ) - if not message_skeletons: - return - - yield message_skeletons - initial_id = message_skeletons[-1].message_id - - -def get_all_usage_reports(db_session: Session) -> list[UsageReportMetadata]: - return [ - UsageReportMetadata( - report_name=r.report_name, - requestor=str(r.requestor_user_id) if r.requestor_user_id else None, - time_created=r.time_created, - period_from=r.period_from, - period_to=r.period_to, - ) - for r in db_session.query(UsageReport).all() - ] - - -def get_usage_report_data( - db_session: Session, - report_name: str, -) -> IO: - file_store = get_default_file_store(db_session) - # usage report may be very large, so don't load it all into memory - return file_store.read_file(file_name=report_name, mode="b", use_tempfile=True) - - -def write_usage_report( - db_session: Session, - report_name: str, - user_id: uuid.UUID | UUID_ID | None, - period: tuple[datetime, datetime] | None, -) -> UsageReport: - new_report = UsageReport( - report_name=report_name, - requestor_user_id=user_id, - period_from=period[0] if period else None, - period_to=period[1] if period else None, - ) - db_session.add(new_report) - db_session.commit() - return new_report diff --git a/backend/ee/danswer/db/user_group.py b/backend/ee/danswer/db/user_group.py deleted file mode 100644 index 9d172c5d716..00000000000 --- a/backend/ee/danswer/db/user_group.py +++ /dev/null @@ -1,482 +0,0 @@ -from collections.abc import Sequence -from operator import and_ -from uuid import UUID - -from sqlalchemy import delete -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy import update -from sqlalchemy.orm import Session - -from danswer.db.connector_credential_pair import get_connector_credential_pair_from_id -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.db.models import ConnectorCredentialPair -from danswer.db.models import Credential__UserGroup -from danswer.db.models import Document -from danswer.db.models import DocumentByConnectorCredentialPair -from danswer.db.models import LLMProvider__UserGroup -from danswer.db.models import TokenRateLimit__UserGroup -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup -from danswer.db.models import UserGroup__ConnectorCredentialPair -from danswer.db.models import UserRole -from danswer.db.users import fetch_user_by_id -from danswer.utils.logger import setup_logger -from ee.danswer.server.user_group.models import SetCuratorRequest -from ee.danswer.server.user_group.models import UserGroupCreate -from ee.danswer.server.user_group.models import UserGroupUpdate - -logger = setup_logger() - - -def fetch_user_group(db_session: Session, user_group_id: int) -> UserGroup | None: - stmt = select(UserGroup).where(UserGroup.id == user_group_id) - return db_session.scalar(stmt) - - -def fetch_user_groups( - db_session: Session, only_current: bool = True -) -> Sequence[UserGroup]: - stmt = select(UserGroup) - if only_current: - stmt = stmt.where(UserGroup.is_up_to_date == True) # noqa: E712 - return db_session.scalars(stmt).all() - - -def fetch_user_groups_for_user( - db_session: Session, user_id: UUID, only_curator_groups: bool = False -) -> Sequence[UserGroup]: - stmt = ( - select(UserGroup) - .join(User__UserGroup, User__UserGroup.user_group_id == UserGroup.id) - .join(User, User.id == User__UserGroup.user_id) # type: ignore - .where(User.id == user_id) # type: ignore - ) - if only_curator_groups: - stmt = stmt.where(User__UserGroup.is_curator == True) # noqa: E712 - return db_session.scalars(stmt).all() - - -def fetch_documents_for_user_group_paginated( - db_session: Session, - user_group_id: int, - last_document_id: str | None = None, - limit: int = 100, -) -> tuple[Sequence[Document], str | None]: - stmt = ( - select(Document) - .join( - DocumentByConnectorCredentialPair, - Document.id == DocumentByConnectorCredentialPair.id, - ) - .join( - ConnectorCredentialPair, - and_( - DocumentByConnectorCredentialPair.connector_id - == ConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id - == ConnectorCredentialPair.credential_id, - ), - ) - .join( - UserGroup__ConnectorCredentialPair, - UserGroup__ConnectorCredentialPair.cc_pair_id == ConnectorCredentialPair.id, - ) - .join( - UserGroup, - UserGroup__ConnectorCredentialPair.user_group_id == UserGroup.id, - ) - .where(UserGroup.id == user_group_id) - .order_by(Document.id) - .limit(limit) - ) - if last_document_id is not None: - stmt = stmt.where(Document.id > last_document_id) - stmt = stmt.distinct() - - documents = db_session.scalars(stmt).all() - return documents, documents[-1].id if documents else None - - -def fetch_user_groups_for_documents( - db_session: Session, - document_ids: list[str], -) -> Sequence[tuple[int, list[str]]]: - stmt = ( - select(Document.id, func.array_agg(UserGroup.name)) - .join( - UserGroup__ConnectorCredentialPair, - UserGroup.id == UserGroup__ConnectorCredentialPair.user_group_id, - ) - .join( - ConnectorCredentialPair, - ConnectorCredentialPair.id == UserGroup__ConnectorCredentialPair.cc_pair_id, - ) - .join( - DocumentByConnectorCredentialPair, - and_( - DocumentByConnectorCredentialPair.connector_id - == ConnectorCredentialPair.connector_id, - DocumentByConnectorCredentialPair.credential_id - == ConnectorCredentialPair.credential_id, - ), - ) - .join(Document, Document.id == DocumentByConnectorCredentialPair.id) - .where(Document.id.in_(document_ids)) - .where(UserGroup__ConnectorCredentialPair.is_current == True) # noqa: E712 - # don't include CC pairs that are being deleted - # NOTE: CC pairs can never go from DELETING to any other state -> it's safe to ignore them - .where(ConnectorCredentialPair.status != ConnectorCredentialPairStatus.DELETING) - .group_by(Document.id) - ) - - return db_session.execute(stmt).all() # type: ignore - - -def _check_user_group_is_modifiable(user_group: UserGroup) -> None: - if not user_group.is_up_to_date: - raise ValueError( - "Specified user group is currently syncing. Wait until the current " - "sync has finished before editing." - ) - - -def _add_user__user_group_relationships__no_commit( - db_session: Session, user_group_id: int, user_ids: list[UUID] -) -> list[User__UserGroup]: - """NOTE: does not commit the transaction.""" - relationships = [ - User__UserGroup(user_id=user_id, user_group_id=user_group_id) - for user_id in user_ids - ] - db_session.add_all(relationships) - return relationships - - -def _add_user_group__cc_pair_relationships__no_commit( - db_session: Session, user_group_id: int, cc_pair_ids: list[int] -) -> list[UserGroup__ConnectorCredentialPair]: - """NOTE: does not commit the transaction.""" - relationships = [ - UserGroup__ConnectorCredentialPair( - user_group_id=user_group_id, cc_pair_id=cc_pair_id - ) - for cc_pair_id in cc_pair_ids - ] - db_session.add_all(relationships) - return relationships - - -def insert_user_group(db_session: Session, user_group: UserGroupCreate) -> UserGroup: - db_user_group = UserGroup(name=user_group.name) - db_session.add(db_user_group) - db_session.flush() # give the group an ID - - _add_user__user_group_relationships__no_commit( - db_session=db_session, - user_group_id=db_user_group.id, - user_ids=user_group.user_ids, - ) - _add_user_group__cc_pair_relationships__no_commit( - db_session=db_session, - user_group_id=db_user_group.id, - cc_pair_ids=user_group.cc_pair_ids, - ) - - db_session.commit() - return db_user_group - - -def _cleanup_user__user_group_relationships__no_commit( - db_session: Session, - user_group_id: int, - user_ids: list[UUID] | None = None, -) -> None: - """NOTE: does not commit the transaction.""" - where_clause = User__UserGroup.user_group_id == user_group_id - if user_ids: - where_clause &= User__UserGroup.user_id.in_(user_ids) - - user__user_group_relationships = db_session.scalars( - select(User__UserGroup).where(where_clause) - ).all() - for user__user_group_relationship in user__user_group_relationships: - db_session.delete(user__user_group_relationship) - - -def _cleanup_credential__user_group_relationships__no_commit( - db_session: Session, - user_group_id: int, -) -> None: - """NOTE: does not commit the transaction.""" - db_session.query(Credential__UserGroup).filter( - Credential__UserGroup.user_group_id == user_group_id - ).delete(synchronize_session=False) - - -def _cleanup_llm_provider__user_group_relationships__no_commit( - db_session: Session, user_group_id: int -) -> None: - """NOTE: does not commit the transaction.""" - db_session.query(LLMProvider__UserGroup).filter( - LLMProvider__UserGroup.user_group_id == user_group_id - ).delete(synchronize_session=False) - - -def _mark_user_group__cc_pair_relationships_outdated__no_commit( - db_session: Session, user_group_id: int -) -> None: - """NOTE: does not commit the transaction.""" - user_group__cc_pair_relationships = db_session.scalars( - select(UserGroup__ConnectorCredentialPair).where( - UserGroup__ConnectorCredentialPair.user_group_id == user_group_id - ) - ) - for user_group__cc_pair_relationship in user_group__cc_pair_relationships: - user_group__cc_pair_relationship.is_current = False - - -def _validate_curator_status__no_commit( - db_session: Session, - users: list[User], -) -> None: - for user in users: - # Check if the user is a curator in any of their groups - curator_relationships = ( - db_session.query(User__UserGroup) - .filter( - User__UserGroup.user_id == user.id, - User__UserGroup.is_curator == True, # noqa: E712 - ) - .all() - ) - - if curator_relationships: - user.role = UserRole.CURATOR - elif user.role == UserRole.CURATOR: - user.role = UserRole.BASIC - db_session.add(user) - - -def remove_curator_status__no_commit(db_session: Session, user: User) -> None: - stmt = ( - update(User__UserGroup) - .where(User__UserGroup.user_id == user.id) - .values(is_curator=False) - ) - db_session.execute(stmt) - _validate_curator_status__no_commit(db_session, [user]) - - -def update_user_curator_relationship( - db_session: Session, - user_group_id: int, - set_curator_request: SetCuratorRequest, -) -> None: - user = fetch_user_by_id(db_session, set_curator_request.user_id) - if not user: - raise ValueError(f"User with id '{set_curator_request.user_id}' not found") - requested_user_groups = fetch_user_groups_for_user( - db_session=db_session, - user_id=set_curator_request.user_id, - only_curator_groups=False, - ) - - group_ids = [group.id for group in requested_user_groups] - if user_group_id not in group_ids: - raise ValueError(f"user is not in group '{user_group_id}'") - - relationship_to_update = ( - db_session.query(User__UserGroup) - .filter( - User__UserGroup.user_group_id == user_group_id, - User__UserGroup.user_id == set_curator_request.user_id, - ) - .first() - ) - - if relationship_to_update: - relationship_to_update.is_curator = set_curator_request.is_curator - else: - relationship_to_update = User__UserGroup( - user_group_id=user_group_id, - user_id=set_curator_request.user_id, - is_curator=True, - ) - db_session.add(relationship_to_update) - - _validate_curator_status__no_commit(db_session, [user]) - db_session.commit() - - -def update_user_group( - db_session: Session, - user: User | None, - user_group_id: int, - user_group_update: UserGroupUpdate, -) -> UserGroup: - stmt = select(UserGroup).where(UserGroup.id == user_group_id) - db_user_group = db_session.scalar(stmt) - if db_user_group is None: - raise ValueError(f"UserGroup with id '{user_group_id}' not found") - - _check_user_group_is_modifiable(db_user_group) - - current_user_ids = set([user.id for user in db_user_group.users]) - updated_user_ids = set(user_group_update.user_ids) - added_user_ids = list(updated_user_ids - current_user_ids) - removed_user_ids = list(current_user_ids - updated_user_ids) - - # LEAVING THIS HERE FOR NOW FOR GIVING DIFFERENT ROLES - # ACCESS TO DIFFERENT PERMISSIONS - # if (removed_user_ids or added_user_ids) and ( - # not user or user.role != UserRole.ADMIN - # ): - # raise ValueError("Only admins can add or remove users from user groups") - - if removed_user_ids: - _cleanup_user__user_group_relationships__no_commit( - db_session=db_session, - user_group_id=user_group_id, - user_ids=removed_user_ids, - ) - - if added_user_ids: - _add_user__user_group_relationships__no_commit( - db_session=db_session, - user_group_id=user_group_id, - user_ids=added_user_ids, - ) - - cc_pairs_updated = set([cc_pair.id for cc_pair in db_user_group.cc_pairs]) != set( - user_group_update.cc_pair_ids - ) - if cc_pairs_updated: - _mark_user_group__cc_pair_relationships_outdated__no_commit( - db_session=db_session, user_group_id=user_group_id - ) - _add_user_group__cc_pair_relationships__no_commit( - db_session=db_session, - user_group_id=db_user_group.id, - cc_pair_ids=user_group_update.cc_pair_ids, - ) - - # only needs to sync with Vespa if the cc_pairs have been updated - if cc_pairs_updated: - db_user_group.is_up_to_date = False - - removed_users = db_session.scalars( - select(User).where(User.id.in_(removed_user_ids)) # type: ignore - ).unique() - _validate_curator_status__no_commit(db_session, list(removed_users)) - db_session.commit() - return db_user_group - - -def _cleanup_token_rate_limit__user_group_relationships__no_commit( - db_session: Session, user_group_id: int -) -> None: - """NOTE: does not commit the transaction.""" - token_rate_limit__user_group_relationships = db_session.scalars( - select(TokenRateLimit__UserGroup).where( - TokenRateLimit__UserGroup.user_group_id == user_group_id - ) - ).all() - for ( - token_rate_limit__user_group_relationship - ) in token_rate_limit__user_group_relationships: - db_session.delete(token_rate_limit__user_group_relationship) - - -def prepare_user_group_for_deletion(db_session: Session, user_group_id: int) -> None: - stmt = select(UserGroup).where(UserGroup.id == user_group_id) - db_user_group = db_session.scalar(stmt) - if db_user_group is None: - raise ValueError(f"UserGroup with id '{user_group_id}' not found") - - _check_user_group_is_modifiable(db_user_group) - - _cleanup_credential__user_group_relationships__no_commit( - db_session=db_session, user_group_id=user_group_id - ) - _cleanup_user__user_group_relationships__no_commit( - db_session=db_session, user_group_id=user_group_id - ) - _mark_user_group__cc_pair_relationships_outdated__no_commit( - db_session=db_session, user_group_id=user_group_id - ) - _cleanup_token_rate_limit__user_group_relationships__no_commit( - db_session=db_session, user_group_id=user_group_id - ) - - db_user_group.is_up_to_date = False - db_user_group.is_up_for_deletion = True - db_session.commit() - - -def _cleanup_user_group__cc_pair_relationships__no_commit( - db_session: Session, user_group_id: int, outdated_only: bool -) -> None: - """NOTE: does not commit the transaction.""" - stmt = select(UserGroup__ConnectorCredentialPair).where( - UserGroup__ConnectorCredentialPair.user_group_id == user_group_id - ) - if outdated_only: - stmt = stmt.where( - UserGroup__ConnectorCredentialPair.is_current == False # noqa: E712 - ) - user_group__cc_pair_relationships = db_session.scalars(stmt) - for user_group__cc_pair_relationship in user_group__cc_pair_relationships: - db_session.delete(user_group__cc_pair_relationship) - - -def mark_user_group_as_synced(db_session: Session, user_group: UserGroup) -> None: - # cleanup outdated relationships - _cleanup_user_group__cc_pair_relationships__no_commit( - db_session=db_session, user_group_id=user_group.id, outdated_only=True - ) - user_group.is_up_to_date = True - db_session.commit() - - -def delete_user_group(db_session: Session, user_group: UserGroup) -> None: - _cleanup_llm_provider__user_group_relationships__no_commit( - db_session=db_session, user_group_id=user_group.id - ) - _cleanup_user__user_group_relationships__no_commit( - db_session=db_session, user_group_id=user_group.id - ) - _cleanup_user_group__cc_pair_relationships__no_commit( - db_session=db_session, - user_group_id=user_group.id, - outdated_only=False, - ) - - # need to flush so that we don't get a foreign key error when deleting the user group row - db_session.flush() - - db_session.delete(user_group) - db_session.commit() - - -def delete_user_group_cc_pair_relationship__no_commit( - cc_pair_id: int, db_session: Session -) -> None: - """Deletes all rows from UserGroup__ConnectorCredentialPair where the - connector_credential_pair_id matches the given cc_pair_id. - - Should be used very carefully (only for connectors that are being deleted).""" - cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session) - if not cc_pair: - raise ValueError(f"Connector Credential Pair '{cc_pair_id}' does not exist") - - if cc_pair.status != ConnectorCredentialPairStatus.DELETING: - raise ValueError( - f"Connector Credential Pair '{cc_pair_id}' is not in the DELETING state" - ) - - delete_stmt = delete(UserGroup__ConnectorCredentialPair).where( - UserGroup__ConnectorCredentialPair.cc_pair_id == cc_pair_id, - ) - db_session.execute(delete_stmt) diff --git a/backend/ee/danswer/main.py b/backend/ee/danswer/main.py deleted file mode 100644 index d7d1d6406a3..00000000000 --- a/backend/ee/danswer/main.py +++ /dev/null @@ -1,107 +0,0 @@ -from fastapi import FastAPI -from httpx_oauth.clients.openid import OpenID - -from danswer.auth.users import auth_backend -from danswer.auth.users import fastapi_users -from danswer.configs.app_configs import AUTH_TYPE -from danswer.configs.app_configs import OAUTH_CLIENT_ID -from danswer.configs.app_configs import OAUTH_CLIENT_SECRET -from danswer.configs.app_configs import USER_AUTH_SECRET -from danswer.configs.app_configs import WEB_DOMAIN -from danswer.configs.constants import AuthType -from danswer.main import get_application as get_application_base -from danswer.main import include_router_with_global_prefix_prepended -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import global_version -from ee.danswer.configs.app_configs import OPENID_CONFIG_URL -from ee.danswer.server.analytics.api import router as analytics_router -from ee.danswer.server.api_key.api import router as api_key_router -from ee.danswer.server.auth_check import check_ee_router_auth -from ee.danswer.server.enterprise_settings.api import ( - admin_router as enterprise_settings_admin_router, -) -from ee.danswer.server.enterprise_settings.api import ( - basic_router as enterprise_settings_router, -) -from ee.danswer.server.query_and_chat.chat_backend import ( - router as chat_router, -) -from ee.danswer.server.query_and_chat.query_backend import ( - basic_router as query_router, -) -from ee.danswer.server.query_history.api import router as query_history_router -from ee.danswer.server.reporting.usage_export_api import router as usage_export_router -from ee.danswer.server.saml import router as saml_router -from ee.danswer.server.seeding import seed_db -from ee.danswer.server.token_rate_limits.api import ( - router as token_rate_limit_settings_router, -) -from ee.danswer.server.user_group.api import router as user_group_router -from ee.danswer.utils.encryption import test_encryption - -logger = setup_logger() - - -def get_application() -> FastAPI: - # Anything that happens at import time is not guaranteed to be running ee-version - # Anything after the server startup will be running ee version - global_version.set_ee() - - test_encryption() - - application = get_application_base() - - if AUTH_TYPE == AuthType.OIDC: - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_oauth_router( - OpenID(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OPENID_CONFIG_URL), - auth_backend, - USER_AUTH_SECRET, - associate_by_email=True, - is_verified_by_default=True, - redirect_url=f"{WEB_DOMAIN}/auth/oidc/callback", - ), - prefix="/auth/oidc", - tags=["auth"], - ) - # need basic auth router for `logout` endpoint - include_router_with_global_prefix_prepended( - application, - fastapi_users.get_auth_router(auth_backend), - prefix="/auth", - tags=["auth"], - ) - - elif AUTH_TYPE == AuthType.SAML: - include_router_with_global_prefix_prepended(application, saml_router) - - # RBAC / group access control - include_router_with_global_prefix_prepended(application, user_group_router) - # Analytics endpoints - include_router_with_global_prefix_prepended(application, analytics_router) - include_router_with_global_prefix_prepended(application, query_history_router) - # Api key management - include_router_with_global_prefix_prepended(application, api_key_router) - # EE only backend APIs - include_router_with_global_prefix_prepended(application, query_router) - include_router_with_global_prefix_prepended(application, chat_router) - # Enterprise-only global settings - include_router_with_global_prefix_prepended( - application, enterprise_settings_admin_router - ) - # Token rate limit settings - include_router_with_global_prefix_prepended( - application, token_rate_limit_settings_router - ) - include_router_with_global_prefix_prepended(application, enterprise_settings_router) - include_router_with_global_prefix_prepended(application, usage_export_router) - - # Ensure all routes have auth enabled or are explicitly marked as public - check_ee_router_auth(application) - - # seed the Danswer environment with LLMs, Assistants, etc. based on an optional - # environment variable. Used to automate deployment for multiple environments. - seed_db() - - return application diff --git a/backend/ee/danswer/server/__init__.py b/backend/ee/danswer/server/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/server/analytics/api.py b/backend/ee/danswer/server/analytics/api.py deleted file mode 100644 index f79199323f5..00000000000 --- a/backend/ee/danswer/server/analytics/api.py +++ /dev/null @@ -1,117 +0,0 @@ -import datetime -from collections import defaultdict - -from fastapi import APIRouter -from fastapi import Depends -from pydantic import BaseModel -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 ee.danswer.db.analytics import fetch_danswerbot_analytics -from ee.danswer.db.analytics import fetch_per_user_query_analytics -from ee.danswer.db.analytics import fetch_query_analytics - -router = APIRouter(prefix="/analytics") - - -class QueryAnalyticsResponse(BaseModel): - total_queries: int - total_likes: int - total_dislikes: int - date: datetime.date - - -@router.get("/admin/query") -def get_query_analytics( - start: datetime.datetime | None = None, - end: datetime.datetime | None = None, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[QueryAnalyticsResponse]: - daily_query_usage_info = fetch_query_analytics( - start=start - or ( - datetime.datetime.utcnow() - datetime.timedelta(days=30) - ), # default is 30d lookback - end=end or datetime.datetime.utcnow(), - db_session=db_session, - ) - return [ - QueryAnalyticsResponse( - total_queries=total_queries, - total_likes=total_likes, - total_dislikes=total_dislikes, - date=date, - ) - for total_queries, total_likes, total_dislikes, date in daily_query_usage_info - ] - - -class UserAnalyticsResponse(BaseModel): - total_active_users: int - date: datetime.date - - -@router.get("/admin/user") -def get_user_analytics( - start: datetime.datetime | None = None, - end: datetime.datetime | None = None, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[UserAnalyticsResponse]: - daily_query_usage_info_per_user = fetch_per_user_query_analytics( - start=start - or ( - datetime.datetime.utcnow() - datetime.timedelta(days=30) - ), # default is 30d lookback - end=end or datetime.datetime.utcnow(), - db_session=db_session, - ) - - user_analytics: dict[datetime.date, int] = defaultdict(int) - for __, ___, ____, date, _____ in daily_query_usage_info_per_user: - user_analytics[date] += 1 - return [ - UserAnalyticsResponse( - total_active_users=cnt, - date=date, - ) - for date, cnt in user_analytics.items() - ] - - -class DanswerbotAnalyticsResponse(BaseModel): - total_queries: int - auto_resolved: int - date: datetime.date - - -@router.get("/admin/danswerbot") -def get_danswerbot_analytics( - start: datetime.datetime | None = None, - end: datetime.datetime | None = None, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[DanswerbotAnalyticsResponse]: - daily_danswerbot_info = fetch_danswerbot_analytics( - start=start - or ( - datetime.datetime.utcnow() - datetime.timedelta(days=30) - ), # default is 30d lookback - end=end or datetime.datetime.utcnow(), - db_session=db_session, - ) - - resolution_results = [ - DanswerbotAnalyticsResponse( - total_queries=total_queries, - # If it hits negatives, something has gone wrong... - auto_resolved=max(0, total_queries - total_negatives), - date=date, - ) - for total_queries, total_negatives, date in daily_danswerbot_info - ] - - return resolution_results diff --git a/backend/ee/danswer/server/api_key/api.py b/backend/ee/danswer/server/api_key/api.py deleted file mode 100644 index c7353f055fb..00000000000 --- a/backend/ee/danswer/server/api_key/api.py +++ /dev/null @@ -1,62 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -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 ee.danswer.db.api_key import ApiKeyDescriptor -from ee.danswer.db.api_key import fetch_api_keys -from ee.danswer.db.api_key import insert_api_key -from ee.danswer.db.api_key import regenerate_api_key -from ee.danswer.db.api_key import remove_api_key -from ee.danswer.db.api_key import update_api_key -from ee.danswer.server.api_key.models import APIKeyArgs - - -router = APIRouter(prefix="/admin/api-key") - - -@router.get("") -def list_api_keys( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[ApiKeyDescriptor]: - return fetch_api_keys(db_session) - - -@router.post("") -def create_api_key( - api_key_args: APIKeyArgs, - user: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> ApiKeyDescriptor: - return insert_api_key(db_session, api_key_args, user.id if user else None) - - -@router.post("/{api_key_id}/regenerate") -def regenerate_existing_api_key( - api_key_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> ApiKeyDescriptor: - return regenerate_api_key(db_session, api_key_id) - - -@router.patch("/{api_key_id}") -def update_existing_api_key( - api_key_id: int, - api_key_args: APIKeyArgs, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> ApiKeyDescriptor: - return update_api_key(db_session, api_key_id, api_key_args) - - -@router.delete("/{api_key_id}") -def delete_api_key( - api_key_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - remove_api_key(db_session, api_key_id) diff --git a/backend/ee/danswer/server/api_key/models.py b/backend/ee/danswer/server/api_key/models.py deleted file mode 100644 index 596d02c43a8..00000000000 --- a/backend/ee/danswer/server/api_key/models.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseModel - -from danswer.auth.schemas import UserRole - - -class APIKeyArgs(BaseModel): - name: str | None = None - role: UserRole = UserRole.BASIC diff --git a/backend/ee/danswer/server/auth_check.py b/backend/ee/danswer/server/auth_check.py deleted file mode 100644 index 49353abf84c..00000000000 --- a/backend/ee/danswer/server/auth_check.py +++ /dev/null @@ -1,29 +0,0 @@ -from fastapi import FastAPI - -from danswer.server.auth_check import check_router_auth -from danswer.server.auth_check import PUBLIC_ENDPOINT_SPECS - - -EE_PUBLIC_ENDPOINT_SPECS = PUBLIC_ENDPOINT_SPECS + [ - # needs to be accessible prior to user login - ("/enterprise-settings", {"GET"}), - ("/enterprise-settings/logo", {"GET"}), - ("/enterprise-settings/logotype", {"GET"}), - ("/enterprise-settings/custom-analytics-script", {"GET"}), - # oidc - ("/auth/oidc/authorize", {"GET"}), - ("/auth/oidc/callback", {"GET"}), - # saml - ("/auth/saml/authorize", {"GET"}), - ("/auth/saml/callback", {"POST"}), - ("/auth/saml/logout", {"POST"}), -] - - -def check_ee_router_auth( - application: FastAPI, - public_endpoint_specs: list[tuple[str, set[str]]] = EE_PUBLIC_ENDPOINT_SPECS, -) -> None: - # similar to the open source version of this function, but checking for the EE-only - # endpoints as well - check_router_auth(application, public_endpoint_specs) diff --git a/backend/ee/danswer/server/enterprise_settings/api.py b/backend/ee/danswer/server/enterprise_settings/api.py deleted file mode 100644 index 736296517db..00000000000 --- a/backend/ee/danswer/server/enterprise_settings/api.py +++ /dev/null @@ -1,91 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Response -from fastapi import UploadFile -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.file_store.file_store import get_default_file_store -from ee.danswer.server.enterprise_settings.models import AnalyticsScriptUpload -from ee.danswer.server.enterprise_settings.models import EnterpriseSettings -from ee.danswer.server.enterprise_settings.store import _LOGO_FILENAME -from ee.danswer.server.enterprise_settings.store import _LOGOTYPE_FILENAME -from ee.danswer.server.enterprise_settings.store import load_analytics_script -from ee.danswer.server.enterprise_settings.store import load_settings -from ee.danswer.server.enterprise_settings.store import store_analytics_script -from ee.danswer.server.enterprise_settings.store import store_settings -from ee.danswer.server.enterprise_settings.store import upload_logo - -admin_router = APIRouter(prefix="/admin/enterprise-settings") -basic_router = APIRouter(prefix="/enterprise-settings") - - -@admin_router.put("") -def put_settings( - settings: EnterpriseSettings, _: User | None = Depends(current_admin_user) -) -> None: - try: - settings.check_validity() - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - store_settings(settings) - - -@basic_router.get("") -def fetch_settings() -> EnterpriseSettings: - return load_settings() - - -@admin_router.put("/logo") -def put_logo( - file: UploadFile, - is_logotype: bool = False, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_admin_user), -) -> None: - upload_logo(file=file, db_session=db_session, is_logotype=is_logotype) - - -def fetch_logo_or_logotype(is_logotype: bool, db_session: Session) -> Response: - try: - file_store = get_default_file_store(db_session) - filename = _LOGOTYPE_FILENAME if is_logotype else _LOGO_FILENAME - file_io = file_store.read_file(filename, mode="b") - # NOTE: specifying "image/jpeg" here, but it still works for pngs - # TODO: do this properly - return Response(content=file_io.read(), media_type="image/jpeg") - except Exception: - raise HTTPException( - status_code=404, - detail=f"No {'logotype' if is_logotype else 'logo'} file found", - ) - - -@basic_router.get("/logotype") -def fetch_logotype(db_session: Session = Depends(get_session)) -> Response: - return fetch_logo_or_logotype(is_logotype=True, db_session=db_session) - - -@basic_router.get("/logo") -def fetch_logo( - is_logotype: bool = False, db_session: Session = Depends(get_session) -) -> Response: - return fetch_logo_or_logotype(is_logotype=is_logotype, db_session=db_session) - - -@admin_router.put("/custom-analytics-script") -def upload_custom_analytics_script( - script_upload: AnalyticsScriptUpload, _: User | None = Depends(current_admin_user) -) -> None: - try: - store_analytics_script(script_upload) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@basic_router.get("/custom-analytics-script") -def fetch_custom_analytics_script() -> str | None: - return load_analytics_script() diff --git a/backend/ee/danswer/server/enterprise_settings/models.py b/backend/ee/danswer/server/enterprise_settings/models.py deleted file mode 100644 index c9831d87aeb..00000000000 --- a/backend/ee/danswer/server/enterprise_settings/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from pydantic import BaseModel - - -class EnterpriseSettings(BaseModel): - """General settings that only apply to the Enterprise Edition of Danswer - - NOTE: don't put anything sensitive in here, as this is accessible without auth.""" - - application_name: str | None = None - use_custom_logo: bool = False - use_custom_logotype: bool = False - - # custom Chat components - custom_lower_disclaimer_content: str | None = None - custom_header_content: str | None = None - custom_popup_header: str | None = None - custom_popup_content: str | None = None - - def check_validity(self) -> None: - return - - -class AnalyticsScriptUpload(BaseModel): - script: str - secret_key: str diff --git a/backend/ee/danswer/server/enterprise_settings/store.py b/backend/ee/danswer/server/enterprise_settings/store.py deleted file mode 100644 index 30b72d5d2e8..00000000000 --- a/backend/ee/danswer/server/enterprise_settings/store.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -from io import BytesIO -from typing import Any -from typing import cast -from typing import IO - -from fastapi import HTTPException -from fastapi import UploadFile -from sqlalchemy.orm import Session - -from danswer.configs.constants import FileOrigin -from danswer.configs.constants import KV_CUSTOM_ANALYTICS_SCRIPT_KEY -from danswer.configs.constants import KV_ENTERPRISE_SETTINGS_KEY -from danswer.dynamic_configs.factory import get_dynamic_config_store -from danswer.dynamic_configs.interface import ConfigNotFoundError -from danswer.file_store.file_store import get_default_file_store -from danswer.utils.logger import setup_logger -from ee.danswer.server.enterprise_settings.models import AnalyticsScriptUpload -from ee.danswer.server.enterprise_settings.models import EnterpriseSettings - - -logger = setup_logger() - - -def load_settings() -> EnterpriseSettings: - dynamic_config_store = get_dynamic_config_store() - try: - settings = EnterpriseSettings( - **cast(dict, dynamic_config_store.load(KV_ENTERPRISE_SETTINGS_KEY)) - ) - except ConfigNotFoundError: - settings = EnterpriseSettings() - dynamic_config_store.store(KV_ENTERPRISE_SETTINGS_KEY, settings.model_dump()) - - return settings - - -def store_settings(settings: EnterpriseSettings) -> None: - get_dynamic_config_store().store(KV_ENTERPRISE_SETTINGS_KEY, settings.model_dump()) - - -_CUSTOM_ANALYTICS_SECRET_KEY = os.environ.get("CUSTOM_ANALYTICS_SECRET_KEY") - - -def load_analytics_script() -> str | None: - dynamic_config_store = get_dynamic_config_store() - try: - return cast(str, dynamic_config_store.load(KV_CUSTOM_ANALYTICS_SCRIPT_KEY)) - except ConfigNotFoundError: - return None - - -def store_analytics_script(analytics_script_upload: AnalyticsScriptUpload) -> None: - if ( - not _CUSTOM_ANALYTICS_SECRET_KEY - or analytics_script_upload.secret_key != _CUSTOM_ANALYTICS_SECRET_KEY - ): - raise ValueError("Invalid secret key") - - get_dynamic_config_store().store( - KV_CUSTOM_ANALYTICS_SCRIPT_KEY, analytics_script_upload.script - ) - - -_LOGO_FILENAME = "__logo__" -_LOGOTYPE_FILENAME = "__logotype__" - - -def is_valid_file_type(filename: str) -> bool: - valid_extensions = (".png", ".jpg", ".jpeg") - return filename.endswith(valid_extensions) - - -def guess_file_type(filename: str) -> str: - if filename.lower().endswith(".png"): - return "image/png" - elif filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"): - return "image/jpeg" - return "application/octet-stream" - - -def upload_logo( - db_session: Session, file: UploadFile | str, is_logotype: bool = False -) -> bool: - content: IO[Any] - - if isinstance(file, str): - logger.notice(f"Uploading logo from local path {file}") - if not os.path.isfile(file) or not is_valid_file_type(file): - logger.error( - "Invalid file type- only .png, .jpg, and .jpeg files are allowed" - ) - return False - - with open(file, "rb") as file_handle: - file_content = file_handle.read() - content = BytesIO(file_content) - display_name = file - file_type = guess_file_type(file) - - else: - logger.notice("Uploading logo from uploaded file") - if not file.filename or not is_valid_file_type(file.filename): - raise HTTPException( - status_code=400, - detail="Invalid file type- only .png, .jpg, and .jpeg files are allowed", - ) - content = file.file - display_name = file.filename - file_type = file.content_type or "image/jpeg" - - file_store = get_default_file_store(db_session) - file_store.save_file( - file_name=_LOGOTYPE_FILENAME if is_logotype else _LOGO_FILENAME, - content=content, - display_name=display_name, - file_origin=FileOrigin.OTHER, - file_type=file_type, - ) - return True diff --git a/backend/ee/danswer/server/query_and_chat/__init__.py b/backend/ee/danswer/server/query_and_chat/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/server/query_and_chat/chat_backend.py b/backend/ee/danswer/server/query_and_chat/chat_backend.py deleted file mode 100644 index 0d5d1987f34..00000000000 --- a/backend/ee/danswer/server/query_and_chat/chat_backend.py +++ /dev/null @@ -1,267 +0,0 @@ -import re - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.chat.chat_utils import create_chat_chain -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import LLMRelevanceFilterResponse -from danswer.chat.models import QADocsResponse -from danswer.chat.models import StreamingError -from danswer.chat.process_message import stream_chat_message_objects -from danswer.configs.constants import MessageType -from danswer.configs.danswerbot_configs import DANSWER_BOT_TARGET_CHUNK_PERCENTAGE -from danswer.db.chat import create_chat_session -from danswer.db.chat import create_new_chat_message -from danswer.db.chat import get_or_create_root_message -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.utils import get_max_input_tokens -from danswer.natural_language_processing.utils import get_tokenizer -from danswer.one_shot_answer.qa_utils import combine_message_thread -from danswer.search.models import OptionalSearchSetting -from danswer.search.models import RetrievalDetails -from danswer.secondary_llm_flows.query_expansion import thread_based_query_rephrase -from danswer.server.query_and_chat.models import ChatMessageDetail -from danswer.server.query_and_chat.models import CreateChatMessageRequest -from danswer.utils.logger import setup_logger -from ee.danswer.server.query_and_chat.models import BasicCreateChatMessageRequest -from ee.danswer.server.query_and_chat.models import ( - BasicCreateChatMessageWithHistoryRequest, -) -from ee.danswer.server.query_and_chat.models import ChatBasicResponse -from ee.danswer.server.query_and_chat.models import SimpleDoc - -logger = setup_logger() - -router = APIRouter(prefix="/chat") - - -def translate_doc_response_to_simple_doc( - doc_response: QADocsResponse, -) -> list[SimpleDoc]: - return [ - SimpleDoc( - id=doc.document_id, - semantic_identifier=doc.semantic_identifier, - link=doc.link, - blurb=doc.blurb, - match_highlights=[ - highlight for highlight in doc.match_highlights if highlight - ], - source_type=doc.source_type, - metadata=doc.metadata, - ) - for doc in doc_response.top_documents - ] - - -def remove_answer_citations(answer: str) -> str: - pattern = r"\s*\[\[\d+\]\]\(http[s]?://[^\s]+\)" - - return re.sub(pattern, "", answer) - - -@router.post("/send-message-simple-api") -def handle_simplified_chat_message( - chat_message_req: BasicCreateChatMessageRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatBasicResponse: - """This is a Non-Streaming version that only gives back a minimal set of information""" - logger.notice(f"Received new simple api chat message: {chat_message_req.message}") - - if not chat_message_req.message: - raise HTTPException(status_code=400, detail="Empty chat message is invalid") - - try: - parent_message, _ = create_chat_chain( - chat_session_id=chat_message_req.chat_session_id, db_session=db_session - ) - except Exception: - parent_message = get_or_create_root_message( - chat_session_id=chat_message_req.chat_session_id, db_session=db_session - ) - - if ( - chat_message_req.retrieval_options is None - and chat_message_req.search_doc_ids is None - ): - retrieval_options: RetrievalDetails | None = RetrievalDetails( - run_search=OptionalSearchSetting.ALWAYS, - real_time=False, - ) - else: - retrieval_options = chat_message_req.retrieval_options - - full_chat_msg_info = CreateChatMessageRequest( - chat_session_id=chat_message_req.chat_session_id, - parent_message_id=parent_message.id, - message=chat_message_req.message, - file_descriptors=[], - prompt_id=None, - search_doc_ids=chat_message_req.search_doc_ids, - retrieval_options=retrieval_options, - query_override=chat_message_req.query_override, - # Currently only applies to search flow not chat - chunks_above=0, - chunks_below=0, - full_doc=chat_message_req.full_doc, - ) - - packets = stream_chat_message_objects( - new_msg_req=full_chat_msg_info, - user=user, - db_session=db_session, - ) - - response = ChatBasicResponse() - - answer = "" - for packet in packets: - if isinstance(packet, DanswerAnswerPiece) and packet.answer_piece: - answer += packet.answer_piece - elif isinstance(packet, QADocsResponse): - response.simple_search_docs = translate_doc_response_to_simple_doc(packet) - elif isinstance(packet, StreamingError): - response.error_msg = packet.error - elif isinstance(packet, ChatMessageDetail): - response.message_id = packet.message_id - - response.answer = answer - if answer: - response.answer_citationless = remove_answer_citations(answer) - - return response - - -@router.post("/send-message-simple-with-history") -def handle_send_message_simple_with_history( - req: BasicCreateChatMessageWithHistoryRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> ChatBasicResponse: - """This is a Non-Streaming version that only gives back a minimal set of information. - takes in chat history maintained by the caller - and does query rephrasing similar to answer-with-quote""" - - if len(req.messages) == 0: - raise HTTPException(status_code=400, detail="Messages cannot be zero length") - - expected_role = MessageType.USER - for msg in req.messages: - if not msg.message: - raise HTTPException( - status_code=400, detail="One or more chat messages were empty" - ) - - if msg.role != expected_role: - raise HTTPException( - status_code=400, - detail="Message roles must start and end with MessageType.USER and alternate in-between.", - ) - if expected_role == MessageType.USER: - expected_role = MessageType.ASSISTANT - else: - expected_role = MessageType.USER - - query = req.messages[-1].message - msg_history = req.messages[:-1] - - logger.notice(f"Received new simple with history chat message: {query}") - - user_id = user.id if user is not None else None - chat_session = create_chat_session( - db_session=db_session, - description="handle_send_message_simple_with_history", - user_id=user_id, - persona_id=req.persona_id, - one_shot=False, - ) - - llm, _ = get_llms_for_persona(persona=chat_session.persona) - - llm_tokenizer = get_tokenizer( - model_name=llm.config.model_name, - provider_type=llm.config.model_provider, - ) - - input_tokens = get_max_input_tokens( - model_name=llm.config.model_name, model_provider=llm.config.model_provider - ) - max_history_tokens = int(input_tokens * DANSWER_BOT_TARGET_CHUNK_PERCENTAGE) - - # Every chat Session begins with an empty root message - root_message = get_or_create_root_message( - chat_session_id=chat_session.id, db_session=db_session - ) - - chat_message = root_message - for msg in msg_history: - chat_message = create_new_chat_message( - chat_session_id=chat_session.id, - parent_message=chat_message, - prompt_id=req.prompt_id, - message=msg.message, - token_count=len(llm_tokenizer.encode(msg.message)), - message_type=msg.role, - db_session=db_session, - commit=False, - ) - db_session.commit() - - history_str = combine_message_thread( - messages=msg_history, - max_tokens=max_history_tokens, - llm_tokenizer=llm_tokenizer, - ) - - rephrased_query = req.query_override or thread_based_query_rephrase( - user_query=query, - history_str=history_str, - ) - - full_chat_msg_info = CreateChatMessageRequest( - chat_session_id=chat_session.id, - parent_message_id=chat_message.id, - message=query, - file_descriptors=[], - prompt_id=req.prompt_id, - search_doc_ids=None, - retrieval_options=req.retrieval_options, - query_override=rephrased_query, - chunks_above=0, - chunks_below=0, - full_doc=req.full_doc, - ) - - packets = stream_chat_message_objects( - new_msg_req=full_chat_msg_info, - user=user, - db_session=db_session, - ) - - response = ChatBasicResponse() - - answer = "" - for packet in packets: - if isinstance(packet, DanswerAnswerPiece) and packet.answer_piece: - answer += packet.answer_piece - elif isinstance(packet, QADocsResponse): - response.simple_search_docs = translate_doc_response_to_simple_doc(packet) - elif isinstance(packet, StreamingError): - response.error_msg = packet.error - elif isinstance(packet, ChatMessageDetail): - response.message_id = packet.message_id - elif isinstance(packet, LLMRelevanceFilterResponse): - response.llm_chunks_indices = packet.relevant_chunk_indices - - response.answer = answer - if answer: - response.answer_citationless = remove_answer_citations(answer) - - return response diff --git a/backend/ee/danswer/server/query_and_chat/models.py b/backend/ee/danswer/server/query_and_chat/models.py deleted file mode 100644 index b0ce553ebe0..00000000000 --- a/backend/ee/danswer/server/query_and_chat/models.py +++ /dev/null @@ -1,77 +0,0 @@ -from pydantic import BaseModel -from pydantic import Field - -from danswer.configs.constants import DocumentSource -from danswer.one_shot_answer.models import ThreadMessage -from danswer.search.enums import LLMEvaluationType -from danswer.search.enums import SearchType -from danswer.search.models import ChunkContext -from danswer.search.models import RerankingDetails -from danswer.search.models import RetrievalDetails -from danswer.server.manage.models import StandardAnswer - - -class StandardAnswerRequest(BaseModel): - message: str - slack_bot_categories: list[str] - - -class StandardAnswerResponse(BaseModel): - standard_answers: list[StandardAnswer] = Field(default_factory=list) - - -class DocumentSearchRequest(ChunkContext): - message: str - search_type: SearchType - retrieval_options: RetrievalDetails - recency_bias_multiplier: float = 1.0 - evaluation_type: LLMEvaluationType - # None to use system defaults for reranking - rerank_settings: RerankingDetails | None = None - - -class BasicCreateChatMessageRequest(ChunkContext): - """Before creating messages, be sure to create a chat_session and get an id - Note, for simplicity this option only allows for a single linear chain of messages - """ - - chat_session_id: int - # New message contents - message: str - # Defaults to using retrieval with no additional filters - retrieval_options: RetrievalDetails | None = None - # Allows the caller to specify the exact search query they want to use - # will disable Query Rewording if specified - query_override: str | None = None - # If search_doc_ids provided, then retrieval options are unused - search_doc_ids: list[int] | None = None - - -class BasicCreateChatMessageWithHistoryRequest(ChunkContext): - # Last element is the new query. All previous elements are historical context - messages: list[ThreadMessage] - prompt_id: int | None - persona_id: int - retrieval_options: RetrievalDetails = Field(default_factory=RetrievalDetails) - query_override: str | None = None - skip_rerank: bool | None = None - - -class SimpleDoc(BaseModel): - id: str - semantic_identifier: str - link: str | None - blurb: str - match_highlights: list[str] - source_type: DocumentSource - metadata: dict | None - - -class ChatBasicResponse(BaseModel): - # This is built piece by piece, any of these can be None as the flow could break - answer: str | None = None - answer_citationless: str | None = None - simple_search_docs: list[SimpleDoc] | None = None - error_msg: str | None = None - message_id: int | None = None - llm_chunks_indices: list[int] | None = None diff --git a/backend/ee/danswer/server/query_and_chat/query_backend.py b/backend/ee/danswer/server/query_and_chat/query_backend.py deleted file mode 100644 index aef3648220e..00000000000 --- a/backend/ee/danswer/server/query_and_chat/query_backend.py +++ /dev/null @@ -1,185 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_user -from danswer.configs.danswerbot_configs import DANSWER_BOT_TARGET_CHUNK_PERCENTAGE -from danswer.danswerbot.slack.handlers.handle_standard_answers import ( - oneoff_standard_answers, -) -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.persona import get_persona_by_id -from danswer.llm.answering.prompts.citations_prompt import ( - compute_max_document_tokens_for_persona, -) -from danswer.llm.factory import get_default_llms -from danswer.llm.factory import get_llms_for_persona -from danswer.llm.factory import get_main_llm_from_tuple -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 SavedSearchDocWithContent -from danswer.search.models import SearchRequest -from danswer.search.pipeline import SearchPipeline -from danswer.search.utils import dedupe_documents -from danswer.search.utils import drop_llm_indices -from danswer.search.utils import relevant_sections_to_indices -from danswer.utils.logger import setup_logger -from ee.danswer.server.query_and_chat.models import DocumentSearchRequest -from ee.danswer.server.query_and_chat.models import StandardAnswerRequest -from ee.danswer.server.query_and_chat.models import StandardAnswerResponse - - -logger = setup_logger() -basic_router = APIRouter(prefix="/query") - - -class DocumentSearchResponse(BaseModel): - top_documents: list[SavedSearchDocWithContent] - llm_indices: list[int] - - -@basic_router.post("/document-search") -def handle_search_request( - search_request: DocumentSearchRequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> DocumentSearchResponse: - """Simple search endpoint, does not create a new message or records in the DB""" - query = search_request.message - logger.notice(f"Received document search query: {query}") - - llm, fast_llm = get_default_llms() - - search_pipeline = SearchPipeline( - search_request=SearchRequest( - query=query, - search_type=search_request.search_type, - human_selected_filters=search_request.retrieval_options.filters, - enable_auto_detect_filters=search_request.retrieval_options.enable_auto_detect_filters, - persona=None, # For simplicity, default settings should be good for this search - offset=search_request.retrieval_options.offset, - limit=search_request.retrieval_options.limit, - rerank_settings=search_request.rerank_settings, - evaluation_type=search_request.evaluation_type, - chunks_above=search_request.chunks_above, - chunks_below=search_request.chunks_below, - full_doc=search_request.full_doc, - ), - user=user, - llm=llm, - fast_llm=fast_llm, - db_session=db_session, - bypass_acl=False, - ) - top_sections = search_pipeline.reranked_sections - relevance_sections = search_pipeline.section_relevance - top_docs = [ - SavedSearchDocWithContent( - document_id=section.center_chunk.document_id, - chunk_ind=section.center_chunk.chunk_id, - content=section.center_chunk.content, - semantic_identifier=section.center_chunk.semantic_identifier or "Unknown", - link=section.center_chunk.source_links.get(0) - if section.center_chunk.source_links - else None, - blurb=section.center_chunk.blurb, - source_type=section.center_chunk.source_type, - boost=section.center_chunk.boost, - hidden=section.center_chunk.hidden, - metadata=section.center_chunk.metadata, - score=section.center_chunk.score or 0.0, - match_highlights=section.center_chunk.match_highlights, - updated_at=section.center_chunk.updated_at, - primary_owners=section.center_chunk.primary_owners, - secondary_owners=section.center_chunk.secondary_owners, - is_internet=False, - db_doc_id=0, - ) - for section in top_sections - ] - - # Deduping happens at the last step to avoid harming quality by dropping content early on - deduped_docs = top_docs - dropped_inds = None - - if search_request.retrieval_options.dedupe_docs: - deduped_docs, dropped_inds = dedupe_documents(top_docs) - - llm_indices = relevant_sections_to_indices( - relevance_sections=relevance_sections, items=deduped_docs - ) - - if dropped_inds: - llm_indices = drop_llm_indices( - llm_indices=llm_indices, - search_docs=deduped_docs, - dropped_indices=dropped_inds, - ) - - return DocumentSearchResponse(top_documents=deduped_docs, llm_indices=llm_indices) - - -@basic_router.post("/answer-with-quote") -def get_answer_with_quote( - query_request: DirectQARequest, - user: User | None = Depends(current_user), - db_session: Session = Depends(get_session), -) -> OneShotQAResponse: - query = query_request.messages[0].message - logger.notice(f"Received query for one shot answer API with quotes: {query}") - - persona = get_persona_by_id( - persona_id=query_request.persona_id, - user=user, - db_session=db_session, - is_for_edit=False, - ) - - llm = get_main_llm_from_tuple( - get_default_llms() if not persona else get_llms_for_persona(persona) - ) - input_tokens = get_max_input_tokens( - model_name=llm.config.model_name, model_provider=llm.config.model_provider - ) - max_history_tokens = int(input_tokens * DANSWER_BOT_TARGET_CHUNK_PERCENTAGE) - - remaining_tokens = input_tokens - max_history_tokens - - max_document_tokens = compute_max_document_tokens_for_persona( - persona=persona, - actual_user_input=query, - max_llm_token_override=remaining_tokens, - ) - - answer_details = get_search_answer( - query_req=query_request, - user=user, - max_document_tokens=max_document_tokens, - max_history_tokens=max_history_tokens, - db_session=db_session, - ) - - return answer_details - - -@basic_router.get("/standard-answer") -def get_standard_answer( - request: StandardAnswerRequest, - db_session: Session = Depends(get_session), - _: User | None = Depends(current_user), -) -> StandardAnswerResponse: - try: - standard_answers = oneoff_standard_answers( - message=request.message, - slack_bot_categories=request.slack_bot_categories, - db_session=db_session, - ) - return StandardAnswerResponse(standard_answers=standard_answers) - except Exception as e: - logger.error(f"Error in get_standard_answer: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail="An internal server error occurred") diff --git a/backend/ee/danswer/server/query_and_chat/token_limit.py b/backend/ee/danswer/server/query_and_chat/token_limit.py deleted file mode 100644 index 538458fb63f..00000000000 --- a/backend/ee/danswer/server/query_and_chat/token_limit.py +++ /dev/null @@ -1,184 +0,0 @@ -from collections import defaultdict -from collections.abc import Sequence -from datetime import datetime -from itertools import groupby -from typing import Dict -from typing import List -from typing import Tuple -from uuid import UUID - -from fastapi import HTTPException -from sqlalchemy import func -from sqlalchemy import select -from sqlalchemy.orm import Session - -from danswer.db.engine import get_session_context_manager -from danswer.db.models import ChatMessage -from danswer.db.models import ChatSession -from danswer.db.models import TokenRateLimit -from danswer.db.models import TokenRateLimit__UserGroup -from danswer.db.models import User -from danswer.db.models import User__UserGroup -from danswer.db.models import UserGroup -from danswer.server.query_and_chat.token_limit import _get_cutoff_time -from danswer.server.query_and_chat.token_limit import _is_rate_limited -from danswer.server.query_and_chat.token_limit import _user_is_rate_limited_by_global -from danswer.utils.threadpool_concurrency import run_functions_tuples_in_parallel -from ee.danswer.db.api_key import is_api_key_email_address -from ee.danswer.db.token_limit import fetch_all_user_token_rate_limits - - -def _check_token_rate_limits(user: User | None) -> None: - if user is None: - # Unauthenticated users are only rate limited by global settings - _user_is_rate_limited_by_global() - - elif is_api_key_email_address(user.email): - # API keys are only rate limited by global settings - _user_is_rate_limited_by_global() - - else: - run_functions_tuples_in_parallel( - [ - (_user_is_rate_limited, (user.id,)), - (_user_is_rate_limited_by_group, (user.id,)), - (_user_is_rate_limited_by_global, ()), - ] - ) - - -""" -User rate limits -""" - - -def _user_is_rate_limited(user_id: UUID) -> None: - with get_session_context_manager() as db_session: - user_rate_limits = fetch_all_user_token_rate_limits( - db_session=db_session, enabled_only=True, ordered=False - ) - - if user_rate_limits: - user_cutoff_time = _get_cutoff_time(user_rate_limits) - user_usage = _fetch_user_usage(user_id, user_cutoff_time, db_session) - - if _is_rate_limited(user_rate_limits, user_usage): - raise HTTPException( - status_code=429, - detail="Token budget exceeded for user. Try again later.", - ) - - -def _fetch_user_usage( - user_id: UUID, cutoff_time: datetime, db_session: Session -) -> Sequence[tuple[datetime, int]]: - """ - Fetch user usage within the cutoff time, grouped by minute - """ - result = db_session.execute( - select( - func.date_trunc("minute", ChatMessage.time_sent), - func.sum(ChatMessage.token_count), - ) - .join(ChatSession, ChatMessage.chat_session_id == ChatSession.id) - .where(ChatSession.user_id == user_id, ChatMessage.time_sent >= cutoff_time) - .group_by(func.date_trunc("minute", ChatMessage.time_sent)) - ).all() - - return [(row[0], row[1]) for row in result] - - -""" -User Group rate limits -""" - - -def _user_is_rate_limited_by_group(user_id: UUID) -> None: - with get_session_context_manager() as db_session: - group_rate_limits = _fetch_all_user_group_rate_limits(user_id, db_session) - - if group_rate_limits: - # Group cutoff time is the same for all groups. - # This could be optimized to only fetch the maximum cutoff time for - # a specific group, but seems unnecessary for now. - group_cutoff_time = _get_cutoff_time( - [e for sublist in group_rate_limits.values() for e in sublist] - ) - - user_group_ids = list(group_rate_limits.keys()) - group_usage = _fetch_user_group_usage( - user_group_ids, group_cutoff_time, db_session - ) - - has_at_least_one_untriggered_limit = False - for user_group_id, rate_limits in group_rate_limits.items(): - usage = group_usage.get(user_group_id, []) - - if not _is_rate_limited(rate_limits, usage): - has_at_least_one_untriggered_limit = True - break - - if not has_at_least_one_untriggered_limit: - raise HTTPException( - status_code=429, - detail="Token budget exceeded for user's groups. Try again later.", - ) - - -def _fetch_all_user_group_rate_limits( - user_id: UUID, db_session: Session -) -> Dict[int, List[TokenRateLimit]]: - group_limits = ( - select(TokenRateLimit, User__UserGroup.user_group_id) - .join( - TokenRateLimit__UserGroup, - TokenRateLimit.id == TokenRateLimit__UserGroup.rate_limit_id, - ) - .join( - UserGroup, - UserGroup.id == TokenRateLimit__UserGroup.user_group_id, - ) - .join( - User__UserGroup, - User__UserGroup.user_group_id == UserGroup.id, - ) - .where( - User__UserGroup.user_id == user_id, - TokenRateLimit.enabled.is_(True), - ) - ) - - raw_rate_limits = db_session.execute(group_limits).all() - - group_rate_limits = defaultdict(list) - for rate_limit, user_group_id in raw_rate_limits: - group_rate_limits[user_group_id].append(rate_limit) - - return group_rate_limits - - -def _fetch_user_group_usage( - user_group_ids: list[int], cutoff_time: datetime, db_session: Session -) -> dict[int, list[Tuple[datetime, int]]]: - """ - Fetch user group usage within the cutoff time, grouped by minute - """ - user_group_usage = db_session.execute( - select( - func.sum(ChatMessage.token_count), - func.date_trunc("minute", ChatMessage.time_sent), - UserGroup.id, - ) - .join(ChatSession, ChatMessage.chat_session_id == ChatSession.id) - .join(User__UserGroup, User__UserGroup.user_id == ChatSession.user_id) - .join(UserGroup, UserGroup.id == User__UserGroup.user_group_id) - .filter(UserGroup.id.in_(user_group_ids), ChatMessage.time_sent >= cutoff_time) - .group_by(func.date_trunc("minute", ChatMessage.time_sent), UserGroup.id) - ).all() - - return { - user_group_id: [(usage, time_sent) for time_sent, usage, _ in group_usage] - for user_group_id, group_usage in groupby( - user_group_usage, key=lambda row: row[2] - ) - } diff --git a/backend/ee/danswer/server/query_history/api.py b/backend/ee/danswer/server/query_history/api.py deleted file mode 100644 index ed532a85603..00000000000 --- a/backend/ee/danswer/server/query_history/api.py +++ /dev/null @@ -1,394 +0,0 @@ -import csv -import io -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Literal - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import get_display_email -from danswer.chat.chat_utils import create_chat_chain -from danswer.configs.constants import MessageType -from danswer.configs.constants import QAFeedbackType -from danswer.db.chat import get_chat_session_by_id -from danswer.db.engine import get_session -from danswer.db.models import ChatMessage -from danswer.db.models import ChatSession -from danswer.db.models import User -from ee.danswer.db.query_history import fetch_chat_sessions_eagerly_by_time - -router = APIRouter() - - -class AbridgedSearchDoc(BaseModel): - """A subset of the info present in `SearchDoc`""" - - document_id: str - semantic_identifier: str - link: str | None - - -class MessageSnapshot(BaseModel): - message: str - message_type: MessageType - documents: list[AbridgedSearchDoc] - feedback_type: QAFeedbackType | None - feedback_text: str | None - time_created: datetime - - @classmethod - def build(cls, message: ChatMessage) -> "MessageSnapshot": - latest_messages_feedback_obj = ( - message.chat_message_feedbacks[-1] - if len(message.chat_message_feedbacks) > 0 - else None - ) - feedback_type = ( - ( - QAFeedbackType.LIKE - if latest_messages_feedback_obj.is_positive - else QAFeedbackType.DISLIKE - ) - if latest_messages_feedback_obj - else None - ) - feedback_text = ( - latest_messages_feedback_obj.feedback_text - if latest_messages_feedback_obj - else None - ) - return cls( - message=message.message, - message_type=message.message_type, - documents=[ - AbridgedSearchDoc( - document_id=document.document_id, - semantic_identifier=document.semantic_id, - link=document.link, - ) - for document in message.search_docs - ], - feedback_type=feedback_type, - feedback_text=feedback_text, - time_created=message.time_sent, - ) - - -class ChatSessionMinimal(BaseModel): - id: int - user_email: str - name: str | None - first_user_message: str - first_ai_message: str - persona_name: str - time_created: datetime - feedback_type: QAFeedbackType | Literal["mixed"] | None - - -class ChatSessionSnapshot(BaseModel): - id: int - user_email: str - name: str | None - messages: list[MessageSnapshot] - persona_name: str - time_created: datetime - - -class QuestionAnswerPairSnapshot(BaseModel): - chat_session_id: int - # 1-indexed message number in the chat_session - # e.g. the first message pair in the chat_session is 1, the second is 2, etc. - message_pair_num: int - user_message: str - ai_response: str - retrieved_documents: list[AbridgedSearchDoc] - feedback_type: QAFeedbackType | None - feedback_text: str | None - persona_name: str - user_email: str - time_created: datetime - - @classmethod - def from_chat_session_snapshot( - cls, - chat_session_snapshot: ChatSessionSnapshot, - ) -> list["QuestionAnswerPairSnapshot"]: - message_pairs: list[tuple[MessageSnapshot, MessageSnapshot]] = [] - for ind in range(1, len(chat_session_snapshot.messages), 2): - message_pairs.append( - ( - chat_session_snapshot.messages[ind - 1], - chat_session_snapshot.messages[ind], - ) - ) - - return [ - cls( - chat_session_id=chat_session_snapshot.id, - message_pair_num=ind + 1, - user_message=user_message.message, - ai_response=ai_message.message, - retrieved_documents=ai_message.documents, - feedback_type=ai_message.feedback_type, - feedback_text=ai_message.feedback_text, - persona_name=chat_session_snapshot.persona_name, - user_email=get_display_email(chat_session_snapshot.user_email), - time_created=user_message.time_created, - ) - for ind, (user_message, ai_message) in enumerate(message_pairs) - ] - - def to_json(self) -> dict[str, str]: - return { - "chat_session_id": str(self.chat_session_id), - "message_pair_num": str(self.message_pair_num), - "user_message": self.user_message, - "ai_response": self.ai_response, - "retrieved_documents": "|".join( - [ - doc.link or doc.semantic_identifier - for doc in self.retrieved_documents - ] - ), - "feedback_type": self.feedback_type.value if self.feedback_type else "", - "feedback_text": self.feedback_text or "", - "persona_name": self.persona_name, - "user_email": self.user_email, - "time_created": str(self.time_created), - } - - -def fetch_and_process_chat_session_history_minimal( - db_session: Session, - start: datetime, - end: datetime, - feedback_filter: QAFeedbackType | None = None, - limit: int | None = 500, -) -> list[ChatSessionMinimal]: - chat_sessions = fetch_chat_sessions_eagerly_by_time( - start=start, end=end, db_session=db_session, limit=limit - ) - - minimal_sessions = [] - for chat_session in chat_sessions: - if not chat_session.messages: - continue - - first_user_message = next( - ( - message.message - for message in chat_session.messages - if message.message_type == MessageType.USER - ), - "", - ) - first_ai_message = next( - ( - message.message - for message in chat_session.messages - if message.message_type == MessageType.ASSISTANT - ), - "", - ) - - has_positive_feedback = any( - feedback.is_positive - for message in chat_session.messages - for feedback in message.chat_message_feedbacks - ) - - has_negative_feedback = any( - not feedback.is_positive - for message in chat_session.messages - for feedback in message.chat_message_feedbacks - ) - - feedback_type: QAFeedbackType | Literal["mixed"] | None = ( - "mixed" - if has_positive_feedback and has_negative_feedback - else QAFeedbackType.LIKE - if has_positive_feedback - else QAFeedbackType.DISLIKE - if has_negative_feedback - else None - ) - - if feedback_filter: - if feedback_filter == QAFeedbackType.LIKE and not has_positive_feedback: - continue - if feedback_filter == QAFeedbackType.DISLIKE and not has_negative_feedback: - continue - - minimal_sessions.append( - ChatSessionMinimal( - id=chat_session.id, - user_email=get_display_email( - chat_session.user.email if chat_session.user else None - ), - name=chat_session.description, - first_user_message=first_user_message, - first_ai_message=first_ai_message, - persona_name=chat_session.persona.name, - time_created=chat_session.time_created, - feedback_type=feedback_type, - ) - ) - - return minimal_sessions - - -def fetch_and_process_chat_session_history( - db_session: Session, - start: datetime, - end: datetime, - feedback_type: QAFeedbackType | None, - limit: int | None = 500, -) -> list[ChatSessionSnapshot]: - chat_sessions = fetch_chat_sessions_eagerly_by_time( - start=start, end=end, db_session=db_session, limit=limit - ) - - chat_session_snapshots = [ - snapshot_from_chat_session(chat_session=chat_session, db_session=db_session) - for chat_session in chat_sessions - ] - - valid_snapshots = [ - snapshot for snapshot in chat_session_snapshots if snapshot is not None - ] - - if feedback_type: - valid_snapshots = [ - snapshot - for snapshot in valid_snapshots - if any( - message.feedback_type == feedback_type for message in snapshot.messages - ) - ] - - return valid_snapshots - - -def snapshot_from_chat_session( - chat_session: ChatSession, - db_session: Session, -) -> ChatSessionSnapshot | None: - try: - # Older chats may not have the right structure - last_message, messages = create_chat_chain( - chat_session_id=chat_session.id, db_session=db_session - ) - messages.append(last_message) - except RuntimeError: - return None - - return ChatSessionSnapshot( - id=chat_session.id, - user_email=get_display_email( - chat_session.user.email if chat_session.user else None - ), - name=chat_session.description, - messages=[ - MessageSnapshot.build(message) - for message in messages - if message.message_type != MessageType.SYSTEM - ], - persona_name=chat_session.persona.name, - time_created=chat_session.time_created, - ) - - -@router.get("/admin/chat-session-history") -def get_chat_session_history( - feedback_type: QAFeedbackType | None = None, - start: datetime | None = None, - end: datetime | None = None, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[ChatSessionMinimal]: - return fetch_and_process_chat_session_history_minimal( - db_session=db_session, - start=start - or ( - datetime.now(tz=timezone.utc) - timedelta(days=30) - ), # default is 30d lookback - end=end or datetime.now(tz=timezone.utc), - feedback_filter=feedback_type, - ) - - -@router.get("/admin/chat-session-history/{chat_session_id}") -def get_chat_session_admin( - chat_session_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> ChatSessionSnapshot: - try: - chat_session = get_chat_session_by_id( - chat_session_id=chat_session_id, - user_id=None, # view chat regardless of user - db_session=db_session, - include_deleted=True, - ) - except ValueError: - raise HTTPException( - 400, f"Chat session with id '{chat_session_id}' does not exist." - ) - snapshot = snapshot_from_chat_session( - chat_session=chat_session, db_session=db_session - ) - - if snapshot is None: - raise HTTPException( - 400, - f"Could not create snapshot for chat session with id '{chat_session_id}'", - ) - - return snapshot - - -@router.get("/admin/query-history-csv") -def get_query_history_as_csv( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> StreamingResponse: - complete_chat_session_history = fetch_and_process_chat_session_history( - db_session=db_session, - start=datetime.fromtimestamp(0, tz=timezone.utc), - end=datetime.now(tz=timezone.utc), - feedback_type=None, - limit=None, - ) - - question_answer_pairs: list[QuestionAnswerPairSnapshot] = [] - for chat_session_snapshot in complete_chat_session_history: - question_answer_pairs.extend( - QuestionAnswerPairSnapshot.from_chat_session_snapshot(chat_session_snapshot) - ) - - # Create an in-memory text stream - stream = io.StringIO() - writer = csv.DictWriter( - stream, fieldnames=list(QuestionAnswerPairSnapshot.model_fields.keys()) - ) - writer.writeheader() - for row in question_answer_pairs: - writer.writerow(row.to_json()) - - # Reset the stream's position to the start - stream.seek(0) - - return StreamingResponse( - iter([stream.getvalue()]), - media_type="text/csv", - headers={ - "Content-Disposition": "attachment;filename=danswer_query_history.csv" - }, - ) diff --git a/backend/ee/danswer/server/reporting/usage_export_api.py b/backend/ee/danswer/server/reporting/usage_export_api.py deleted file mode 100644 index 409702c3fbf..00000000000 --- a/backend/ee/danswer/server/reporting/usage_export_api.py +++ /dev/null @@ -1,82 +0,0 @@ -from collections.abc import Generator -from datetime import datetime - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Response -from fastapi.responses import StreamingResponse -from pydantic import BaseModel -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.file_store.constants import STANDARD_CHUNK_SIZE -from ee.danswer.db.usage_export import get_all_usage_reports -from ee.danswer.db.usage_export import get_usage_report_data -from ee.danswer.db.usage_export import UsageReportMetadata -from ee.danswer.server.reporting.usage_export_generation import create_new_usage_report - -router = APIRouter() - - -class GenerateUsageReportParams(BaseModel): - period_from: str | None = None - period_to: str | None = None - - -@router.post("/admin/generate-usage-report") -def generate_report( - params: GenerateUsageReportParams, - user: User = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> UsageReportMetadata: - period = None - if params.period_from and params.period_to: - try: - period = ( - datetime.fromisoformat(params.period_from), - datetime.fromisoformat(params.period_to), - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - new_report = create_new_usage_report(db_session, user.id if user else None, period) - return new_report - - -@router.get("/admin/usage-report/{report_name}") -def read_usage_report( - report_name: str, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> Response: - try: - file = get_usage_report_data(db_session, report_name) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - def iterfile() -> Generator[bytes, None, None]: - while True: - chunk = file.read(STANDARD_CHUNK_SIZE) - if not chunk: - break - yield chunk - - return StreamingResponse( - content=iterfile(), - media_type="application/zip", - headers={"Content-Disposition": f"attachment; filename={report_name}"}, - ) - - -@router.get("/admin/usage-report") -def fetch_usage_reports( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[UsageReportMetadata]: - try: - return get_all_usage_reports(db_session) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/ee/danswer/server/reporting/usage_export_generation.py b/backend/ee/danswer/server/reporting/usage_export_generation.py deleted file mode 100644 index 2274b00dd70..00000000000 --- a/backend/ee/danswer/server/reporting/usage_export_generation.py +++ /dev/null @@ -1,165 +0,0 @@ -import csv -import tempfile -import uuid -import zipfile -from datetime import datetime -from datetime import timedelta -from datetime import timezone - -from fastapi_users_db_sqlalchemy import UUID_ID -from sqlalchemy.orm import Session - -from danswer.auth.schemas import UserStatus -from danswer.configs.constants import FileOrigin -from danswer.db.users import list_users -from danswer.file_store.constants import MAX_IN_MEMORY_SIZE -from danswer.file_store.file_store import FileStore -from danswer.file_store.file_store import get_default_file_store -from ee.danswer.db.usage_export import get_all_empty_chat_message_entries -from ee.danswer.db.usage_export import write_usage_report -from ee.danswer.server.reporting.usage_export_models import UsageReportMetadata -from ee.danswer.server.reporting.usage_export_models import UserSkeleton - - -def generate_chat_messages_report( - db_session: Session, - file_store: FileStore, - report_id: str, - period: tuple[datetime, datetime] | None, -) -> str: - file_name = f"{report_id}_chat_sessions" - - if period is None: - period = ( - datetime.fromtimestamp(0, tz=timezone.utc), - datetime.now(tz=timezone.utc), - ) - else: - # time-picker sends a time which is at the beginning of the day - # so we need to add one day to the end time to make it inclusive - period = ( - period[0], - period[1] + timedelta(days=1), - ) - - with tempfile.SpooledTemporaryFile( - max_size=MAX_IN_MEMORY_SIZE, mode="w+" - ) as temp_file: - csvwriter = csv.writer(temp_file, delimiter=",") - csvwriter.writerow(["session_id", "user_id", "flow_type", "time_sent"]) - for chat_message_skeleton_batch in get_all_empty_chat_message_entries( - db_session, period - ): - for chat_message_skeleton in chat_message_skeleton_batch: - csvwriter.writerow( - [ - chat_message_skeleton.chat_session_id, - chat_message_skeleton.user_id, - chat_message_skeleton.flow_type, - chat_message_skeleton.time_sent.isoformat(), - ] - ) - - # after writing seek to begining of buffer - temp_file.seek(0) - file_store.save_file( - file_name=file_name, - content=temp_file, - display_name=file_name, - file_origin=FileOrigin.OTHER, - file_type="text/csv", - ) - - return file_name - - -def generate_user_report( - db_session: Session, - file_store: FileStore, - report_id: str, -) -> str: - file_name = f"{report_id}_users" - - with tempfile.SpooledTemporaryFile( - max_size=MAX_IN_MEMORY_SIZE, mode="w+" - ) as temp_file: - csvwriter = csv.writer(temp_file, delimiter=",") - csvwriter.writerow(["user_id", "status"]) - - users = list_users(db_session) - for user in users: - user_skeleton = UserSkeleton( - user_id=str(user.id), - status=UserStatus.LIVE if user.is_active else UserStatus.DEACTIVATED, - ) - csvwriter.writerow([user_skeleton.user_id, user_skeleton.status]) - - temp_file.seek(0) - file_store.save_file( - file_name=file_name, - content=temp_file, - display_name=file_name, - file_origin=FileOrigin.OTHER, - file_type="text/csv", - ) - - return file_name - - -def create_new_usage_report( - db_session: Session, - user_id: UUID_ID | None, # None = auto-generated - period: tuple[datetime, datetime] | None, -) -> UsageReportMetadata: - report_id = str(uuid.uuid4()) - file_store = get_default_file_store(db_session) - - messages_filename = generate_chat_messages_report( - db_session, file_store, report_id, period - ) - users_filename = generate_user_report(db_session, file_store, report_id) - - with tempfile.SpooledTemporaryFile(max_size=MAX_IN_MEMORY_SIZE) as zip_buffer: - with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file: - # write messages - chat_messages_tmpfile = file_store.read_file( - messages_filename, mode="b", use_tempfile=True - ) - zip_file.writestr( - "chat_messages.csv", - chat_messages_tmpfile.read(), - ) - - # write users - users_tmpfile = file_store.read_file( - users_filename, mode="b", use_tempfile=True - ) - zip_file.writestr("users.csv", users_tmpfile.read()) - - zip_buffer.seek(0) - - # store zip blob to file_store - report_name = ( - f"{datetime.now(tz=timezone.utc).strftime('%Y-%m-%d')}" - f"_{report_id}_usage_report.zip" - ) - file_store.save_file( - file_name=report_name, - content=zip_buffer, - display_name=report_name, - file_origin=FileOrigin.GENERATED_REPORT, - file_type="application/zip", - ) - - # add report after zip file is written - new_report = write_usage_report(db_session, report_name, user_id, period) - - return UsageReportMetadata( - report_name=new_report.report_name, - requestor=( - str(new_report.requestor_user_id) if new_report.requestor_user_id else None - ), - time_created=new_report.time_created, - period_from=new_report.period_from, - period_to=new_report.period_to, - ) diff --git a/backend/ee/danswer/server/reporting/usage_export_models.py b/backend/ee/danswer/server/reporting/usage_export_models.py deleted file mode 100644 index 98d9021f816..00000000000 --- a/backend/ee/danswer/server/reporting/usage_export_models.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime -from enum import Enum - -from pydantic import BaseModel - -from danswer.auth.schemas import UserStatus - - -class FlowType(str, Enum): - CHAT = "chat" - SEARCH = "search" - SLACK = "slack" - - -class ChatMessageSkeleton(BaseModel): - message_id: int - chat_session_id: int - user_id: str | None - flow_type: FlowType - time_sent: datetime - - -class UserSkeleton(BaseModel): - user_id: str - status: UserStatus - - -class UsageReportMetadata(BaseModel): - report_name: str - requestor: str | None - time_created: datetime - period_from: datetime | None # None = All time - period_to: datetime | None diff --git a/backend/ee/danswer/server/saml.py b/backend/ee/danswer/server/saml.py deleted file mode 100644 index 5bc62e98d61..00000000000 --- a/backend/ee/danswer/server/saml.py +++ /dev/null @@ -1,185 +0,0 @@ -import contextlib -import secrets -from typing import Any - -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from fastapi import Request -from fastapi import Response -from fastapi import status -from fastapi_users import exceptions -from fastapi_users.password import PasswordHelper -from onelogin.saml2.auth import OneLogin_Saml2_Auth # type: ignore -from pydantic import BaseModel -from pydantic import EmailStr -from sqlalchemy.orm import Session - -from danswer.auth.schemas import UserCreate -from danswer.auth.schemas import UserRole -from danswer.auth.users import get_user_manager -from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS -from danswer.db.auth import get_user_count -from danswer.db.auth import get_user_db -from danswer.db.engine import get_async_session -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.utils.logger import setup_logger -from ee.danswer.configs.app_configs import SAML_CONF_DIR -from ee.danswer.db.saml import expire_saml_account -from ee.danswer.db.saml import get_saml_account -from ee.danswer.db.saml import upsert_saml_account -from ee.danswer.utils.secrets import encrypt_string -from ee.danswer.utils.secrets import extract_hashed_cookie - - -logger = setup_logger() -router = APIRouter(prefix="/auth/saml") - - -async def upsert_saml_user(email: str) -> User: - get_async_session_context = contextlib.asynccontextmanager( - get_async_session - ) # type:ignore - get_user_db_context = contextlib.asynccontextmanager(get_user_db) - get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) - - async with get_async_session_context() as session: - async with get_user_db_context(session) as user_db: - async with get_user_manager_context(user_db) as user_manager: - try: - return await user_manager.get_by_email(email) - except exceptions.UserNotExists: - logger.notice("Creating user from SAML login") - - user_count = await get_user_count() - role = UserRole.ADMIN if user_count == 0 else UserRole.BASIC - - fastapi_users_pw_helper = PasswordHelper() - password = fastapi_users_pw_helper.generate() - hashed_pass = fastapi_users_pw_helper.hash(password) - - user: User = await user_manager.create( - UserCreate( - email=EmailStr(email), - password=hashed_pass, - is_verified=True, - role=role, - ) - ) - - return user - - -async def prepare_from_fastapi_request(request: Request) -> dict[str, Any]: - form_data = await request.form() - if request.client is None: - raise ValueError("Invalid request for SAML") - - # Use X-Forwarded headers if available - http_host = request.headers.get("X-Forwarded-Host") or request.client.host - server_port = request.headers.get("X-Forwarded-Port") or request.url.port - - rv: dict[str, Any] = { - "http_host": http_host, - "server_port": server_port, - "script_name": request.url.path, - "post_data": {}, - "get_data": {}, - } - if request.query_params: - rv["get_data"] = (request.query_params,) - if "SAMLResponse" in form_data: - SAMLResponse = form_data["SAMLResponse"] - rv["post_data"]["SAMLResponse"] = SAMLResponse - if "RelayState" in form_data: - RelayState = form_data["RelayState"] - rv["post_data"]["RelayState"] = RelayState - return rv - - -class SAMLAuthorizeResponse(BaseModel): - authorization_url: str - - -@router.get("/authorize") -async def saml_login(request: Request) -> SAMLAuthorizeResponse: - req = await prepare_from_fastapi_request(request) - auth = OneLogin_Saml2_Auth(req, custom_base_path=SAML_CONF_DIR) - callback_url = auth.login() - return SAMLAuthorizeResponse(authorization_url=callback_url) - - -@router.post("/callback") -async def saml_login_callback( - request: Request, - db_session: Session = Depends(get_session), -) -> Response: - req = await prepare_from_fastapi_request(request) - auth = OneLogin_Saml2_Auth(req, custom_base_path=SAML_CONF_DIR) - auth.process_response() - errors = auth.get_errors() - if len(errors) != 0: - logger.error( - "Error when processing SAML Response: %s %s" - % (", ".join(errors), auth.get_last_error_reason()) - ) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. Failed to parse SAML Response.", - ) - - if not auth.is_authenticated(): - detail = "Access denied. User was not authenticated" - logger.error(detail) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=detail, - ) - - user_email = auth.get_attribute("email") - if not user_email: - detail = "SAML is not set up correctly, email attribute must be provided." - logger.error(detail) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=detail, - ) - - user_email = user_email[0] - - user = await upsert_saml_user(email=user_email) - - # Generate a random session cookie and Sha256 encrypt before saving - session_cookie = secrets.token_hex(16) - saved_cookie = encrypt_string(session_cookie) - - upsert_saml_account(user_id=user.id, cookie=saved_cookie, db_session=db_session) - - # Redirect to main Danswer search page - response = Response(status_code=status.HTTP_204_NO_CONTENT) - - response.set_cookie( - key="session", - value=session_cookie, - httponly=True, - secure=True, - max_age=SESSION_EXPIRE_TIME_SECONDS, - ) - - return response - - -@router.post("/logout") -def saml_logout( - request: Request, - db_session: Session = Depends(get_session), -) -> None: - saved_cookie = extract_hashed_cookie(request) - - if saved_cookie: - saml_account = get_saml_account(cookie=saved_cookie, db_session=db_session) - if saml_account: - expire_saml_account(saml_account, db_session) - - return diff --git a/backend/ee/danswer/server/seeding.py b/backend/ee/danswer/server/seeding.py deleted file mode 100644 index bbca5acc20a..00000000000 --- a/backend/ee/danswer/server/seeding.py +++ /dev/null @@ -1,146 +0,0 @@ -import os - -from pydantic import BaseModel -from sqlalchemy.orm import Session - -from danswer.db.engine import get_session_context_manager -from danswer.db.llm import update_default_provider -from danswer.db.llm import upsert_llm_provider -from danswer.db.persona import upsert_persona -from danswer.search.enums import RecencyBiasSetting -from danswer.server.features.persona.models import CreatePersonaRequest -from danswer.server.manage.llm.models import LLMProviderUpsertRequest -from danswer.server.settings.models import Settings -from danswer.server.settings.store import store_settings as store_base_settings -from danswer.utils.logger import setup_logger -from ee.danswer.server.enterprise_settings.models import AnalyticsScriptUpload -from ee.danswer.server.enterprise_settings.models import EnterpriseSettings -from ee.danswer.server.enterprise_settings.store import store_analytics_script -from ee.danswer.server.enterprise_settings.store import ( - store_settings as store_ee_settings, -) -from ee.danswer.server.enterprise_settings.store import upload_logo - -logger = setup_logger() - -_SEED_CONFIG_ENV_VAR_NAME = "ENV_SEED_CONFIGURATION" - - -class SeedConfiguration(BaseModel): - llms: list[LLMProviderUpsertRequest] | None = None - admin_user_emails: list[str] | None = None - seeded_logo_path: str | None = None - personas: list[CreatePersonaRequest] | None = None - settings: Settings | None = None - enterprise_settings: EnterpriseSettings | None = None - # Use existing `CUSTOM_ANALYTICS_SECRET_KEY` for reference - analytics_script_path: str | None = None - - -def _parse_env() -> SeedConfiguration | None: - seed_config_str = os.getenv(_SEED_CONFIG_ENV_VAR_NAME) - if not seed_config_str: - return None - seed_config = SeedConfiguration.parse_raw(seed_config_str) - return seed_config - - -def _seed_llms( - db_session: Session, llm_upsert_requests: list[LLMProviderUpsertRequest] -) -> None: - if llm_upsert_requests: - logger.notice("Seeding LLMs") - seeded_providers = [ - upsert_llm_provider(db_session, llm_upsert_request) - for llm_upsert_request in llm_upsert_requests - ] - update_default_provider(db_session, seeded_providers[0].id) - - -def _seed_personas(db_session: Session, personas: list[CreatePersonaRequest]) -> None: - if personas: - logger.notice("Seeding Personas") - for persona in personas: - upsert_persona( - user=None, # Seeding is done as admin - name=persona.name, - description=persona.description, - num_chunks=persona.num_chunks - if persona.num_chunks is not None - else 0.0, - llm_relevance_filter=persona.llm_relevance_filter, - llm_filter_extraction=persona.llm_filter_extraction, - recency_bias=RecencyBiasSetting.AUTO, - prompt_ids=persona.prompt_ids, - document_set_ids=persona.document_set_ids, - llm_model_provider_override=persona.llm_model_provider_override, - llm_model_version_override=persona.llm_model_version_override, - starter_messages=persona.starter_messages, - is_public=persona.is_public, - db_session=db_session, - tool_ids=persona.tool_ids, - ) - - -def _seed_settings(settings: Settings) -> None: - logger.notice("Seeding Settings") - try: - settings.check_validity() - store_base_settings(settings) - logger.notice("Successfully seeded Settings") - except ValueError as e: - logger.error(f"Failed to seed Settings: {str(e)}") - - -def _seed_enterprise_settings(seed_config: SeedConfiguration) -> None: - if seed_config.enterprise_settings is not None: - logger.notice("Seeding enterprise settings") - store_ee_settings(seed_config.enterprise_settings) - - -def _seed_logo(db_session: Session, logo_path: str | None) -> None: - if logo_path: - logger.notice("Uploading logo") - upload_logo(db_session=db_session, file=logo_path) - - -def _seed_analytics_script(seed_config: SeedConfiguration) -> None: - custom_analytics_secret_key = os.environ.get("CUSTOM_ANALYTICS_SECRET_KEY") - if seed_config.analytics_script_path and custom_analytics_secret_key: - logger.notice("Seeding analytics script") - try: - with open(seed_config.analytics_script_path, "r") as file: - script_content = file.read() - analytics_script = AnalyticsScriptUpload( - script=script_content, secret_key=custom_analytics_secret_key - ) - store_analytics_script(analytics_script) - except FileNotFoundError: - logger.error( - f"Analytics script file not found: {seed_config.analytics_script_path}" - ) - except ValueError as e: - logger.error(f"Failed to seed analytics script: {str(e)}") - - -def get_seed_config() -> SeedConfiguration | None: - return _parse_env() - - -def seed_db() -> None: - seed_config = _parse_env() - if seed_config is None: - logger.debug("No seeding configuration file passed") - return - - with get_session_context_manager() as db_session: - if seed_config.llms is not None: - _seed_llms(db_session, seed_config.llms) - if seed_config.personas is not None: - _seed_personas(db_session, seed_config.personas) - if seed_config.settings is not None: - _seed_settings(seed_config.settings) - - _seed_logo(db_session, seed_config.seeded_logo_path) - _seed_enterprise_settings(seed_config) - _seed_analytics_script(seed_config) diff --git a/backend/ee/danswer/server/token_rate_limits/api.py b/backend/ee/danswer/server/token_rate_limits/api.py deleted file mode 100644 index 97f1f15faed..00000000000 --- a/backend/ee/danswer/server/token_rate_limits/api.py +++ /dev/null @@ -1,106 +0,0 @@ -from collections import defaultdict - -from fastapi import APIRouter -from fastapi import Depends -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.server.query_and_chat.token_limit import any_rate_limit_exists -from danswer.server.token_rate_limits.models import TokenRateLimitArgs -from danswer.server.token_rate_limits.models import TokenRateLimitDisplay -from ee.danswer.db.token_limit import fetch_all_user_group_token_rate_limits_by_group -from ee.danswer.db.token_limit import fetch_all_user_token_rate_limits -from ee.danswer.db.token_limit import fetch_user_group_token_rate_limits -from ee.danswer.db.token_limit import insert_user_group_token_rate_limit -from ee.danswer.db.token_limit import insert_user_token_rate_limit - -router = APIRouter(prefix="/admin/token-rate-limits") - - -""" -Group Token Limit Settings -""" - - -@router.get("/user-groups") -def get_all_group_token_limit_settings( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> dict[str, list[TokenRateLimitDisplay]]: - user_groups_to_token_rate_limits = fetch_all_user_group_token_rate_limits_by_group( - db_session - ) - - token_rate_limits_by_group = defaultdict(list) - for token_rate_limit, group_name in user_groups_to_token_rate_limits: - token_rate_limits_by_group[group_name].append( - TokenRateLimitDisplay.from_db(token_rate_limit) - ) - - return dict(token_rate_limits_by_group) - - -@router.get("/user-group/{group_id}") -def get_group_token_limit_settings( - group_id: int, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> list[TokenRateLimitDisplay]: - return [ - TokenRateLimitDisplay.from_db(token_rate_limit) - for token_rate_limit in fetch_user_group_token_rate_limits( - db_session, group_id, user - ) - ] - - -@router.post("/user-group/{group_id}") -def create_group_token_limit_settings( - group_id: int, - token_limit_settings: TokenRateLimitArgs, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> TokenRateLimitDisplay: - rate_limit_display = TokenRateLimitDisplay.from_db( - insert_user_group_token_rate_limit( - db_session=db_session, - token_rate_limit_settings=token_limit_settings, - group_id=group_id, - ) - ) - # clear cache in case this was the first rate limit created - any_rate_limit_exists.cache_clear() - return rate_limit_display - - -""" -User Token Limit Settings -""" - - -@router.get("/users") -def get_user_token_limit_settings( - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> list[TokenRateLimitDisplay]: - return [ - TokenRateLimitDisplay.from_db(token_rate_limit) - for token_rate_limit in fetch_all_user_token_rate_limits(db_session) - ] - - -@router.post("/users") -def create_user_token_limit_settings( - token_limit_settings: TokenRateLimitArgs, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> TokenRateLimitDisplay: - rate_limit_display = TokenRateLimitDisplay.from_db( - insert_user_token_rate_limit(db_session, token_limit_settings) - ) - # clear cache in case this was the first rate limit created - any_rate_limit_exists.cache_clear() - return rate_limit_display diff --git a/backend/ee/danswer/server/user_group/api.py b/backend/ee/danswer/server/user_group/api.py deleted file mode 100644 index e18487d5491..00000000000 --- a/backend/ee/danswer/server/user_group/api.py +++ /dev/null @@ -1,105 +0,0 @@ -from fastapi import APIRouter -from fastapi import Depends -from fastapi import HTTPException -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session - -from danswer.auth.users import current_admin_user -from danswer.auth.users import current_curator_or_admin_user -from danswer.db.engine import get_session -from danswer.db.models import User -from danswer.db.models import UserRole -from ee.danswer.db.user_group import fetch_user_groups -from ee.danswer.db.user_group import fetch_user_groups_for_user -from ee.danswer.db.user_group import insert_user_group -from ee.danswer.db.user_group import prepare_user_group_for_deletion -from ee.danswer.db.user_group import update_user_curator_relationship -from ee.danswer.db.user_group import update_user_group -from ee.danswer.server.user_group.models import SetCuratorRequest -from ee.danswer.server.user_group.models import UserGroup -from ee.danswer.server.user_group.models import UserGroupCreate -from ee.danswer.server.user_group.models import UserGroupUpdate - -router = APIRouter(prefix="/manage") - - -@router.get("/admin/user-group") -def list_user_groups( - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> list[UserGroup]: - if user is None or user.role == UserRole.ADMIN: - user_groups = fetch_user_groups(db_session, only_current=False) - else: - user_groups = fetch_user_groups_for_user( - db_session=db_session, - user_id=user.id, - only_curator_groups=user.role == UserRole.CURATOR, - ) - return [UserGroup.from_model(user_group) for user_group in user_groups] - - -@router.post("/admin/user-group") -def create_user_group( - user_group: UserGroupCreate, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> UserGroup: - try: - db_user_group = insert_user_group(db_session, user_group) - except IntegrityError: - raise HTTPException( - 400, - f"User group with name '{user_group.name}' already exists. Please " - + "choose a different name.", - ) - return UserGroup.from_model(db_user_group) - - -@router.patch("/admin/user-group/{user_group_id}") -def patch_user_group( - user_group_id: int, - user_group_update: UserGroupUpdate, - user: User | None = Depends(current_curator_or_admin_user), - db_session: Session = Depends(get_session), -) -> UserGroup: - try: - return UserGroup.from_model( - update_user_group( - db_session=db_session, - user=user, - user_group_id=user_group_id, - user_group_update=user_group_update, - ) - ) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.post("/admin/user-group/{user_group_id}/set-curator") -def set_user_curator( - user_group_id: int, - set_curator_request: SetCuratorRequest, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - try: - update_user_curator_relationship( - db_session=db_session, - user_group_id=user_group_id, - set_curator_request=set_curator_request, - ) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - - -@router.delete("/admin/user-group/{user_group_id}") -def delete_user_group( - user_group_id: int, - _: User | None = Depends(current_admin_user), - db_session: Session = Depends(get_session), -) -> None: - try: - prepare_user_group_for_deletion(db_session, user_group_id) - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/ee/danswer/server/user_group/models.py b/backend/ee/danswer/server/user_group/models.py deleted file mode 100644 index 077a217e932..00000000000 --- a/backend/ee/danswer/server/user_group/models.py +++ /dev/null @@ -1,91 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - -from danswer.db.models import UserGroup as UserGroupModel -from danswer.server.documents.models import ConnectorCredentialPairDescriptor -from danswer.server.documents.models import ConnectorSnapshot -from danswer.server.documents.models import CredentialSnapshot -from danswer.server.features.document_set.models import DocumentSet -from danswer.server.features.persona.models import PersonaSnapshot -from danswer.server.manage.models import UserInfo -from danswer.server.manage.models import UserPreferences - - -class UserGroup(BaseModel): - id: int - name: str - users: list[UserInfo] - curator_ids: list[UUID] - cc_pairs: list[ConnectorCredentialPairDescriptor] - document_sets: list[DocumentSet] - personas: list[PersonaSnapshot] - is_up_to_date: bool - is_up_for_deletion: bool - - @classmethod - def from_model(cls, user_group_model: UserGroupModel) -> "UserGroup": - return cls( - id=user_group_model.id, - name=user_group_model.name, - users=[ - UserInfo( - id=str(user.id), - email=user.email, - is_active=user.is_active, - is_superuser=user.is_superuser, - is_verified=user.is_verified, - role=user.role, - preferences=UserPreferences( - default_model=user.default_model, - chosen_assistants=user.chosen_assistants, - ), - ) - for user in user_group_model.users - ], - curator_ids=[ - user.user_id - for user in user_group_model.user_group_relationships - if user.is_curator and user.user_id is not None - ], - cc_pairs=[ - ConnectorCredentialPairDescriptor( - id=cc_pair_relationship.cc_pair.id, - name=cc_pair_relationship.cc_pair.name, - connector=ConnectorSnapshot.from_connector_db_model( - cc_pair_relationship.cc_pair.connector - ), - credential=CredentialSnapshot.from_credential_db_model( - cc_pair_relationship.cc_pair.credential - ), - ) - for cc_pair_relationship in user_group_model.cc_pair_relationships - if cc_pair_relationship.is_current - ], - document_sets=[ - DocumentSet.from_model(ds) for ds in user_group_model.document_sets - ], - personas=[ - PersonaSnapshot.from_model(persona) - for persona in user_group_model.personas - if not persona.deleted - ], - is_up_to_date=user_group_model.is_up_to_date, - is_up_for_deletion=user_group_model.is_up_for_deletion, - ) - - -class UserGroupCreate(BaseModel): - name: str - user_ids: list[UUID] - cc_pair_ids: list[int] - - -class UserGroupUpdate(BaseModel): - user_ids: list[UUID] - cc_pair_ids: list[int] - - -class SetCuratorRequest(BaseModel): - user_id: UUID - is_curator: bool diff --git a/backend/ee/danswer/user_groups/sync.py b/backend/ee/danswer/user_groups/sync.py deleted file mode 100644 index e3bea192670..00000000000 --- a/backend/ee/danswer/user_groups/sync.py +++ /dev/null @@ -1,87 +0,0 @@ -from sqlalchemy.orm import Session - -from danswer.access.access import get_access_for_documents -from danswer.db.document import prepare_to_modify_documents -from danswer.db.search_settings import get_current_search_settings -from danswer.db.search_settings import get_secondary_search_settings -from danswer.document_index.factory import get_default_document_index -from danswer.document_index.interfaces import DocumentIndex -from danswer.document_index.interfaces import UpdateRequest -from danswer.utils.logger import setup_logger -from ee.danswer.db.user_group import delete_user_group -from ee.danswer.db.user_group import fetch_documents_for_user_group_paginated -from ee.danswer.db.user_group import fetch_user_group -from ee.danswer.db.user_group import mark_user_group_as_synced - -logger = setup_logger() - -_SYNC_BATCH_SIZE = 100 - - -def _sync_user_group_batch( - document_ids: list[str], document_index: DocumentIndex, db_session: Session -) -> None: - logger.debug(f"Syncing document sets for: {document_ids}") - - # Acquires a lock on the documents so that no other process can modify them - with prepare_to_modify_documents(db_session=db_session, document_ids=document_ids): - # get current state of document sets for these documents - document_id_to_access = get_access_for_documents( - document_ids=document_ids, db_session=db_session - ) - - # update Vespa - document_index.update( - update_requests=[ - UpdateRequest( - document_ids=[document_id], - access=document_id_to_access[document_id], - ) - for document_id in document_ids - ] - ) - - # Finish the transaction and release the locks - db_session.commit() - - -def sync_user_groups(user_group_id: int, db_session: Session) -> None: - """Sync the status of Postgres for the specified user group""" - search_settings = get_current_search_settings(db_session) - secondary_search_settings = get_secondary_search_settings(db_session) - - document_index = get_default_document_index( - primary_index_name=search_settings.index_name, - secondary_index_name=secondary_search_settings.index_name - if secondary_search_settings - else None, - ) - - user_group = fetch_user_group(db_session=db_session, user_group_id=user_group_id) - if user_group is None: - raise ValueError(f"User group '{user_group_id}' does not exist") - - cursor = None - while True: - # NOTE: this may miss some documents, but that is okay. Any new documents added - # will be added with the correct group membership - document_batch, cursor = fetch_documents_for_user_group_paginated( - db_session=db_session, - user_group_id=user_group_id, - last_document_id=cursor, - limit=_SYNC_BATCH_SIZE, - ) - - _sync_user_group_batch( - document_ids=[document.id for document in document_batch], - document_index=document_index, - db_session=db_session, - ) - - if cursor is None: - break - - if user_group.is_up_for_deletion: - delete_user_group(db_session=db_session, user_group=user_group) - else: - mark_user_group_as_synced(db_session=db_session, user_group=user_group) diff --git a/backend/ee/danswer/utils/__init__.py b/backend/ee/danswer/utils/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/ee/danswer/utils/encryption.py b/backend/ee/danswer/utils/encryption.py deleted file mode 100644 index 4e2329985ce..00000000000 --- a/backend/ee/danswer/utils/encryption.py +++ /dev/null @@ -1,85 +0,0 @@ -from functools import lru_cache -from os import urandom - -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import padding -from cryptography.hazmat.primitives.ciphers import algorithms -from cryptography.hazmat.primitives.ciphers import Cipher -from cryptography.hazmat.primitives.ciphers import modes - -from danswer.configs.app_configs import ENCRYPTION_KEY_SECRET -from danswer.utils.logger import setup_logger -from danswer.utils.variable_functionality import fetch_versioned_implementation - -logger = setup_logger() - - -@lru_cache(maxsize=1) -def _get_trimmed_key(key: str) -> bytes: - encoded_key = key.encode() - key_length = len(encoded_key) - if key_length < 16: - raise RuntimeError("Invalid ENCRYPTION_KEY_SECRET - too short") - elif key_length > 32: - key = key[:32] - elif key_length not in (16, 24, 32): - valid_lengths = [16, 24, 32] - key = key[: min(valid_lengths, key=lambda x: abs(x - key_length))] - - return encoded_key - - -def _encrypt_string(input_str: str) -> bytes: - if not ENCRYPTION_KEY_SECRET: - return input_str.encode() - - key = _get_trimmed_key(ENCRYPTION_KEY_SECRET) - iv = urandom(16) - padder = padding.PKCS7(algorithms.AES.block_size).padder() - padded_data = padder.update(input_str.encode()) + padder.finalize() - - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) - encryptor = cipher.encryptor() - encrypted_data = encryptor.update(padded_data) + encryptor.finalize() - - return iv + encrypted_data - - -def _decrypt_bytes(input_bytes: bytes) -> str: - if not ENCRYPTION_KEY_SECRET: - return input_bytes.decode() - - key = _get_trimmed_key(ENCRYPTION_KEY_SECRET) - iv = input_bytes[:16] - encrypted_data = input_bytes[16:] - - cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()) - decryptor = cipher.decryptor() - decrypted_padded_data = decryptor.update(encrypted_data) + decryptor.finalize() - - unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder() - decrypted_data = unpadder.update(decrypted_padded_data) + unpadder.finalize() - - return decrypted_data.decode() - - -def encrypt_string_to_bytes(input_str: str) -> bytes: - versioned_encryption_fn = fetch_versioned_implementation( - "danswer.utils.encryption", "_encrypt_string" - ) - return versioned_encryption_fn(input_str) - - -def decrypt_bytes_to_string(input_bytes: bytes) -> str: - versioned_decryption_fn = fetch_versioned_implementation( - "danswer.utils.encryption", "_decrypt_bytes" - ) - return versioned_decryption_fn(input_bytes) - - -def test_encryption() -> None: - test_string = "Danswer is the BEST!" - encrypted_bytes = encrypt_string_to_bytes(test_string) - decrypted_string = decrypt_bytes_to_string(encrypted_bytes) - if test_string != decrypted_string: - raise RuntimeError("Encryption decryption test failed") diff --git a/backend/ee/danswer/utils/secrets.py b/backend/ee/danswer/utils/secrets.py deleted file mode 100644 index d59d3b77ac8..00000000000 --- a/backend/ee/danswer/utils/secrets.py +++ /dev/null @@ -1,14 +0,0 @@ -import hashlib - -from fastapi import Request - -from danswer.configs.constants import SESSION_KEY - - -def encrypt_string(s: str) -> str: - return hashlib.sha256(s.encode()).hexdigest() - - -def extract_hashed_cookie(request: Request) -> str | None: - session_cookie = request.cookies.get(SESSION_KEY) - return encrypt_string(session_cookie) if session_cookie else None diff --git a/backend/model_server/__init__.py b/backend/model_server/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/model_server/constants.py b/backend/model_server/constants.py deleted file mode 100644 index d6991b40203..00000000000 --- a/backend/model_server/constants.py +++ /dev/null @@ -1,30 +0,0 @@ -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import EmbedTextType - - -MODEL_WARM_UP_STRING = "hi " * 512 -DEFAULT_OPENAI_MODEL = "text-embedding-3-small" -DEFAULT_COHERE_MODEL = "embed-english-light-v3.0" -DEFAULT_VOYAGE_MODEL = "voyage-large-2-instruct" -DEFAULT_VERTEX_MODEL = "text-embedding-004" - - -class EmbeddingModelTextType: - PROVIDER_TEXT_TYPE_MAP = { - EmbeddingProvider.COHERE: { - EmbedTextType.QUERY: "search_query", - EmbedTextType.PASSAGE: "search_document", - }, - EmbeddingProvider.VOYAGE: { - EmbedTextType.QUERY: "query", - EmbedTextType.PASSAGE: "document", - }, - EmbeddingProvider.GOOGLE: { - EmbedTextType.QUERY: "RETRIEVAL_QUERY", - EmbedTextType.PASSAGE: "RETRIEVAL_DOCUMENT", - }, - } - - @staticmethod - def get_type(provider: EmbeddingProvider, text_type: EmbedTextType) -> str: - return EmbeddingModelTextType.PROVIDER_TEXT_TYPE_MAP[provider][text_type] diff --git a/backend/model_server/custom_models.py b/backend/model_server/custom_models.py deleted file mode 100644 index 38bf4b077fa..00000000000 --- a/backend/model_server/custom_models.py +++ /dev/null @@ -1,200 +0,0 @@ -import torch -import torch.nn.functional as F -from fastapi import APIRouter -from huggingface_hub import snapshot_download # type: ignore -from transformers import AutoTokenizer # type: ignore -from transformers import BatchEncoding - -from danswer.utils.logger import setup_logger -from model_server.constants import MODEL_WARM_UP_STRING -from model_server.danswer_torch_model import HybridClassifier -from model_server.utils import simple_log_function_time -from shared_configs.configs import INDEXING_ONLY -from shared_configs.configs import INTENT_MODEL_TAG -from shared_configs.configs import INTENT_MODEL_VERSION -from shared_configs.model_server_models import IntentRequest -from shared_configs.model_server_models import IntentResponse - -logger = setup_logger() - -router = APIRouter(prefix="/custom") - -_INTENT_TOKENIZER: AutoTokenizer | None = None -_INTENT_MODEL: HybridClassifier | None = None - - -def get_intent_model_tokenizer() -> AutoTokenizer: - global _INTENT_TOKENIZER - if _INTENT_TOKENIZER is None: - # The tokenizer details are not uploaded to the HF hub since it's just the - # unmodified distilbert tokenizer. - _INTENT_TOKENIZER = AutoTokenizer.from_pretrained("distilbert-base-uncased") - return _INTENT_TOKENIZER - - -def get_local_intent_model( - model_name_or_path: str = INTENT_MODEL_VERSION, - tag: str = INTENT_MODEL_TAG, -) -> HybridClassifier: - global _INTENT_MODEL - if _INTENT_MODEL is None: - try: - # Calculate where the cache should be, then load from local if available - logger.notice(f"Loading model from local cache: {model_name_or_path}") - local_path = snapshot_download( - repo_id=model_name_or_path, revision=tag, local_files_only=True - ) - _INTENT_MODEL = HybridClassifier.from_pretrained(local_path) - logger.notice(f"Loaded model from local cache: {local_path}") - except Exception as e: - logger.warning(f"Failed to load model directly: {e}") - try: - # Attempt to download the model snapshot - logger.notice(f"Downloading model snapshot for {model_name_or_path}") - local_path = snapshot_download(repo_id=model_name_or_path, revision=tag) - _INTENT_MODEL = HybridClassifier.from_pretrained(local_path) - except Exception as e: - logger.error( - f"Failed to load model even after attempted snapshot download: {e}" - ) - raise - return _INTENT_MODEL - - -def warm_up_intent_model() -> None: - logger.notice(f"Warming up Intent Model: {INTENT_MODEL_VERSION}") - intent_tokenizer = get_intent_model_tokenizer() - tokens = intent_tokenizer( - MODEL_WARM_UP_STRING, return_tensors="pt", truncation=True, padding=True - ) - - intent_model = get_local_intent_model() - device = intent_model.device - intent_model( - query_ids=tokens["input_ids"].to(device), - query_mask=tokens["attention_mask"].to(device), - ) - - -@simple_log_function_time() -def run_inference(tokens: BatchEncoding) -> tuple[list[float], list[float]]: - intent_model = get_local_intent_model() - device = intent_model.device - - outputs = intent_model( - query_ids=tokens["input_ids"].to(device), - query_mask=tokens["attention_mask"].to(device), - ) - - token_logits = outputs["token_logits"] - intent_logits = outputs["intent_logits"] - - # Move tensors to CPU before applying softmax and converting to numpy - intent_probabilities = F.softmax(intent_logits.cpu(), dim=-1).numpy()[0] - token_probabilities = F.softmax(token_logits.cpu(), dim=-1).numpy()[0] - - # Extract the probabilities for the positive class (index 1) for each token - token_positive_probs = token_probabilities[:, 1].tolist() - - return intent_probabilities.tolist(), token_positive_probs - - -def map_keywords( - input_ids: torch.Tensor, tokenizer: AutoTokenizer, is_keyword: list[bool] -) -> list[str]: - tokens = tokenizer.convert_ids_to_tokens(input_ids) - - if not len(tokens) == len(is_keyword): - raise ValueError("Length of tokens and keyword predictions must match") - - if input_ids[0] == tokenizer.cls_token_id: - tokens = tokens[1:] - is_keyword = is_keyword[1:] - - if input_ids[-1] == tokenizer.sep_token_id: - tokens = tokens[:-1] - is_keyword = is_keyword[:-1] - - unk_token = tokenizer.unk_token - if unk_token in tokens: - raise ValueError("Unknown token detected in the input") - - keywords = [] - current_keyword = "" - - for ind, token in enumerate(tokens): - if is_keyword[ind]: - if token.startswith("##"): - current_keyword += token[2:] - else: - if current_keyword: - keywords.append(current_keyword) - current_keyword = token - else: - # If mispredicted a later token of a keyword, add it to the current keyword - # to complete it - if current_keyword: - if len(current_keyword) > 2 and current_keyword.startswith("##"): - current_keyword = current_keyword[2:] - - else: - keywords.append(current_keyword) - current_keyword = "" - - if current_keyword: - keywords.append(current_keyword) - - return keywords - - -def clean_keywords(keywords: list[str]) -> list[str]: - cleaned_words = [] - for word in keywords: - word = word[:-2] if word.endswith("'s") else word - word = word.replace("/", " ") - word = word.replace("'", "").replace('"', "") - cleaned_words.extend([w for w in word.strip().split() if w and not w.isspace()]) - return cleaned_words - - -def run_analysis(intent_req: IntentRequest) -> tuple[bool, list[str]]: - tokenizer = get_intent_model_tokenizer() - model_input = tokenizer( - intent_req.query, return_tensors="pt", truncation=False, padding=False - ) - - if len(model_input.input_ids[0]) > 512: - # If the user text is too long, assume it is semantic and keep all words - return True, intent_req.query.split() - - intent_probs, token_probs = run_inference(model_input) - - is_keyword_sequence = intent_probs[0] >= intent_req.keyword_percent_threshold - - keyword_preds = [ - token_prob >= intent_req.keyword_percent_threshold for token_prob in token_probs - ] - - try: - keywords = map_keywords(model_input.input_ids[0], tokenizer, keyword_preds) - except Exception as e: - logger.error( - f"Failed to extract keywords for query: {intent_req.query} due to {e}" - ) - # Fallback to keeping all words - keywords = intent_req.query.split() - - cleaned_keywords = clean_keywords(keywords) - - return is_keyword_sequence, cleaned_keywords - - -@router.post("/query-analysis") -async def process_analysis_request( - intent_request: IntentRequest, -) -> IntentResponse: - if INDEXING_ONLY: - raise RuntimeError("Indexing model server should not call intent endpoint") - - is_keyword, keywords = run_analysis(intent_request) - return IntentResponse(is_keyword=is_keyword, keywords=keywords) diff --git a/backend/model_server/danswer_torch_model.py b/backend/model_server/danswer_torch_model.py deleted file mode 100644 index 28554a4fd2d..00000000000 --- a/backend/model_server/danswer_torch_model.py +++ /dev/null @@ -1,74 +0,0 @@ -import json -import os - -import torch -import torch.nn as nn -from transformers import DistilBertConfig # type: ignore -from transformers import DistilBertModel - - -class HybridClassifier(nn.Module): - def __init__(self) -> None: - super().__init__() - config = DistilBertConfig() - self.distilbert = DistilBertModel(config) - - # Keyword tokenwise binary classification layer - self.keyword_classifier = nn.Linear(self.distilbert.config.dim, 2) - - # Intent Classifier layers - self.pre_classifier = nn.Linear( - self.distilbert.config.dim, self.distilbert.config.dim - ) - self.intent_classifier = nn.Linear(self.distilbert.config.dim, 2) - self.dropout = nn.Dropout(self.distilbert.config.seq_classif_dropout) - - self.device = torch.device("cpu") - - def forward( - self, - query_ids: torch.Tensor, - query_mask: torch.Tensor, - ) -> dict[str, torch.Tensor]: - outputs = self.distilbert(input_ids=query_ids, attention_mask=query_mask) - sequence_output = outputs.last_hidden_state - - # Intent classification on the CLS token - cls_token_state = sequence_output[:, 0, :] - pre_classifier_out = self.pre_classifier(cls_token_state) - dropout_out = self.dropout(pre_classifier_out) - intent_logits = self.intent_classifier(dropout_out) - - # Keyword classification on all tokens - token_logits = self.keyword_classifier(sequence_output) - - return {"intent_logits": intent_logits, "token_logits": token_logits} - - @classmethod - def from_pretrained(cls, load_directory: str) -> "HybridClassifier": - model_path = os.path.join(load_directory, "pytorch_model.bin") - config_path = os.path.join(load_directory, "config.json") - - with open(config_path, "r") as f: - config = json.load(f) - model = cls(**config) - - if torch.backends.mps.is_available(): - # Apple silicon GPU - device = torch.device("mps") - elif torch.cuda.is_available(): - device = torch.device("cuda") - else: - device = torch.device("cpu") - - model.load_state_dict(torch.load(model_path, map_location=device)) - model = model.to(device) - - model.device = device - - model.eval() - # Eval doesn't set requires_grad to False, do it manually to save memory and have faster inference - for param in model.parameters(): - param.requires_grad = False - - return model diff --git a/backend/model_server/encoders.py b/backend/model_server/encoders.py deleted file mode 100644 index 4e97bd00f27..00000000000 --- a/backend/model_server/encoders.py +++ /dev/null @@ -1,393 +0,0 @@ -import json -from typing import Any -from typing import Optional - -import openai -import vertexai # type: ignore -import voyageai # type: ignore -from cohere import Client as CohereClient -from fastapi import APIRouter -from fastapi import HTTPException -from google.oauth2 import service_account # type: ignore -from retry import retry -from sentence_transformers import CrossEncoder # type: ignore -from sentence_transformers import SentenceTransformer # type: ignore -from vertexai.language_models import TextEmbeddingInput # type: ignore -from vertexai.language_models import TextEmbeddingModel # type: ignore - -from danswer.utils.logger import setup_logger -from model_server.constants import DEFAULT_COHERE_MODEL -from model_server.constants import DEFAULT_OPENAI_MODEL -from model_server.constants import DEFAULT_VERTEX_MODEL -from model_server.constants import DEFAULT_VOYAGE_MODEL -from model_server.constants import EmbeddingModelTextType -from model_server.constants import EmbeddingProvider -from model_server.utils import simple_log_function_time -from shared_configs.configs import INDEXING_ONLY -from shared_configs.enums import EmbedTextType -from shared_configs.enums import RerankerProvider -from shared_configs.model_server_models import Embedding -from shared_configs.model_server_models import EmbedRequest -from shared_configs.model_server_models import EmbedResponse -from shared_configs.model_server_models import RerankRequest -from shared_configs.model_server_models import RerankResponse -from shared_configs.utils import batch_list - - -logger = setup_logger() - -router = APIRouter(prefix="/encoder") - -_GLOBAL_MODELS_DICT: dict[str, "SentenceTransformer"] = {} -_RERANK_MODEL: Optional["CrossEncoder"] = None - -# If we are not only indexing, dont want retry very long -_RETRY_DELAY = 10 if INDEXING_ONLY else 0.1 -_RETRY_TRIES = 10 if INDEXING_ONLY else 2 - -# OpenAI only allows 2048 embeddings to be computed at once -_OPENAI_MAX_INPUT_LEN = 2048 -# Cohere allows up to 96 embeddings in a single embedding calling -_COHERE_MAX_INPUT_LEN = 96 - - -def _initialize_client( - api_key: str, provider: EmbeddingProvider, model: str | None = None -) -> Any: - if provider == EmbeddingProvider.OPENAI: - return openai.OpenAI(api_key=api_key) - elif provider == EmbeddingProvider.COHERE: - return CohereClient(api_key=api_key) - elif provider == EmbeddingProvider.VOYAGE: - return voyageai.Client(api_key=api_key) - elif provider == EmbeddingProvider.GOOGLE: - credentials = service_account.Credentials.from_service_account_info( - json.loads(api_key) - ) - project_id = json.loads(api_key)["project_id"] - vertexai.init(project=project_id, credentials=credentials) - return TextEmbeddingModel.from_pretrained(model or DEFAULT_VERTEX_MODEL) - else: - raise ValueError(f"Unsupported provider: {provider}") - - -class CloudEmbedding: - def __init__( - self, - api_key: str, - provider: EmbeddingProvider, - # Only for Google as is needed on client setup - model: str | None = None, - ) -> None: - self.provider = provider - self.client = _initialize_client(api_key, self.provider, model) - - def _embed_openai(self, texts: list[str], model: str | None) -> list[Embedding]: - if model is None: - model = DEFAULT_OPENAI_MODEL - - # OpenAI does not seem to provide truncation option, however - # the context lengths used by Danswer currently are smaller than the max token length - # for OpenAI embeddings so it's not a big deal - final_embeddings: list[Embedding] = [] - try: - for text_batch in batch_list(texts, _OPENAI_MAX_INPUT_LEN): - response = self.client.embeddings.create(input=text_batch, model=model) - final_embeddings.extend( - [embedding.embedding for embedding in response.data] - ) - return final_embeddings - except Exception as e: - error_string = ( - f"Error embedding text with OpenAI: {str(e)} \n" - f"Model: {model} \n" - f"Provider: {self.provider} \n" - f"Texts: {texts}" - ) - logger.error(error_string) - raise RuntimeError(error_string) - - def _embed_cohere( - self, texts: list[str], model: str | None, embedding_type: str - ) -> list[Embedding]: - if model is None: - model = DEFAULT_COHERE_MODEL - - final_embeddings: list[Embedding] = [] - for text_batch in batch_list(texts, _COHERE_MAX_INPUT_LEN): - # Does not use the same tokenizer as the Danswer API server but it's approximately the same - # empirically it's only off by a very few tokens so it's not a big deal - response = self.client.embed( - texts=text_batch, - model=model, - input_type=embedding_type, - truncate="END", - ) - final_embeddings.extend(response.embeddings) - return final_embeddings - - def _embed_voyage( - self, texts: list[str], model: str | None, embedding_type: str - ) -> list[Embedding]: - if model is None: - model = DEFAULT_VOYAGE_MODEL - - # Similar to Cohere, the API server will do approximate size chunking - # it's acceptable to miss by a few tokens - response = self.client.embed( - texts, - model=model, - input_type=embedding_type, - truncation=True, # Also this is default - ) - return response.embeddings - - def _embed_vertex( - self, texts: list[str], model: str | None, embedding_type: str - ) -> list[Embedding]: - if model is None: - model = DEFAULT_VERTEX_MODEL - - embeddings = self.client.get_embeddings( - [ - TextEmbeddingInput( - text, - embedding_type, - ) - for text in texts - ], - auto_truncate=True, # Also this is default - ) - return [embedding.values for embedding in embeddings] - - @retry(tries=_RETRY_TRIES, delay=_RETRY_DELAY) - def embed( - self, - *, - texts: list[str], - text_type: EmbedTextType, - model_name: str | None = None, - ) -> list[Embedding]: - try: - if self.provider == EmbeddingProvider.OPENAI: - return self._embed_openai(texts, model_name) - - embedding_type = EmbeddingModelTextType.get_type(self.provider, text_type) - if self.provider == EmbeddingProvider.COHERE: - return self._embed_cohere(texts, model_name, embedding_type) - elif self.provider == EmbeddingProvider.VOYAGE: - return self._embed_voyage(texts, model_name, embedding_type) - elif self.provider == EmbeddingProvider.GOOGLE: - return self._embed_vertex(texts, model_name, embedding_type) - else: - raise ValueError(f"Unsupported provider: {self.provider}") - except Exception as e: - raise HTTPException( - status_code=500, - detail=f"Error embedding text with {self.provider}: {str(e)}", - ) - - @staticmethod - def create( - api_key: str, provider: EmbeddingProvider, model: str | None = None - ) -> "CloudEmbedding": - logger.debug(f"Creating Embedding instance for provider: {provider}") - return CloudEmbedding(api_key, provider, model) - - -def get_embedding_model( - model_name: str, - max_context_length: int, -) -> "SentenceTransformer": - from sentence_transformers import SentenceTransformer # type: ignore - - global _GLOBAL_MODELS_DICT # A dictionary to store models - - if _GLOBAL_MODELS_DICT is None: - _GLOBAL_MODELS_DICT = {} - - if model_name not in _GLOBAL_MODELS_DICT: - logger.notice(f"Loading {model_name}") - # Some model architectures that aren't built into the Transformers or Sentence - # Transformer need to be downloaded to be loaded locally. This does not mean - # data is sent to remote servers for inference, however the remote code can - # be fairly arbitrary so only use trusted models - model = SentenceTransformer( - model_name_or_path=model_name, - trust_remote_code=True, - ) - model.max_seq_length = max_context_length - _GLOBAL_MODELS_DICT[model_name] = model - elif max_context_length != _GLOBAL_MODELS_DICT[model_name].max_seq_length: - _GLOBAL_MODELS_DICT[model_name].max_seq_length = max_context_length - - return _GLOBAL_MODELS_DICT[model_name] - - -def get_local_reranking_model( - model_name: str, -) -> CrossEncoder: - global _RERANK_MODEL - if _RERANK_MODEL is None: - logger.notice(f"Loading {model_name}") - model = CrossEncoder(model_name) - _RERANK_MODEL = model - return _RERANK_MODEL - - -@simple_log_function_time() -def embed_text( - texts: list[str], - text_type: EmbedTextType, - model_name: str | None, - max_context_length: int, - normalize_embeddings: bool, - api_key: str | None, - provider_type: EmbeddingProvider | None, - prefix: str | None, -) -> list[Embedding]: - if not all(texts): - raise ValueError("Empty strings are not allowed for embedding.") - - # Third party API based embedding model - if not texts: - raise ValueError("No texts provided for embedding.") - elif provider_type is not None: - logger.debug(f"Embedding text with provider: {provider_type}") - if api_key is None: - raise RuntimeError("API key not provided for cloud model") - - if prefix: - # This may change in the future if some providers require the user - # to manually append a prefix but this is not the case currently - raise ValueError( - "Prefix string is not valid for cloud models. " - "Cloud models take an explicit text type instead." - ) - - cloud_model = CloudEmbedding( - api_key=api_key, provider=provider_type, model=model_name - ) - embeddings = cloud_model.embed( - texts=texts, - model_name=model_name, - text_type=text_type, - ) - - # Check for None values in embeddings - if any(embedding is None for embedding in embeddings): - error_message = "Embeddings contain None values\n" - error_message += "Corresponding texts:\n" - error_message += "\n".join(texts) - raise ValueError(error_message) - - elif model_name is not None: - prefixed_texts = [f"{prefix}{text}" for text in texts] if prefix else texts - - local_model = get_embedding_model( - model_name=model_name, max_context_length=max_context_length - ) - embeddings_vectors = local_model.encode( - prefixed_texts, normalize_embeddings=normalize_embeddings - ) - embeddings = [ - embedding if isinstance(embedding, list) else embedding.tolist() - for embedding in embeddings_vectors - ] - - else: - raise ValueError( - "Either model name or provider must be provided to run embeddings." - ) - - return embeddings - - -@simple_log_function_time() -def local_rerank(query: str, docs: list[str], model_name: str) -> list[float]: - cross_encoder = get_local_reranking_model(model_name) - return cross_encoder.predict([(query, doc) for doc in docs]).tolist() # type: ignore - - -def cohere_rerank( - query: str, docs: list[str], model_name: str, api_key: str -) -> list[float]: - cohere_client = CohereClient(api_key=api_key) - response = cohere_client.rerank(query=query, documents=docs, model=model_name) - results = response.results - sorted_results = sorted(results, key=lambda item: item.index) - return [result.relevance_score for result in sorted_results] - - -@router.post("/bi-encoder-embed") -async def process_embed_request( - embed_request: EmbedRequest, -) -> EmbedResponse: - if not embed_request.texts: - raise HTTPException(status_code=400, detail="No texts to be embedded") - elif not all(embed_request.texts): - raise ValueError("Empty strings are not allowed for embedding.") - - try: - if embed_request.text_type == EmbedTextType.QUERY: - prefix = embed_request.manual_query_prefix - elif embed_request.text_type == EmbedTextType.PASSAGE: - prefix = embed_request.manual_passage_prefix - else: - prefix = None - - embeddings = embed_text( - texts=embed_request.texts, - model_name=embed_request.model_name, - max_context_length=embed_request.max_context_length, - normalize_embeddings=embed_request.normalize_embeddings, - api_key=embed_request.api_key, - provider_type=embed_request.provider_type, - text_type=embed_request.text_type, - prefix=prefix, - ) - return EmbedResponse(embeddings=embeddings) - except Exception as e: - exception_detail = f"Error during embedding process:\n{str(e)}" - logger.exception(exception_detail) - raise HTTPException(status_code=500, detail=exception_detail) - - -@router.post("/cross-encoder-scores") -async def process_rerank_request(rerank_request: RerankRequest) -> RerankResponse: - """Cross encoders can be purely black box from the app perspective""" - if INDEXING_ONLY: - raise RuntimeError("Indexing model server should not call intent endpoint") - - if not rerank_request.documents or not rerank_request.query: - raise HTTPException( - status_code=400, detail="Missing documents or query for reranking" - ) - if not all(rerank_request.documents): - raise ValueError("Empty documents cannot be reranked.") - - try: - if rerank_request.provider_type is None: - sim_scores = local_rerank( - query=rerank_request.query, - docs=rerank_request.documents, - model_name=rerank_request.model_name, - ) - return RerankResponse(scores=sim_scores) - elif rerank_request.provider_type == RerankerProvider.COHERE: - if rerank_request.api_key is None: - raise RuntimeError("Cohere Rerank Requires an API Key") - sim_scores = cohere_rerank( - query=rerank_request.query, - docs=rerank_request.documents, - model_name=rerank_request.model_name, - api_key=rerank_request.api_key, - ) - return RerankResponse(scores=sim_scores) - else: - raise ValueError(f"Unsupported provider: {rerank_request.provider_type}") - except Exception as e: - logger.exception(f"Error during reranking process:\n{str(e)}") - raise HTTPException( - status_code=500, detail="Failed to run Cross-Encoder reranking" - ) diff --git a/backend/model_server/main.py b/backend/model_server/main.py deleted file mode 100644 index 5c7979475c7..00000000000 --- a/backend/model_server/main.py +++ /dev/null @@ -1,100 +0,0 @@ -import os -import shutil -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from pathlib import Path - -import torch -import uvicorn -from fastapi import FastAPI -from transformers import logging as transformer_logging # type:ignore - -from danswer import __version__ -from danswer.utils.logger import setup_logger -from model_server.custom_models import router as custom_models_router -from model_server.custom_models import warm_up_intent_model -from model_server.encoders import router as encoders_router -from model_server.management_endpoints import router as management_router -from shared_configs.configs import INDEXING_ONLY -from shared_configs.configs import MIN_THREADS_ML_MODELS -from shared_configs.configs import MODEL_SERVER_ALLOWED_HOST -from shared_configs.configs import MODEL_SERVER_PORT - -os.environ["TOKENIZERS_PARALLELISM"] = "false" -os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" - -HF_CACHE_PATH = Path("/root/.cache/huggingface/") -TEMP_HF_CACHE_PATH = Path("/root/.cache/temp_huggingface/") - -transformer_logging.set_verbosity_error() - -logger = setup_logger() - - -def _move_files_recursively(source: Path, dest: Path, overwrite: bool = False) -> None: - """ - This moves the files from the temp huggingface cache to the huggingface cache - - We have to move each file individually because the directories might - have the same name but not the same contents and we dont want to remove - the files in the existing huggingface cache that don't exist in the temp - huggingface cache. - """ - for item in source.iterdir(): - target_path = dest / item.relative_to(source) - if item.is_dir(): - _move_files_recursively(item, target_path, overwrite) - else: - target_path.parent.mkdir(parents=True, exist_ok=True) - if target_path.exists() and not overwrite: - continue - shutil.move(str(item), str(target_path)) - - -@asynccontextmanager -async def lifespan(app: FastAPI) -> AsyncGenerator: - if torch.cuda.is_available(): - logger.notice("CUDA GPU is available") - elif torch.backends.mps.is_available(): - logger.notice("Mac MPS is available") - else: - logger.notice("GPU is not available, using CPU") - - if TEMP_HF_CACHE_PATH.is_dir(): - logger.notice("Moving contents of temp_huggingface to huggingface cache.") - _move_files_recursively(TEMP_HF_CACHE_PATH, HF_CACHE_PATH) - shutil.rmtree(TEMP_HF_CACHE_PATH, ignore_errors=True) - logger.notice("Moved contents of temp_huggingface to huggingface cache.") - - torch.set_num_threads(max(MIN_THREADS_ML_MODELS, torch.get_num_threads())) - logger.notice(f"Torch Threads: {torch.get_num_threads()}") - - if not INDEXING_ONLY: - warm_up_intent_model() - else: - logger.notice("This model server should only run document indexing.") - - yield - - -def get_model_app() -> FastAPI: - application = FastAPI( - title="Danswer Model Server", version=__version__, lifespan=lifespan - ) - - application.include_router(management_router) - application.include_router(encoders_router) - application.include_router(custom_models_router) - - return application - - -app = get_model_app() - - -if __name__ == "__main__": - logger.notice( - f"Starting Danswer Model Server on http://{MODEL_SERVER_ALLOWED_HOST}:{str(MODEL_SERVER_PORT)}/" - ) - logger.notice(f"Model Server Version: {__version__}") - uvicorn.run(app, host=MODEL_SERVER_ALLOWED_HOST, port=MODEL_SERVER_PORT) diff --git a/backend/model_server/management_endpoints.py b/backend/model_server/management_endpoints.py deleted file mode 100644 index 56640a2fa73..00000000000 --- a/backend/model_server/management_endpoints.py +++ /dev/null @@ -1,20 +0,0 @@ -import torch -from fastapi import APIRouter -from fastapi import Response - -router = APIRouter(prefix="/api") - - -@router.get("/health") -def healthcheck() -> Response: - return Response(status_code=200) - - -@router.get("/gpu-status") -def gpu_status() -> dict[str, bool | str]: - if torch.cuda.is_available(): - return {"gpu_available": True, "type": "cuda"} - elif torch.backends.mps.is_available(): - return {"gpu_available": True, "type": "mps"} - else: - return {"gpu_available": False, "type": "none"} diff --git a/backend/model_server/utils.py b/backend/model_server/utils.py deleted file mode 100644 index 0c2d6bac5dc..00000000000 --- a/backend/model_server/utils.py +++ /dev/null @@ -1,41 +0,0 @@ -import time -from collections.abc import Callable -from collections.abc import Generator -from collections.abc import Iterator -from functools import wraps -from typing import Any -from typing import cast -from typing import TypeVar - -from danswer.utils.logger import setup_logger - -logger = setup_logger() - -F = TypeVar("F", bound=Callable) -FG = TypeVar("FG", bound=Callable[..., Generator | Iterator]) - - -def simple_log_function_time( - func_name: str | None = None, - debug_only: bool = False, - include_args: bool = False, -) -> Callable[[F], F]: - def decorator(func: F) -> F: - @wraps(func) - def wrapped_func(*args: Any, **kwargs: Any) -> Any: - start_time = time.time() - result = func(*args, **kwargs) - elapsed_time_str = str(time.time() - start_time) - log_name = func_name or func.__name__ - args_str = f" args={args} kwargs={kwargs}" if include_args else "" - final_log = f"{log_name}{args_str} took {elapsed_time_str} seconds" - if debug_only: - logger.debug(final_log) - else: - logger.notice(final_log) - - return result - - return cast(F, wrapped_func) - - return decorator diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index a9cf3650e13..00000000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,14 +0,0 @@ -[tool.mypy] -plugins = "sqlalchemy.ext.mypy.plugin" -mypy_path = "$MYPY_CONFIG_FILE_DIR" -explicit_package_bases = true -disallow_untyped_defs = true - -[tool.ruff] -ignore = [] -line-length = 130 -select = [ - "E", - "F", - "W", -] diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index db3dbf8b00d..00000000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -pythonpath = . -markers = - slow: marks tests as slow \ No newline at end of file diff --git a/backend/requirements/cdk.txt b/backend/requirements/cdk.txt deleted file mode 100644 index de9d6d332fc..00000000000 --- a/backend/requirements/cdk.txt +++ /dev/null @@ -1,2 +0,0 @@ -aws-cdk-lib>=2.0.0 -constructs>=10.0.0 \ No newline at end of file diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt deleted file mode 100644 index 9427335c47d..00000000000 --- a/backend/requirements/default.txt +++ /dev/null @@ -1,76 +0,0 @@ -aiohttp==3.9.4 -alembic==1.10.4 -asyncpg==0.27.0 -atlassian-python-api==3.37.0 -beautifulsoup4==4.12.2 -boto3==1.34.84 -celery==5.3.4 -chardet==5.2.0 -dask==2023.8.1 -ddtrace==2.6.5 -distributed==2023.8.1 -fastapi==0.109.2 -fastapi-users==12.1.3 -fastapi-users-db-sqlalchemy==5.0.0 -filelock==3.12.0 -google-api-python-client==2.86.0 -google-auth-httplib2==0.1.0 -google-auth-oauthlib==1.0.0 -# GPT4All library has issues running on Macs and python:3.11.4-slim-bookworm -# will reintroduce this when library version catches up -# gpt4all==2.0.2 -httpcore==0.16.3 -httpx[http2]==0.23.3 -httpx-oauth==0.11.2 -huggingface-hub==0.20.1 -jira==3.5.1 -jsonref==1.1.0 -langchain==0.1.17 -langchain-community==0.0.36 -langchain-core==0.1.50 -langchain-text-splitters==0.0.1 -litellm==1.43.18 -llama-index==0.9.45 -Mako==1.2.4 -msal==1.26.0 -nltk==3.8.1 -Office365-REST-Python-Client==2.5.9 -oauthlib==3.2.2 -openai==1.41.1 -openpyxl==3.1.2 -playwright==1.41.2 -psutil==5.9.5 -psycopg2-binary==2.9.9 -pycryptodome==3.19.1 -pydantic==2.8.2 -PyGithub==1.58.2 -python-dateutil==2.8.2 -python-gitlab==3.9.0 -python-pptx==0.6.23 -pypdf==3.17.0 -pytest-mock==3.12.0 -pytest-playwright==0.3.2 -python-docx==1.1.0 -python-dotenv==1.0.0 -python-multipart==0.0.7 -pywikibot==9.0.0 -requests==2.32.2 -requests-oauthlib==1.3.1 -retry==0.9.2 # This pulls in py which is in CVE-2022-42969, must remove py from image -rfc3986==1.5.0 -rt==3.1.2 -simple-salesforce==1.12.6 -slack-sdk==3.20.2 -SQLAlchemy[mypy]==2.0.15 -starlette==0.36.3 -supervisor==4.2.5 -tiktoken==0.7.0 -timeago==1.0.16 -transformers==4.39.2 -uvicorn==0.21.1 -zulip==0.8.2 -hubspot-api-client==8.1.0 -zenpy==2.0.41 -dropbox==11.36.2 -boto3-stubs[s3]==1.34.133 -ultimate_sitemap_parser==0.5 diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt deleted file mode 100644 index 881920af7f2..00000000000 --- a/backend/requirements/dev.txt +++ /dev/null @@ -1,23 +0,0 @@ -black==23.3.0 -celery-types==0.19.0 -mypy-extensions==1.0.0 -mypy==1.8.0 -pre-commit==3.2.2 -pytest==7.4.4 -reorder-python-imports==3.9.0 -ruff==0.0.286 -types-PyYAML==6.0.12.11 -types-beautifulsoup4==4.12.0.3 -types-html5lib==1.1.11.13 -types-oauthlib==3.2.0.9 -types-setuptools==68.0.0.3 -types-passlib==1.7.7.20240106 -types-psutil==5.9.5.17 -types-psycopg2==2.9.21.10 -types-python-dateutil==2.8.19.13 -types-pytz==2023.3.1.1 -types-regex==2023.3.23.1 -types-requests==2.28.11.17 -types-retry==0.9.9.3 -types-urllib3==1.26.25.11 -boto3-stubs[s3]==1.34.133 \ No newline at end of file diff --git a/backend/requirements/ee.txt b/backend/requirements/ee.txt deleted file mode 100644 index 0717e3a67e7..00000000000 --- a/backend/requirements/ee.txt +++ /dev/null @@ -1 +0,0 @@ -python3-saml==1.15.0 diff --git a/backend/requirements/model_server.txt b/backend/requirements/model_server.txt deleted file mode 100644 index 0fb0e74b67b..00000000000 --- a/backend/requirements/model_server.txt +++ /dev/null @@ -1,14 +0,0 @@ -cohere==5.6.1 -einops==0.8.0 -fastapi==0.109.2 -google-cloud-aiplatform==1.58.0 -numpy==1.26.4 -openai==1.41.1 -pydantic==2.8.2 -retry==0.9.2 -safetensors==0.4.2 -sentence-transformers==2.6.1 -torch==2.0.1 -transformers==4.39.2 -uvicorn==0.21.1 -voyageai==0.2.3 diff --git a/backend/scripts/api_inference_sample.py b/backend/scripts/api_inference_sample.py deleted file mode 100644 index 9a93fdb73dd..00000000000 --- a/backend/scripts/api_inference_sample.py +++ /dev/null @@ -1,87 +0,0 @@ -# This file is used to demonstrate how to use the backend APIs directly -# In this case, the equivalent of asking a question in Danswer Chat in a new chat session -import argparse -import json -import os - -import requests - - -def create_new_chat_session(danswer_url: str, api_key: str | None) -> int: - headers = {"Authorization": f"Bearer {api_key}"} if api_key else None - session_endpoint = danswer_url + "/api/chat/create-chat-session" - - response = requests.post( - session_endpoint, - headers=headers, - json={"persona_id": 0}, # Global default Persona/Assistant ID - ) - response.raise_for_status() - - new_session_id = response.json()["chat_session_id"] - return new_session_id - - -def process_question(danswer_url: str, question: str, api_key: str | None) -> None: - message_endpoint = danswer_url + "/api/chat/send-message" - - chat_session_id = create_new_chat_session(danswer_url, api_key) - - headers = {"Authorization": f"Bearer {api_key}"} if api_key else None - - data = { - "message": question, - "chat_session_id": chat_session_id, - "parent_message_id": None, - "file_descriptors": [], - # Default Question Answer prompt - "prompt_id": 0, - # Not specifying any specific docs to chat to, we want to run a search - "search_doc_ids": None, - "retrieval_options": { - "run_search": "always", - "real_time": True, - "enable_auto_detect_filters": False, - # No filters applied, check all sources, document-sets, time ranges, etc. - "filters": {}, - }, - } - - with requests.post(message_endpoint, headers=headers, json=data) as response: - response.raise_for_status() - - for packet in response.iter_lines(): - response_text = json.loads(packet.decode()) - # Can also check "top_documents" to capture the streamed search results - # that include the highest matching documents to the query - # or check "message_id" to get the message_id used as parent_message_id - # to create follow-up messages - new_token = response_text.get("answer_piece") - - if new_token: - print(new_token, end="", flush=True) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Sample API Usage") - parser.add_argument( - "--danswer-url", - type=str, - default="http://localhost:80", - help="Danswer URL, should point to Danswer nginx.", - ) - parser.add_argument( - "--test-question", - type=str, - default="What is Danswer?", - help="Test question for new Chat Session.", - ) - - # Not needed if Auth is disabled - # Or for Danswer MIT API key must be replaced with session cookie - api_key = os.environ.get("DANSWER_API_KEY") - - args = parser.parse_args() - process_question( - danswer_url=args.danswer_url, question=args.test_question, api_key=api_key - ) diff --git a/backend/scripts/dev_run_background_jobs.py b/backend/scripts/dev_run_background_jobs.py deleted file mode 100644 index 3a917fbed1a..00000000000 --- a/backend/scripts/dev_run_background_jobs.py +++ /dev/null @@ -1,105 +0,0 @@ -import argparse -import os -import subprocess -import threading - - -def monitor_process(process_name: str, process: subprocess.Popen) -> None: - assert process.stdout is not None - - while True: - output = process.stdout.readline() - - if output: - print(f"{process_name}: {output.strip()}") - - if process.poll() is not None: - break - - -def run_jobs(exclude_indexing: bool) -> None: - cmd_worker = [ - "celery", - "-A", - "ee.danswer.background.celery.celery_app", - "worker", - "--pool=threads", - "--concurrency=6", - "--loglevel=INFO", - ] - - cmd_beat = [ - "celery", - "-A", - "ee.danswer.background.celery.celery_app", - "beat", - "--loglevel=INFO", - ] - - worker_process = subprocess.Popen( - cmd_worker, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - beat_process = subprocess.Popen( - cmd_beat, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True - ) - - worker_thread = threading.Thread( - target=monitor_process, args=("WORKER", worker_process) - ) - beat_thread = threading.Thread(target=monitor_process, args=("BEAT", beat_process)) - - worker_thread.start() - beat_thread.start() - - if not exclude_indexing: - update_env = os.environ.copy() - update_env["PYTHONPATH"] = "." - cmd_indexing = ["python", "danswer/background/update.py"] - - indexing_process = subprocess.Popen( - cmd_indexing, - env=update_env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - indexing_thread = threading.Thread( - target=monitor_process, args=("INDEXING", indexing_process) - ) - - indexing_thread.start() - indexing_thread.join() - try: - update_env = os.environ.copy() - update_env["PYTHONPATH"] = "." - cmd_perm_sync = ["python", "ee/danswer/background/permission_sync.py"] - - indexing_process = subprocess.Popen( - cmd_perm_sync, - env=update_env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - perm_sync_thread = threading.Thread( - target=monitor_process, args=("INDEXING", indexing_process) - ) - perm_sync_thread.start() - perm_sync_thread.join() - except Exception: - pass - - worker_thread.join() - beat_thread.join() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Run background jobs.") - parser.add_argument( - "--no-indexing", action="store_true", help="Do not run indexing process" - ) - args = parser.parse_args() - - run_jobs(args.no_indexing) diff --git a/backend/scripts/force_delete_connector_by_id.py b/backend/scripts/force_delete_connector_by_id.py deleted file mode 100755 index 118a4dfa4b4..00000000000 --- a/backend/scripts/force_delete_connector_by_id.py +++ /dev/null @@ -1,218 +0,0 @@ -import argparse -import os -import sys - -from sqlalchemy import delete -from sqlalchemy.orm import Session - -from danswer.db.enums import ConnectorCredentialPairStatus - -# Modify sys.path -current_dir = os.path.dirname(os.path.abspath(__file__)) -parent_dir = os.path.dirname(current_dir) -sys.path.append(parent_dir) - -# pylint: disable=E402 -# flake8: noqa: E402 - -# Now import Danswer modules -from danswer.db.models import ( - DocumentSet__ConnectorCredentialPair, - UserGroup__ConnectorCredentialPair, -) -from danswer.db.connector import fetch_connector_by_id -from danswer.db.document import get_documents_for_connector_credential_pair -from danswer.db.index_attempt import ( - delete_index_attempts, - cancel_indexing_attempts_for_ccpair, -) -from danswer.db.models import ConnectorCredentialPair -from danswer.document_index.interfaces import DocumentIndex -from danswer.utils.logger import setup_logger -from danswer.configs.constants import DocumentSource -from danswer.db.connector_credential_pair import ( - get_connector_credential_pair_from_id, - get_connector_credential_pair, -) -from danswer.db.engine import get_session_context_manager -from danswer.document_index.factory import get_default_document_index -from danswer.file_store.file_store import get_default_file_store -from danswer.document_index.document_index_utils import get_both_index_names -from danswer.db.document import delete_documents_complete__no_commit - -# pylint: enable=E402 -# flake8: noqa: E402 - - -logger = setup_logger() - -_DELETION_BATCH_SIZE = 1000 - - -def _unsafe_deletion( - db_session: Session, - document_index: DocumentIndex, - cc_pair: ConnectorCredentialPair, - pair_id: int, -) -> int: - connector_id = cc_pair.connector_id - credential_id = cc_pair.credential_id - - num_docs_deleted = 0 - - # Gather and delete documents - while True: - documents = get_documents_for_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - limit=_DELETION_BATCH_SIZE, - ) - if not documents: - break - - document_ids = [document.id for document in documents] - document_index.delete(doc_ids=document_ids) - delete_documents_complete__no_commit( - db_session=db_session, - document_ids=document_ids, - ) - - num_docs_deleted += len(documents) - - # Delete index attempts - delete_index_attempts( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - - # Delete document sets - stmt = delete(DocumentSet__ConnectorCredentialPair).where( - DocumentSet__ConnectorCredentialPair.connector_credential_pair_id == pair_id - ) - db_session.execute(stmt) - - # delete user group associations - stmt = delete(UserGroup__ConnectorCredentialPair).where( - UserGroup__ConnectorCredentialPair.cc_pair_id == pair_id - ) - db_session.execute(stmt) - - # need to flush to avoid foreign key violations - db_session.flush() - - # delete the actual connector credential pair - stmt = delete(ConnectorCredentialPair).where( - ConnectorCredentialPair.connector_id == connector_id, - ConnectorCredentialPair.credential_id == credential_id, - ) - db_session.execute(stmt) - - # Delete Connector - connector = fetch_connector_by_id( - db_session=db_session, - connector_id=connector_id, - ) - if not connector or not len(connector.credentials): - logger.debug("Found no credentials left for connector, deleting connector") - db_session.delete(connector) - db_session.commit() - - logger.notice( - "Successfully deleted connector_credential_pair with connector_id:" - f" '{connector_id}' and credential_id: '{credential_id}'. Deleted {num_docs_deleted} docs." - ) - return num_docs_deleted - - -def _delete_connector(cc_pair_id: int, db_session: Session) -> None: - user_input = input( - "DO NOT USE THIS UNLESS YOU KNOW WHAT YOU ARE DOING. \ - IT MAY CAUSE ISSUES with your Danswer instance! \ - Are you SURE you want to continue? (enter 'Y' to continue): " - ) - if user_input != "Y": - logger.notice(f"You entered {user_input}. Exiting!") - return - - logger.notice("Getting connector credential pair") - cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session) - - if not cc_pair: - logger.error(f"Connector credential pair with ID {cc_pair_id} not found") - return - - if cc_pair.status == ConnectorCredentialPairStatus.ACTIVE: - logger.error( - f"Connector {cc_pair.connector.name} is active, cannot continue. \ - Please navigate to the connector and pause before attempting again" - ) - return - - connector_id = cc_pair.connector_id - credential_id = cc_pair.credential_id - - if cc_pair is None: - logger.error( - f"Connector with ID '{connector_id}' and credential ID " - f"'{credential_id}' does not exist. Has it already been deleted?", - ) - return - - logger.notice("Cancelling indexing attempt for the connector") - cancel_indexing_attempts_for_ccpair( - cc_pair_id=cc_pair_id, db_session=db_session, include_secondary_index=True - ) - - validated_cc_pair = get_connector_credential_pair( - db_session=db_session, - connector_id=connector_id, - credential_id=credential_id, - ) - - if not validated_cc_pair: - logger.error( - f"Cannot run deletion attempt - connector_credential_pair with Connector ID: " - f"{connector_id} and Credential ID: {credential_id} does not exist." - ) - - file_names: list[str] = ( - cc_pair.connector.connector_specific_config["file_locations"] - if cc_pair.connector.source == DocumentSource.FILE - else [] - ) - try: - logger.notice("Deleting information from Vespa and Postgres") - curr_ind_name, sec_ind_name = get_both_index_names(db_session) - document_index = get_default_document_index( - primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name - ) - - files_deleted_count = _unsafe_deletion( - db_session=db_session, - document_index=document_index, - cc_pair=cc_pair, - pair_id=cc_pair_id, - ) - logger.notice(f"Deleted {files_deleted_count} files!") - - except Exception as e: - logger.error(f"Failed to delete connector due to {e}") - - if file_names: - logger.notice("Deleting stored files!") - file_store = get_default_file_store(db_session) - for file_name in file_names: - logger.notice(f"Deleting file {file_name}") - file_store.delete_file(file_name) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Delete a connector by its ID") - parser.add_argument( - "connector_id", type=int, help="The ID of the connector to delete" - ) - args = parser.parse_args() - with get_session_context_manager() as db_session: - _delete_connector(args.connector_id, db_session) diff --git a/backend/scripts/reset_indexes.py b/backend/scripts/reset_indexes.py deleted file mode 100644 index 4ec8d9bf312..00000000000 --- a/backend/scripts/reset_indexes.py +++ /dev/null @@ -1,36 +0,0 @@ -# This file is purely for development use, not included in any builds -import os -import sys - -import requests - -# makes it so `PYTHONPATH=.` is not required when running this script -parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.append(parent_dir) - -from danswer.configs.app_configs import DOCUMENT_INDEX_NAME # noqa: E402 -from danswer.document_index.vespa.index import DOCUMENT_ID_ENDPOINT # noqa: E402 -from danswer.utils.logger import setup_logger # noqa: E402 - -logger = setup_logger() - - -def wipe_vespa_index() -> None: - continuation = None - should_continue = True - while should_continue: - params = {"selection": "true", "cluster": DOCUMENT_INDEX_NAME} - if continuation: - params = {**params, "continuation": continuation} - response = requests.delete(DOCUMENT_ID_ENDPOINT, params=params) - response.raise_for_status() - - response_json = response.json() - print(response_json) - - continuation = response_json.get("continuation") - should_continue = bool(continuation) - - -if __name__ == "__main__": - wipe_vespa_index() diff --git a/backend/scripts/reset_postgres.py b/backend/scripts/reset_postgres.py deleted file mode 100644 index 2df4646169e..00000000000 --- a/backend/scripts/reset_postgres.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -import sys - -import psycopg2 -from sqlalchemy.orm import Session - -from danswer.db.engine import get_sqlalchemy_engine - -# makes it so `PYTHONPATH=.` is not required when running this script -parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.append(parent_dir) - -from danswer.configs.app_configs import POSTGRES_DB # noqa: E402 -from danswer.configs.app_configs import POSTGRES_HOST # noqa: E402 -from danswer.configs.app_configs import POSTGRES_PASSWORD # noqa: E402 -from danswer.configs.app_configs import POSTGRES_PORT # noqa: E402 -from danswer.configs.app_configs import POSTGRES_USER # noqa: E402 -from danswer.db.credentials import create_initial_public_credential # noqa: E402 - - -def wipe_all_rows(database: str) -> None: - conn = psycopg2.connect( - dbname=database, - user=POSTGRES_USER, - password=POSTGRES_PASSWORD, - host=POSTGRES_HOST, - port=POSTGRES_PORT, - ) - cur = conn.cursor() - - # Disable triggers to prevent foreign key constraints from being checked - cur.execute("SET session_replication_role = 'replica';") - - # Fetch all table names in the current database - cur.execute( - """ - SELECT tablename - FROM pg_tables - WHERE schemaname = 'public' - """ - ) - - tables = cur.fetchall() - - for table in tables: - table_name = table[0] - - # Don't touch migration history - if table_name == "alembic_version": - continue - - print(f"Deleting all rows from {table_name}...") - cur.execute(f'DELETE FROM "{table_name}"') - - # Re-enable triggers - cur.execute("SET session_replication_role = 'origin';") - - conn.commit() - cur.close() - conn.close() - print("Finished wiping all rows.") - - -if __name__ == "__main__": - print("Cleaning up all Danswer tables") - wipe_all_rows(POSTGRES_DB) - with Session(get_sqlalchemy_engine(), expire_on_commit=False) as db_session: - create_initial_public_credential(db_session) - print("To keep data consistent, it's best to wipe the document index as well.") - print( - "To be safe, it's best to restart the Danswer services (API Server and Background Tasks" - ) diff --git a/backend/scripts/restart_containers.sh b/backend/scripts/restart_containers.sh deleted file mode 100755 index c60d1905eb5..00000000000 --- a/backend/scripts/restart_containers.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash - -# Usage of the script with optional volume arguments -# ./restart_containers.sh [vespa_volume] [postgres_volume] - -VESPA_VOLUME=${1:-""} # Default is empty if not provided -POSTGRES_VOLUME=${2:-""} # Default is empty if not provided - -# Stop and remove the existing containers -echo "Stopping and removing existing containers..." -docker stop danswer_postgres danswer_vespa -docker rm danswer_postgres danswer_vespa - -# Start the PostgreSQL container with optional volume -echo "Starting PostgreSQL container..." -if [[ -n "$POSTGRES_VOLUME" ]]; then - docker run -p 5432:5432 --name danswer_postgres -e POSTGRES_PASSWORD=password -d -v $POSTGRES_VOLUME:/var/lib/postgresql/data postgres -else - docker run -p 5432:5432 --name danswer_postgres -e POSTGRES_PASSWORD=password -d postgres -fi - -# Start the Vespa container with optional volume -echo "Starting Vespa container..." -if [[ -n "$VESPA_VOLUME" ]]; then - docker run --detach --name danswer_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 -v $VESPA_VOLUME:/opt/vespa/var vespaengine/vespa:8 -else - docker run --detach --name danswer_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 vespaengine/vespa:8 -fi - -# Ensure alembic runs in the correct directory -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -PARENT_DIR="$(dirname "$SCRIPT_DIR")" -cd "$PARENT_DIR" - -# Give Postgres a second to start -sleep 1 - -# Run Alembic upgrade -echo "Running Alembic migration..." -alembic upgrade head - -echo "Containers restarted and migration completed." diff --git a/backend/scripts/save_load_state.py b/backend/scripts/save_load_state.py deleted file mode 100644 index 94431e8c813..00000000000 --- a/backend/scripts/save_load_state.py +++ /dev/null @@ -1,138 +0,0 @@ -# This file is purely for development use, not included in any builds -# Remember to first to send over the schema information (run API Server) -import argparse -import json -import os -import subprocess - -import requests - -from alembic import command -from alembic.config import Config -from danswer.configs.app_configs import POSTGRES_DB -from danswer.configs.app_configs import POSTGRES_HOST -from danswer.configs.app_configs import POSTGRES_PASSWORD -from danswer.configs.app_configs import POSTGRES_PORT -from danswer.configs.app_configs import POSTGRES_USER -from danswer.document_index.vespa.index import DOCUMENT_ID_ENDPOINT -from danswer.utils.logger import setup_logger - -logger = setup_logger() - - -def save_postgres(filename: str, container_name: str) -> None: - logger.notice("Attempting to take Postgres snapshot") - cmd = f"docker exec {container_name} pg_dump -U {POSTGRES_USER} -h {POSTGRES_HOST} -p {POSTGRES_PORT} -W -F t {POSTGRES_DB}" - with open(filename, "w") as file: - subprocess.run( - cmd, - shell=True, - check=True, - stdout=file, - text=True, - input=f"{POSTGRES_PASSWORD}\n", - ) - - -def load_postgres(filename: str, container_name: str) -> None: - logger.notice("Attempting to load Postgres snapshot") - try: - alembic_cfg = Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - except Exception as e: - logger.error(f"Alembic upgrade failed: {e}") - - host_file_path = os.path.abspath(filename) - - copy_cmd = f"docker cp {host_file_path} {container_name}:/tmp/" - subprocess.run(copy_cmd, shell=True, check=True) - - container_file_path = f"/tmp/{os.path.basename(filename)}" - - restore_cmd = ( - f"docker exec {container_name} pg_restore --clean -U {POSTGRES_USER} " - f"-h localhost -p {POSTGRES_PORT} -d {POSTGRES_DB} -1 -F t {container_file_path}" - ) - subprocess.run(restore_cmd, shell=True, check=True) - - -def save_vespa(filename: str) -> None: - logger.notice("Attempting to take Vespa snapshot") - continuation = "" - params = {} - doc_jsons: list[dict] = [] - while continuation is not None: - if continuation: - params = {"continuation": continuation} - response = requests.get(DOCUMENT_ID_ENDPOINT, params=params) - response.raise_for_status() - found = response.json() - continuation = found.get("continuation") - docs = found["documents"] - for doc in docs: - doc_json = {"update": doc["id"], "create": True, "fields": doc["fields"]} - doc_jsons.append(doc_json) - - with open(filename, "w") as jsonl_file: - for doc in doc_jsons: - json_str = json.dumps(doc) - jsonl_file.write(json_str + "\n") - - -def load_vespa(filename: str) -> None: - headers = {"Content-Type": "application/json"} - with open(filename, "r") as f: - for line in f: - new_doc = json.loads(line.strip()) - doc_id = new_doc["update"].split("::")[-1] - response = requests.post( - DOCUMENT_ID_ENDPOINT + "/" + doc_id, - headers=headers, - json=new_doc, - ) - response.raise_for_status() - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Danswer checkpoint saving and loading." - ) - parser.add_argument( - "--save", action="store_true", help="Save Danswer state to directory." - ) - parser.add_argument( - "--load", action="store_true", help="Load Danswer state from save directory." - ) - parser.add_argument( - "--postgres_container_name", - type=str, - default="danswer-stack-relational_db-1", - help="Name of the postgres container to dump", - ) - parser.add_argument( - "--checkpoint_dir", - type=str, - default=os.path.join("..", "danswer_checkpoint"), - help="A directory to store temporary files to.", - ) - - args = parser.parse_args() - checkpoint_dir = args.checkpoint_dir - postgres_container = args.postgres_container_name - - if not os.path.exists(checkpoint_dir): - os.makedirs(checkpoint_dir) - - if not args.save and not args.load: - raise ValueError("Must specify --save or --load") - - if args.load: - load_postgres( - os.path.join(checkpoint_dir, "postgres_snapshot.tar"), postgres_container - ) - load_vespa(os.path.join(checkpoint_dir, "vespa_snapshot.jsonl")) - else: - save_postgres( - os.path.join(checkpoint_dir, "postgres_snapshot.tar"), postgres_container - ) - save_vespa(os.path.join(checkpoint_dir, "vespa_snapshot.jsonl")) diff --git a/backend/scripts/sources_selection_analysis.py b/backend/scripts/sources_selection_analysis.py deleted file mode 100644 index b71a4eb3e67..00000000000 --- a/backend/scripts/sources_selection_analysis.py +++ /dev/null @@ -1,733 +0,0 @@ -import argparse -import json -import os -import sys -import time -from datetime import datetime -from os import listdir -from os.path import isfile -from os.path import join -from typing import Optional - -import requests - -parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.append(parent_dir) - -from danswer.configs.app_configs import DOCUMENT_INDEX_NAME # noqa: E402 -from danswer.configs.constants import SOURCE_TYPE # noqa: E402 - -ANALYSIS_FOLDER = f"{parent_dir}/scripts/.analysisfiles/" - - -def color_output( - text: str, - model: Optional[str] = None, - text_color: str = "white", - bg_color: str = "black", - text_style: str = "normal", - text_prefix: str = "", -) -> None: - """Color and print a text - - Args: - text (str): The text to display - model (str, optional): A pre-defined output model. Defaults to None. - text_color (str, optional): Define the text color. Defaults to "white". - bg_color (str, optional): Define the background color. Defaults to "black". - text_style (str, optional): Define the text style. Defaults to "normal". - text_prefix (str, optional): Set a text prefix. Defaults to "". - """ - if model: - if model == "alert": - text_color = "black" - bg_color = "red" - text_style = "bold" - elif model == "critical": - text_prefix = "CRITICAL: " - text_color = "white" - bg_color = "red" - text_style = "bold" - elif model == "note": - text_color = "yellow" - bg_color = "transparent" - text_style = "normal" - elif model == "info": - text_prefix = "INFO: " - text_color = "black" - bg_color = "yellow" - text_style = "bold" - elif model == "info2": - text_prefix = "INFO: " - text_color = "black" - bg_color = "white" - text_style = "bold" - elif model == "valid": - text_prefix = "INFO: " - text_color = "white" - bg_color = "green" - text_style = "bold" - elif model == "debug": - text_prefix = "DEBUG: " - text_color = "blue" - bg_color = "transparent" - text_style = "bold" - - text_colors = { - "black": 30, - "red": 31, - "green": 32, - "yellow": 33, - "blue": 34, - "purple": 35, - "cian": 36, - "white": 37, - } - bg_colors = { - "black": 40, - "red": 41, - "green": 42, - "yellow": 43, - "blue": 44, - "purple": 45, - "cian": 46, - "white": 47, - "transparent": 49, - } - text_styles = { - "normal": 0, - "bold": 1, - "light": 2, - "italicized": 3, - "underlined": 4, - "blink": 5, - } - print( - f"\033[{text_styles[text_style]};{text_colors[text_color]};{bg_colors[bg_color]}m {text_prefix} {text} \033[0;0m" - ) - - -class CompareAnalysis: - def __init__( - self, query: str, previous_content: dict, new_content: dict, threshold: float - ) -> None: - """Make the comparison between 2 analysis for a specific query - - Args: - query (str): The analysed query - previous_content (dict): The previous analysis content for the selected query - new_content (dict): The new analysis content for the selected query - threshold (float): The minimum difference (percentage) between scores to raise an anomaly - """ - self._query = query - self._previous_content = previous_content - self._new_content = new_content - self._threshold = threshold - - def _identify_diff(self, content_key: str) -> list[dict]: - """Try to identify differences between the two analysis based - on the selected analysis key. - - Args: - content_key (str): The analysis item's key to compare the versions. - Examples: score / document_id - - Returns: - list[dict]: List of dict representing the information regarding the difference - Format: { - "previous_rank": XX, - "new_rank": XX, - "document_id": XXXX, - "previous_score": XX, - "new_score": XX, - "score_change_pct": XX - } - """ - changes = [] - - previous_content = { - k: v[content_key] for k, v in self._previous_content.items() - } - new_content = {k: v[content_key] for k, v in self._new_content.items()} - - if previous_content != new_content: - for pos, data in previous_content.items(): - if data != new_content[pos]: - try: - score_change_pct = round( - ( - abs( - self._new_content[pos]["score"] - - self._previous_content[pos]["score"] - ) - / self._new_content[pos]["score"] - ) - * 100.0, - 2, - ) - except ZeroDivisionError: - score_change_pct = 0 - - changes.append( - { - "previous_rank": pos, - "new_rank": pos - if content_key == "score" - else { - "x": k for k, v in new_content.items() if v == data - }.get("x", "not_ranked"), - "document_id": self._previous_content[pos]["document_id"], - "previous_score": self._previous_content[pos]["score"], - "new_score": self._new_content[pos]["score"], - "score_change_pct": score_change_pct, - } - ) - return changes - - def check_config_changes(self, previous_doc_rank: int, new_doc_rank: int) -> None: - """Try to identify possible reasons why a change has been detected by - checking the latest document update date or the boost value. - - Args: - previous_doc_rank (int): The document rank for the previous analysis - new_doc_rank (int): The document rank for the new analysis - """ - if new_doc_rank == "not_ranked": - color_output( - ( - "NOTE: The document is missing in the 'current' analysis file. " - "Unable to identify more details about the reason for the change." - ), - model="note", - ) - return None - - if ( - self._previous_content[previous_doc_rank]["boost"] - != self._new_content[new_doc_rank]["boost"] - ): - color_output( - "NOTE: The 'boost' value has been changed which (maybe) explains the change.", - model="note", - ) - color_output( - ( - f"Previously it was '{self._previous_content[previous_doc_rank]['boost']}' " - f"and now is set to '{self._new_content[new_doc_rank]['boost']}'" - ), - model="note", - ) - if ( - self._previous_content[previous_doc_rank]["updated_at"] - != self._new_content[new_doc_rank]["updated_at"] - ): - color_output("NOTE: The document seems to have been updated.", model="note") - color_output( - ( - f"Previously the updated date was '{self._previous_content[previous_doc_rank]['updated_at']}' " - f"and now is '{self._new_content[new_doc_rank]['updated_at']}'" - ), - model="note", - ) - - def check_documents_score(self) -> bool: - """Check if the scores have changed between analysis. - - Returns: - bool: True if at least one change has been detected. False otherwise. - """ - color_output("Checking documents Score....", model="info") - color_output( - f"Differences under '{self._threshold}%' are ignored (based on the '--threshold' argument)", - model="info", - ) - - if diff := [ - x - for x in self._identify_diff("score") - if x["score_change_pct"] > self._threshold - ]: - color_output("<<<<< Changes detected >>>>>", model="alert") - for change in diff: - color_output("-" * 100) - color_output( - ( - f"The document '{change['document_id']}' (rank: {change['previous_rank']}) " - f"score has a changed of {change['score_change_pct']}%" - ) - ) - color_output(f"previous score: {change['previous_score']}") - color_output(f"current score: {change['new_score']}") - self.check_config_changes(change["previous_rank"], change["new_rank"]) - - color_output("<<<<< End of changes >>>>>", model="alert") - color_output(f"Number of changes detected {len(diff)}", model="info") - else: - color_output("No change detected", model="valid") - color_output("Documents Score check completed.", model="info") - - return False if diff else True - - def check_documents_order(self) -> bool: - """Check if the selected documents are the same and in the same order. - - Returns: - bool: True if at least one change has been detected. False otherwise. - """ - color_output("Checking documents Order....", model="info") - - if diff := self._identify_diff("document_id"): - color_output("<<<<< Changes detected >>>>>", model="alert") - for change in diff: - color_output("-" * 100) - color_output( - ( - f"The document '{change['document_id']}' was at a rank " - f"'{change['previous_rank']}' but now is at rank '{change['new_rank']}'" - ) - ) - color_output(f"previous score: {change['previous_score']}") - color_output(f"current score: {change['new_score']}") - self.check_config_changes(change["previous_rank"], change["new_rank"]) - color_output("<<<<< End of changes >>>>>", model="alert") - color_output(f"Number of changes detected {len(diff)}", model="info") - - else: - color_output("No change detected", model="valid") - color_output("Documents order check completed.", model="info") - - return False if diff else True - - def __call__(self) -> None: - """Manage the analysis process""" - if not self.check_documents_order(): - color_output( - "Skipping other checks as the documents order has changed", model="info" - ) - return None - - self.check_documents_score() - - -class SelectionAnalysis: - def __init__( - self, - exectype: str, - analysisfiles: list = [], - queries: list = [], - threshold: float = 0.0, - web_port: int = 3000, - auth_cookie: str = "", - wait: int = 10, - ) -> None: - """ - - Args: - exectype (str): The execution mode (new or compare) - analysisfiles (list, optional): List of analysis files to compare or if only one, to use as the base. Defaults to []. - Requiered only by the 'compare' mode - queries (list, optional): The queries to analysed. Defaults to []. - Required only by the 'new' mode - threshold (float, optional): The minimum difference (percentage) between scores to raise an anomaly - web_port (int, optional): The port of the UI. Defaults to 3000 (local exec port) - auth_cookie (str, optional): The Auth cookie value (fastapiusersauth). Defaults to None. - wait (int, optional): The waiting time (in seconds) to respect between queries. - It is helpful to avoid hitting the Generative AI rate limiting. - """ - self._exectype = exectype - self._analysisfiles = analysisfiles - self._queries = queries - self._threshold = threshold - self._web_port = web_port - self._auth_cookie = auth_cookie - self._wait = wait - - def _wait_between_queries(self, query: str) -> None: - """If there are remaining queries, waits for the defined time. - - Args: - query (str): The latest executed query - """ - if query != self._queries[-1]: - color_output(f"Next query in {self._wait} seconds", model="debug") - time.sleep(self._wait) - - def prepare(self) -> bool: - """Create the requirements to execute this script - - Returns: - bool: True if all the requirements are setup. False otherwise - """ - try: - os.makedirs(ANALYSIS_FOLDER, exist_ok=True) - return True - except Exception as e: - color_output(f"Unable to setup the requirements: {e}", model="critical") - return False - - def do_request(self, query: str) -> dict: - """Request the Danswer API - - Args: - query (str): A query - - Returns: - dict: The Danswer API response content - """ - cookies = {"fastapiusersauth": self._auth_cookie} if self._auth_cookie else {} - - endpoint = f"http://127.0.0.1:{self._web_port}/api/direct-qa" - query_json = { - "query": query, - "collection": DOCUMENT_INDEX_NAME, - "filters": {SOURCE_TYPE: None}, - "enable_auto_detect_filters": True, - "search_type": "hybrid", - "offset": 0, - "favor_recent": True, - } - try: - response = requests.post(endpoint, json=query_json, cookies=cookies) - if response.status_code != 200: - color_output( - ( - "something goes wrong while requesting the Danswer API " - f"for the query '{query}': {response.text}" - ), - model="critical", - ) - sys.exit(1) - except Exception as e: - color_output( - f"Unable to request the Danswer API for the query '{query}': {e}", - model="critical", - ) - sys.exit(1) - - return json.loads(response.content) - - def get_analysis_files(self) -> list[str]: - """Returns the list of existing analysis files. - - Returns: - list[str]: List of filename - """ - return [f for f in listdir(ANALYSIS_FOLDER) if isfile(join(ANALYSIS_FOLDER, f))] - - def get_analysis_file_content(self, filename: str) -> list[dict]: - """Returns the content of an analysis file - - Args: - filename (str): The analysis filename - - Returns: - list[dict]: Content of the selected file - """ - with open(f"{ANALYSIS_FOLDER}{filename}", "r") as f: - return json.load(f) - - def extract_content(self, contents: dict) -> dict: - """Extract the content returns by the Danswer API - - Args: - contents (dict): The danswer response content - - Returns: - dict: Data regarding the selected sources document - """ - return { - pos: doc - for pos, doc in enumerate( - sorted( - contents["top_ranked_docs"], key=lambda d: d["score"], reverse=True - )[:5] - ) - } - - def save_analysisfile(self, content: list[dict]) -> Optional[str]: - """Save the extracted content - - Args: - content (list[dict]): The content to save - - Returns: - str: The filname - """ - filename = datetime.now().strftime("%Y_%m_%d-%I_%M_%S") - analysis_file = f"{ANALYSIS_FOLDER}{filename}.json" - - try: - with open(analysis_file, "w") as f: - json.dump(content, f, indent=4) - except Exception as e: - color_output(f"Unable to create the analysis file: {e}", model="critical") - return None - - color_output(f"Analysis file created: {analysis_file}", model="debug") - return analysis_file - - def new(self) -> Optional[str]: - """Manage the process to create a new analysis file - based on the submitted queries - - Returns: - str: The new filename with the analysis content - """ - if not self._queries: - color_output("Missing queries", model="critical") - sys.exit(1) - - color_output("Generating a new analysis file...", model="debug") - analysisfile = [] - - for query in self._queries: - color_output(f"Gathering data of the query: '{query}'", model="info2") - contents = self.do_request(query) - - analysisfile.append( - {"query": query, "selected_documents": self.extract_content(contents)} - ) - color_output("Data gathered", model="info2") - self._wait_between_queries(query) - - return self.save_analysisfile(analysisfile) - - def compare( - self, - previous_analysisfile_content: list[dict], - new_analysisfile_content: list[dict], - ) -> None: - """Manage the process to compare two analysis - - Args: - previous_analysisfile_content (list): Previous content analysis - new_analysisfile_content (list): New content analysis - """ - for query in self._queries: - # Extract data regarding the selected source documents - prev_querie_content = [ - x for x in previous_analysisfile_content if x["query"] == query - ][0]["selected_documents"] - new_querie_content = [ - x for x in new_analysisfile_content if x["query"] == query - ][0]["selected_documents"] - - color_output(f"Analysing the query: '{query}'", model="info2") - CompareAnalysis( - query, prev_querie_content, new_querie_content, self._threshold - )() - color_output(f"Analyse completed for the query: '{query}'", model="info2") - self._wait_between_queries(query) - - color_output("All the defined queries have been evaluated.", model="info2") - - def validate_analysisfiles(self) -> bool: - """Validate that the selected analysis files exist - - Returns: - bool: True if all of them exist. False otherwise - """ - existing_analysisfiles = self.get_analysis_files() - - if missing_analysisfiles := [ - x for x in self._analysisfiles if x not in existing_analysisfiles - ]: - color_output( - f"Missing analysis file(s) '{', '.join(missing_analysisfiles)}' - NOT FOUND", - model="critical", - ) - analysisfiles = "\n ".join(existing_analysisfiles) - color_output("Available analysis files:", model="info2") - color_output(analysisfiles) - return False - - return True - - def __call__(self) -> None: - if not self.prepare(): - sys.exit(1) - - if self._exectype == "new": - self.new() - - elif self._exectype == "compare": - self._analysisfiles = [ - x.replace(".json", "") + ".json" for x in self._analysisfiles - ] - - if not self.validate_analysisfiles(): - sys.exit(1) - - color_output( - "Extracting queries from the existing analysis file...", model="debug" - ) - previous_analysisfile_content = self.get_analysis_file_content( - self._analysisfiles[0] - ) - - # Extract the queries - self._queries = sorted([x["query"] for x in previous_analysisfile_content]) - color_output( - f"Extracted queries: {', '.join(self._queries)}", model="debug" - ) - - if len(self._analysisfiles) == 1: - if new_file := self.new(): - new_analysisfile_content = self.get_analysis_file_content( - new_file.split("/")[-1:][0] - ) - return self.compare( - previous_analysisfile_content, new_analysisfile_content - ) - else: - color_output( - "Unable to generate a new analysis file", model="critical" - ) - sys.exit(1) - else: - color_output( - ( - f"For the rest of this execution, the analysis file '{self._analysisfiles[0]}' " - f"is identified as 'previous' and '{self._analysisfiles[1]}' as 'current'" - ), - model="info2", - ) - new_analysisfile_content = self.get_analysis_file_content( - self._analysisfiles[1] - ) - new_queries = sorted([x["query"] for x in new_analysisfile_content]) - if new_queries != self._queries: - color_output( - "Unable to compare analysis files as the queries are differents", - model="critical", - ) - sys.exit(1) - self.compare(previous_analysisfile_content, new_analysisfile_content) - - -def validate_cmd_args(args: argparse.Namespace) -> bool: - """Validate the CMD arguments - - Args: - args (argparse.Namespace): The argparse data input - - Returns: - bool: True if the CMD arguments are valid. False otherwise - """ - if not args.execution: - color_output( - "Missing argument. The execution mode ('--execution') must be defined ('new' or 'compare')", - model="critical", - ) - return False - if args.execution == "new" and not args.q__queries: - color_output( - "Missing argument. When the execution type is set to 'new' the '--queries' argument must be defined", - model="critical", - ) - return False - elif args.execution == "compare": - if not args.files: - color_output( - "Missing argument. When the execution type is set to 'compare' the '--files' argument must be defined", - model="critical", - ) - return False - elif len(args.files) > 2: - color_output( - "Too many arguments. The '--files' argument cannot be repeated more than 2 times.", - model="critical", - ) - return False - return True - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "-a", - "--auth", - type=str, - default=None, - help=( - "Currently, to get this script working when the Danswer Auth is " - "enabled, you must extract from the UI your cookie 'fastapiusersauth' " - "and then set it using this argument" - ), - ) - parser.add_argument( - "-e", - "--execution", - type=str, - choices=["new", "compare"], - default=None, - help=( - "The execution type. Must be 'new' to generate a new analysis file " - "or 'compare' to compare a previous execution with a new one based on the same queries" - ), - ) - parser.add_argument( - "-f", - "--files", - action="extend", - default=[], - nargs=1, - help=( - "Analysis file(s) to use for the comparison. Required if the execution arg is set " - "to 'compare'. NOTE: By repeating this argument, you can make a comparison between " - "two specific executions. If not repeated, a new execution will be performed and " - "compared with the selected one." - ), - ) - parser.add_argument( - "-p", - "--port", - type=int, - default=3000, - help=( - "The Danswer Web (not the API) port. We use the UI to forward the requests to the API. " - "It should be '3000' for local dev and '80' if Danswer runs using docker compose." - ), - ) - parser.add_argument( - "-q" "--queries", - type=str, - action="extend", - default=[], - nargs=1, - help=( - "The query to evaluate. Required if the execution arg is set to 'new'. " - "NOTE: This argument can be repeated multiple times" - ), - ) - parser.add_argument( - "-t", - "--threshold", - type=float, - default=0.0, - help="The minimum score change (percentage) to detect an issue.", - ) - parser.add_argument( - "-w", - "--wait", - type=int, - default=10, - help=( - "The waiting time (in seconds) to respect between queries. " - "It is helpful to avoid hitting the Generative AI rate limiting." - ), - ) - - args = parser.parse_args() - if not validate_cmd_args(args): - sys.exit(1) - - SelectionAnalysis( - args.execution, - args.files, - args.q__queries, - args.threshold, - args.port, - args.auth, - args.wait, - )() diff --git a/backend/scripts/test-openapi-key.py b/backend/scripts/test-openapi-key.py deleted file mode 100644 index 8b12279ba1c..00000000000 --- a/backend/scripts/test-openapi-key.py +++ /dev/null @@ -1,58 +0,0 @@ -from openai import OpenAI - - -VALID_MODEL_LIST = [ - "gpt-4o-mini", - "gpt-4o", - "gpt-4-1106-preview", - "gpt-4-vision-preview", - "gpt-4", - "gpt-4-0314", - "gpt-4-0613", - "gpt-4-32k", - "gpt-4-32k-0314", - "gpt-4-32k-0613", - "gpt-3.5-turbo-0125", - "gpt-3.5-turbo-1106", - "gpt-3.5-turbo", - "gpt-3.5-turbo-16k", - "gpt-3.5-turbo-0301", - "gpt-3.5-turbo-0613", - "gpt-3.5-turbo-16k-0613", -] - - -if __name__ == "__main__": - model_version = None - while model_version not in VALID_MODEL_LIST: - model_version = input("Please provide an OpenAI model version to test: ") - if model_version not in VALID_MODEL_LIST: - print(f"Model must be from valid list: {', '.join(VALID_MODEL_LIST)}") - assert model_version - - api_key = input("Please provide an OpenAI API Key to test: ") - client = OpenAI( - api_key=api_key, - ) - - prompt = "The boy went to the " - print(f"Asking OpenAI to finish the sentence using {model_version}") - print(prompt) - try: - messages = [ - {"role": "system", "content": "Finish the sentence"}, - {"role": "user", "content": prompt}, - ] - response = client.chat.completions.create( - model=model_version, - messages=messages, # type:ignore - max_tokens=5, - temperature=2, - ) - print(response.choices[0].message.content) - print("Success! Feel free to use this API key for Danswer.") - except Exception: - print( - "Failed, provided API key is invalid for Danswer, please address the error from OpenAI." - ) - raise diff --git a/backend/shared_configs/__init__.py b/backend/shared_configs/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/shared_configs/configs.py b/backend/shared_configs/configs.py deleted file mode 100644 index 5ad36cc93c4..00000000000 --- a/backend/shared_configs/configs.py +++ /dev/null @@ -1,70 +0,0 @@ -import os - -# Used for logging -SLACK_CHANNEL_ID = "channel_id" - -MODEL_SERVER_HOST = os.environ.get("MODEL_SERVER_HOST") or "localhost" -MODEL_SERVER_ALLOWED_HOST = os.environ.get("MODEL_SERVER_HOST") or "0.0.0.0" -MODEL_SERVER_PORT = int(os.environ.get("MODEL_SERVER_PORT") or "9000") -# Model server for indexing should use a separate one to not allow indexing to introduce delay -# for inference -INDEXING_MODEL_SERVER_HOST = ( - os.environ.get("INDEXING_MODEL_SERVER_HOST") or MODEL_SERVER_HOST -) -INDEXING_MODEL_SERVER_PORT = int( - os.environ.get("INDEXING_MODEL_SERVER_PORT") or MODEL_SERVER_PORT -) - -# Danswer custom Deep Learning Models -INTENT_MODEL_VERSION = "danswer/hybrid-intent-token-classifier" -INTENT_MODEL_TAG = "v1.0.3" - -# Bi-Encoder, other details -DOC_EMBEDDING_CONTEXT_SIZE = 512 - -# Used to distinguish alternative indices -ALT_INDEX_SUFFIX = "__danswer_alt_index" - -# Used for loading defaults for automatic deployments and dev flows -# For local, use: mixedbread-ai/mxbai-rerank-xsmall-v1 -DEFAULT_CROSS_ENCODER_MODEL_NAME = ( - os.environ.get("DEFAULT_CROSS_ENCODER_MODEL_NAME") or None -) -DEFAULT_CROSS_ENCODER_API_KEY = os.environ.get("DEFAULT_CROSS_ENCODER_API_KEY") or None -DEFAULT_CROSS_ENCODER_PROVIDER_TYPE = ( - os.environ.get("DEFAULT_CROSS_ENCODER_PROVIDER_TYPE") or None -) -DISABLE_RERANK_FOR_STREAMING = ( - os.environ.get("DISABLE_RERANK_FOR_STREAMING", "").lower() == "true" -) - -# This controls the minimum number of pytorch "threads" to allocate to the embedding -# model. If torch finds more threads on its own, this value is not used. -MIN_THREADS_ML_MODELS = int(os.environ.get("MIN_THREADS_ML_MODELS") or 1) - -# Model server that has indexing only set will throw exception if used for reranking -# or intent classification -INDEXING_ONLY = os.environ.get("INDEXING_ONLY", "").lower() == "true" - -# The process needs to have this for the log file to write to -# otherwise, it will not create additional log files -LOG_FILE_NAME = os.environ.get("LOG_FILE_NAME") or "danswer" - -# Enable generating persistent log files for local dev environments -DEV_LOGGING_ENABLED = os.environ.get("DEV_LOGGING_ENABLED", "").lower() == "true" -# notset, debug, info, notice, warning, error, or critical -LOG_LEVEL = os.environ.get("LOG_LEVEL", "notice") - - -# Fields which should only be set on new search setting -PRESERVED_SEARCH_FIELDS = [ - "provider_type", - "api_key", - "model_name", - "index_name", - "multipass_indexing", - "model_dim", - "normalize", - "passage_prefix", - "query_prefix", -] diff --git a/backend/shared_configs/enums.py b/backend/shared_configs/enums.py deleted file mode 100644 index 918872d44b3..00000000000 --- a/backend/shared_configs/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -from enum import Enum - - -class EmbeddingProvider(str, Enum): - OPENAI = "openai" - COHERE = "cohere" - VOYAGE = "voyage" - GOOGLE = "google" - - -class RerankerProvider(str, Enum): - COHERE = "cohere" - - -class EmbedTextType(str, Enum): - QUERY = "query" - PASSAGE = "passage" diff --git a/backend/shared_configs/model_server_models.py b/backend/shared_configs/model_server_models.py deleted file mode 100644 index 3014616c620..00000000000 --- a/backend/shared_configs/model_server_models.py +++ /dev/null @@ -1,55 +0,0 @@ -from pydantic import BaseModel - -from shared_configs.enums import EmbeddingProvider -from shared_configs.enums import EmbedTextType -from shared_configs.enums import RerankerProvider - -Embedding = list[float] - - -class EmbedRequest(BaseModel): - texts: list[str] - # Can be none for cloud embedding model requests, error handling logic exists for other cases - model_name: str | None = None - max_context_length: int - normalize_embeddings: bool - api_key: str | None = None - provider_type: EmbeddingProvider | None = None - text_type: EmbedTextType - manual_query_prefix: str | None = None - manual_passage_prefix: str | None = None - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - -class EmbedResponse(BaseModel): - embeddings: list[Embedding] - - -class RerankRequest(BaseModel): - query: str - documents: list[str] - model_name: str - provider_type: RerankerProvider | None = None - api_key: str | None = None - - # This disables the "model_" protected namespace for pydantic - model_config = {"protected_namespaces": ()} - - -class RerankResponse(BaseModel): - scores: list[float] - - -class IntentRequest(BaseModel): - query: str - # Sequence classification threshold - semantic_percent_threshold: float - # Token classification threshold - keyword_percent_threshold: float - - -class IntentResponse(BaseModel): - is_keyword: bool - keywords: list[str] diff --git a/backend/shared_configs/utils.py b/backend/shared_configs/utils.py deleted file mode 100644 index c40795eb4aa..00000000000 --- a/backend/shared_configs/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import TypeVar - - -T = TypeVar("T") - - -def batch_list( - lst: list[T], - batch_size: int, -) -> list[list[T]]: - return [lst[i : i + batch_size] for i in range(0, len(lst), batch_size)] diff --git a/backend/slackbot_images/Confluence.png b/backend/slackbot_images/Confluence.png deleted file mode 100644 index b201fb6165408b2eb5cc100037ea16187d1dd8f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1013 zcmVL2~dAwMlSW0ODX|etv#oL8`^|XB zzU;bvyM0mcV!oH*y?MX+erDdA2dwG;E~=GGb$#0jk!>8O!&lzfJQua-rk=uX61>73 z?>*OpcR^&U2gn+yW+u>?;sck4n`Ua6H)pThqKKVgmIh`93@6m^+)OV` ztO_9c2Qklumn7NZ#z`|e_P%*LySokDyY)F&wv0q-wDJ9S>z#R1~G?~Jw3gb z?k>L(3wtWwHE|aabrLa%2|;99E=)uq;w?SIh^u7yAiV+4hLYhLKcYZ4bkE)_n&@Vc zoy@YH%eR3`Ygz%|U^qD4hL`aexqk%^s4+WNkIQwBi^z5s*~%<+0YusOJpjj5@#;)3 zeRZvxp=(%8^@*FVlQ{Bf_{0n+;rtYc!OVFD78p365Pb2?9{N3M%}4;P{dOOK15j)= zKnMU#p#aqP;1yiK`FaB8Mlb48VKq(Feg&f@ZPR$ufag*6!I~LN73v&xB|4~6ICU4z zxnakJ+;6$2U;Z(`J%=s6cmX2-wpZo?(`pdKSWtBz`tbHI4sDvM<`cfS02VJ`ILtig z-pruIxO}ZJwepxSwG8<*k$(ESk#uIdJu*ORzhwYq!gBUE({cz4gMg`tLAxC3SR{b9 zeYO$6+fmHRA+$CG%xpZ`1PE^dXt>(lmQ8KN+$v#eK{%(mNB{;jg?1kRsC{biZ~y5) zn$^b!Gwd*PUzQ+3CSdltcdX-dB!E;B4;V0jEr;Q8%8@2{`9+v|4C zHXFQ`RNW3!O{#O}mYUl)BTKp}fMV;^vJX$Gdas${jqm#sLp7f-Q~l1fjq2MoP=9>s j{X52^o?FwJ{&W2Wzl%V&#+jYy00000NkvXXu0mjf(Mi+z diff --git a/backend/slackbot_images/File.png b/backend/slackbot_images/File.png deleted file mode 100644 index 563d74939fc49cbf58c4dc87be49f4cba5055dd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1732 zcmV;#20QtQP)Px*en~_@RCr$PoKI*RRT#kEw@H)P$;Ji?q8M*_l42B;j-&@=+iL|S(!(57SP=xh z^dcmbc(8{eSgMfJeN?2~VpH@uyl&CZ+o zW^u*n0f`uLkeqXKakc(8V{CYHo3V|Z)ctLutf<~jO#(YN!(T6z%y&p= zq!2<1a(8$4kxy$wKP}Zfcm>8-oiS#lXkWN0LT&l7J-Abo&^kA>|7fXH`kC-O%A&|X z*0$W=&Mfo$8Dk^Lt>|3=;fV)7ka#?(wgs(9*%$`^r?;qVN|1oOh{{U6_)Wq^@>En$Pj2SRXB zV7(7!Tfi!1!vui6gzrgQEj7s6mg}Bb=23o7)|$i(>{79-Y}AAlfnPoB1#R?DvCY>dsEjJLuenX$L0PN01#F>4R(977SO)#s~o1 z$r>gFNMUYmiE|%8%I^&)?=9Pcf^X)iwV6FsAUhK8K7#bz$n?B}ky=D`{9pE&m}iaw zKxQzhK;oAc4fmk-5oB|1&OC$-kbEdcZ1mM(pE(KunM89+kZmbRA3+9z4JR~?zv;@O zAmKlebHlb^q*68xWf9FOK%yhZ(OO5gXvdy&R{N~aX_vy>u^KYfUiAfZU>cVG|z{~^414mJpt~!&KtL%4Q zBRi(}a5#CYY)sq>a63(snR}23=l!qi4}SbZ>sQIlF*pW(7KfA1J!Tv}as<$?CVKuC zAd$?4ulVTlk8Zbwylz`yJpcIK-4hcNHT!>g_#GCqR;Yxpe?7B%s6U-qb-%1nuO+CGyJ{rlKGkn%s~a|jZ}!&hXTmB zI(hNMqBrb}`wSXLjI0Pkm3u_+G>TI!~vUWabAiK?w$5oteO@Bxv=8iS*mfds7AeJ-sWqovqenQW6!P+6i9!?^2Sn)qiLsI=9M)pyqMO?HORE~ z8&%(*!<2`PSWJUdg&nb=CW1|VeWWU+IORhigPkI|4mNSXsFsZ^RX+HL`Qhr8e!j+9Q|K!6pf{h&mdi21!JKNQ~A<4U&ie zkr=I!8YB?`A~9McHAo@?L}Ii?YLG+(h{R}()F6on5Q)(mnPZS`n>bb1Jczg`i;*<) zz_UBO1XE1xiUoFgcbM`FbFYSG^}D}80L6<<0(ilQsj3JRAp3UmD+`#?CMx+34rN84 z0x8TQFb~x)ngygdGNmmm!M-1?r`rD1Af>Aq%J!*F8!e(JN;$Fob!>z z)%tB|Z#TX_yaQIHP1I%Ov_}50ZRw8xQatF{P}N4Pn_44Lh~qeuC(pC((BXH{P|CgD z)R4tT7Z=vy`I<-o&H8oc*eB~y^Y2Idufq#>0nL5*{OO^i#~vEJp5$whfy9?{ z&PNwl>o=Eb-W6Y{k=Q`WYY&t3v1c9If2hg|iFDfU@XB1_06wd@?+C~jxeGQW6t7(uLBoP53FPx!awGvb;t zE2f(ZEjo)@c+#q*nBySaVG7m z_r_vYExB|8n_(Dvw7az=z&%Q@(XYt2@(MZ^-%b9xsv526vG$cK%e&>}83eowpe@0 zu{eu>SDJqki{;jou*i(J8jSL5r^Yq&X`1Ll z2{w19Jm+Z3vMNgC1Qh8K_`1@>&XeFOlP^MwN+PLjwhzvI_KkyP?vP+Ar>o|s9MxV(eI#iW7|JR%;sc zO1W^+d_{9#%Bt&nJ~(c2u_6z~{v6+mSdD0q%j6EMaf>DuY_4IZEL^C8WHJsnSpgvC?M1{tw2)sr6+k| z#?v{bX;vb>;9NQR(o=TD`!k8d{ppuZkP|Px1a{6kB{w%WGkHSId`(Webcd4EK4}Lb zifQCzOLr#Fn(J|NOD}syGq=gfmhS4h**9%${)DcxoMh=Me9hc0Cs(>}ni#!FgX0v+^3D?dki%=UCQX~OtFF0@-FQ<+c6+DJtVgfjtaF!c zto@BQvDR&_WM?-zmsLNgRyKpNZ9uQ&+=dc#COhoNI_!oHH?!A=y}@S9n$0$C-pu^i zy7lYX^y%NRL4%*Ofm`daBeNy1I5VJ}q20zIE5Uc_=?&PwpMHidTJ$>$+IH{W&A$Ks z2iE6-2ifr_o)Yb|V%#{roHfVB>DdOp1F9cnzh37rSimxA-+nud-PG|`R=LUnQJmKi z<-I|9D_a0~P_4sQzlR^SU!h^z+O=!#aa65waFphEXPLWUC73mlS70ruT7wOGb}-w% zeLLgHHg4L)`abv&tCU|5m21DAWsQ`(M!Xc#j&xV9T*-L0rAwEw>#lDfg`1XzN?AG= z&^JzvDhQ9Lb2OX$%{NRW`|R^^_PE6cqt(b~kW#5eRE06yTzNHHw{BgzK(TArF81U6 z`Rx4#ZYb3x0CSo0PavWqUhlwEV(^{i{RyV%pu3}T;r`WaiYWNEp< zh;d&|;}>}Thx^+NRik~>0BGb^W#~gD@Y+YQb~kijquzVp zcOEj?o*VK4vnu6_xDMNVaTO76M+t;^RjRVj$Bp;B;5&BgVDF54mtENE5~i6I107Rt zy(Hjx0~!G|2qe);uRU$-4^ygA(dzA(mn z+zBVM2@}8YeRX)=`OQUImts#jMHEU$B|kWO_8iX(I(ObYc248-xET8tS6##Y{Bwz~ z9lX)GO`}B*i$eaeVqO88GYs6@C^^L`?m$${L81c@ zQK{F(167OXXTk31_1bau>eZsKdWs>;5NYlqxPV@)u&|KTtADx>&vfKbb=jP`^E^El zt}DgR%CT;COWjNp6@>LpJA-Z8w#{|15Wrw@MLY9O{NhX3XG5?f$TNNB2E?C41R?Tr zKmYQJ>q7nh`yXQF-^k61dF+D^M>{^>M<0z5Mpsd(SkZCXOUs;AFtSr);8yfX}hO>D4Th zl0$>D&v6AI4*4SO6j@dKOiZuRfGAXB?vKWdbdshtfaRtl@eon{R$N@{2*kVY z?k*RbZyzQ+5MLeonj;AR_+zoCyuEDlWhIsnh*B_x`{WH9H#!0lY726q`FHBma+I)9 z^VQe3bp#=(`MOm}F0=^OeY$x^Nju`|C_o(2oP0cR7pTw&ilZjh5#DB6U9F) za;x+*`5z?#aS`tWN41=jp*}?}u2_yHqA4^mV;1j&?*G^mjzGNgu6yL-I(U9THTKA( zkFjafrrSkWFTV6j=tK~z3}}|B{`DpA6Zy@QDULuq`k3SS{Hv$n>CA?WSoiyS*u_$x ze)hR7z690~r0%9nood%gjR!ah_X&6&LL=Cyp8$fdZ)2rZ@gKiT4toEMvwl;mPe^}V9jz@G|rwgx76PS?Z@!$ z&dm=~OG#hlooVA_STvsMsb6~eRoB}eKXuk9gJ|090^fJ%C?SA4g&S8h#Q9pDN&_3n zGZ0%`*vfG-arWV1oRBqkTln%AUau(p_c9jLK6|MO#1SD}exh0}c}KjDXCQXzdZ*)L zLOp?poe<@znXs?Ep2X@NdqSyWIlp-e&&5}`G7UMO*fKeNa~;n>9Qfo@j+1#>gR?>( zCaRQbIBE@AguLQJZHVf)4Lwhz!FVA4Hq{$lI-1v&I&S zM@@1NhRy{){rt1%um5e~LT>n)$YK8-t>onyJOdF?v6F>K`$B378rV(-;63nAKYQm) zRTx8_5z*~smtSFbGXSW9invR%c|ZPW2UQ%c@0PtNVe~&X@CrmIr#kI0VH0)T)VusD zF(Andad$sjI9?3(�B=uyo+SC> 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 Hu0mjfh+25N diff --git a/backend/slackbot_images/README.md b/backend/slackbot_images/README.md deleted file mode 100644 index bb527d676df..00000000000 --- a/backend/slackbot_images/README.md +++ /dev/null @@ -1,3 +0,0 @@ - -This folder contains images needed by the Danswer Slack Bot. When possible, we use the images -within `web/public`, but sometimes those images do not work for the Slack Bot. diff --git a/backend/slackbot_images/Web.png b/backend/slackbot_images/Web.png deleted file mode 100644 index 33320416baa26220e38b5db07fb79e2eaeebc278..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2885 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 Mo Generator[TestClient, Any, None]: - # Set environment variables - os.environ["ENABLE_PAID_ENTERPRISE_EDITION_FEATURES"] = "True" - - # Initialize TestClient with the FastAPI app - app = fetch_versioned_implementation( - module="danswer.main", attribute="get_application" - )() - client = TestClient(app) - yield client - - -@pytest.mark.skip( - reason="enable when we have a testing environment with preloaded data" -) -def test_handle_simplified_chat_message(client: TestClient) -> None: - req: dict[str, Any] = {} - - req["persona_id"] = 0 - req["description"] = "pytest" - response = client.post("/chat/create-chat-session", json=req) - chat_session_id = response.json()["chat_session_id"] - - req = {} - req["chat_session_id"] = chat_session_id - req["message"] = "hello" - - response = client.post("/chat/send-message-simple-api", json=req) - assert response.status_code == 200 - - -@pytest.mark.skip( - reason="enable when we have a testing environment with preloaded data" -) -def test_handle_send_message_simple_with_history(client: TestClient) -> None: - req: dict[str, Any] = {} - messages = [] - messages.append({"message": "What sorts of questions can you answer for me?"}) - # messages.append({"message": - # "I'd be happy to assist you with a wide range of questions related to Ramp's expense management platform. " - # "I can help with topics such as:\n\n" - # "1. Setting up and managing your Ramp account\n" - # "2. Using Ramp cards and making purchases\n" - # "3. Submitting and reviewing expenses\n" - # "4. Understanding Ramp's features and benefits\n" - # "5. Navigating the Ramp dashboard and mobile app\n" - # "6. Managing team spending and budgets\n" - # "7. Integrating Ramp with accounting software\n" - # "8. Troubleshooting common issues\n\n" - # "Feel free to ask any specific questions you have about using Ramp, " - # "and I'll do my best to provide clear and helpful answers. " - # "Is there a particular area you'd like to know more about?", - # "role": "assistant"}) - # req["prompt_id"] = 9 - # req["persona_id"] = 6 - - # Yoda - req["persona_id"] = 1 - req["prompt_id"] = 4 - messages.append( - { - "message": "Answer questions for you, I can. " - "About many topics, knowledge I have. " - "But specific to documents provided, limited my responses are. " - "Ask you may about:\n\n" - "- User interviews and building trust with participants\n" - "- Designing effective surveys and survey questions \n" - "- Product analysis approaches\n" - "- Recruiting participants for research\n" - "- Discussion guides for user interviews\n" - "- Types of survey questions\n\n" - "More there may be, but focus on these areas, the given context does. " - "Specific questions you have, ask you should. Guide you I will, as best I can.", - "role": "assistant", - } - ) - # messages.append({"message": "Where can I pilot a survey?"}) - - # messages.append({"message": "How many data points should I collect to validate my solution?"}) - messages.append({"message": "What is solution validation research used for?"}) - - req["messages"] = messages - - response = client.post("/chat/send-message-simple-with-history", json=req) - assert response.status_code == 200 - - resp_json = response.json() - - # persona must have LLM relevance enabled for this to pass - assert len(resp_json["llm_chunks_indices"]) > 0 diff --git a/backend/tests/daily/connectors/confluence/test_confluence_basic.py b/backend/tests/daily/connectors/confluence/test_confluence_basic.py deleted file mode 100644 index 7f05242c50b..00000000000 --- a/backend/tests/daily/connectors/confluence/test_confluence_basic.py +++ /dev/null @@ -1,42 +0,0 @@ -import os -import time - -import pytest - -from danswer.connectors.confluence.connector import ConfluenceConnector - - -@pytest.fixture -def confluence_connector() -> ConfluenceConnector: - connector = ConfluenceConnector(os.environ["CONFLUENCE_TEST_SPACE_URL"]) - connector.load_credentials( - { - "confluence_username": os.environ["CONFLUENCE_USER_NAME"], - "confluence_access_token": os.environ["CONFLUENCE_ACCESS_TOKEN"], - } - ) - return connector - - -def test_confluence_connector_basic(confluence_connector: ConfluenceConnector) -> None: - doc_batch_generator = confluence_connector.poll_source(0, time.time()) - - doc_batch = next(doc_batch_generator) - with pytest.raises(StopIteration): - next(doc_batch_generator) - - assert len(doc_batch) == 1 - - doc = doc_batch[0] - assert doc.semantic_identifier == "DailyConnectorTestSpace Home" - assert doc.metadata["labels"] == ["testlabel"] - assert doc.primary_owners - assert doc.primary_owners[0].email == "chris@danswer.ai" - assert len(doc.sections) == 1 - - section = doc.sections[0] - assert section.text == "test123small" - assert ( - section.link - == "https://danswerai.atlassian.net/wiki/spaces/DailyConne/overview" - ) diff --git a/backend/tests/daily/embedding/test_embeddings.py b/backend/tests/daily/embedding/test_embeddings.py deleted file mode 100644 index a9c12b236cf..00000000000 --- a/backend/tests/daily/embedding/test_embeddings.py +++ /dev/null @@ -1,78 +0,0 @@ -import os - -import pytest - -from danswer.natural_language_processing.search_nlp_models import EmbeddingModel -from shared_configs.enums import EmbedTextType -from shared_configs.model_server_models import EmbeddingProvider - -VALID_SAMPLE = ["hi", "hello my name is bob", "woah there!!!. 😃"] -# openai limit is 2048, cohere is supposed to be 96 but in practice that doesn't -# seem to be true -TOO_LONG_SAMPLE = ["a"] * 2500 - - -def _run_embeddings( - texts: list[str], embedding_model: EmbeddingModel, expected_dim: int -) -> None: - for text_type in [EmbedTextType.QUERY, EmbedTextType.PASSAGE]: - embeddings = embedding_model.encode(texts, text_type) - assert len(embeddings) == len(texts) - assert len(embeddings[0]) == expected_dim - - -@pytest.fixture -def openai_embedding_model() -> EmbeddingModel: - return EmbeddingModel( - server_host="localhost", - server_port=9000, - model_name="text-embedding-3-small", - normalize=True, - query_prefix=None, - passage_prefix=None, - api_key=os.getenv("OPENAI_API_KEY"), - provider_type=EmbeddingProvider.OPENAI, - ) - - -def test_openai_embedding(openai_embedding_model: EmbeddingModel) -> None: - _run_embeddings(VALID_SAMPLE, openai_embedding_model, 1536) - _run_embeddings(TOO_LONG_SAMPLE, openai_embedding_model, 1536) - - -@pytest.fixture -def cohere_embedding_model() -> EmbeddingModel: - return EmbeddingModel( - server_host="localhost", - server_port=9000, - model_name="embed-english-light-v3.0", - normalize=True, - query_prefix=None, - passage_prefix=None, - api_key=os.getenv("COHERE_API_KEY"), - provider_type=EmbeddingProvider.COHERE, - ) - - -def test_cohere_embedding(cohere_embedding_model: EmbeddingModel) -> None: - _run_embeddings(VALID_SAMPLE, cohere_embedding_model, 384) - _run_embeddings(TOO_LONG_SAMPLE, cohere_embedding_model, 384) - - -@pytest.fixture -def local_nomic_embedding_model() -> EmbeddingModel: - return EmbeddingModel( - server_host="localhost", - server_port=9000, - model_name="nomic-ai/nomic-embed-text-v1", - normalize=True, - query_prefix="search_query: ", - passage_prefix="search_document: ", - api_key=None, - provider_type=None, - ) - - -def test_local_nomic_embedding(local_nomic_embedding_model: EmbeddingModel) -> None: - _run_embeddings(VALID_SAMPLE, local_nomic_embedding_model, 768) - _run_embeddings(TOO_LONG_SAMPLE, local_nomic_embedding_model, 768) diff --git a/backend/tests/integration/Dockerfile b/backend/tests/integration/Dockerfile deleted file mode 100644 index d4869dd76c2..00000000000 --- a/backend/tests/integration/Dockerfile +++ /dev/null @@ -1,83 +0,0 @@ -FROM python:3.11.7-slim-bookworm -# Dockerfile for integration tests -# Currently needs all dependencies, since the ITs use some of the Danswer -# backend code. - -# Install system dependencies -# cmake needed for psycopg (postgres) -# libpq-dev needed for psycopg (postgres) -# curl included just for users' convenience -# zip for Vespa step futher down -# ca-certificates for HTTPS -RUN apt-get update && \ - apt-get install -y \ - cmake \ - curl \ - zip \ - ca-certificates \ - libgnutls30=3.7.9-2+deb12u3 \ - libblkid1=2.38.1-5+deb12u1 \ - libmount1=2.38.1-5+deb12u1 \ - libsmartcols1=2.38.1-5+deb12u1 \ - libuuid1=2.38.1-5+deb12u1 \ - libxmlsec1-dev \ - pkg-config \ - gcc && \ - rm -rf /var/lib/apt/lists/* && \ - apt-get clean - -# Install Python dependencies -# Remove py which is pulled in by retry, py is not needed and is a CVE -COPY ./requirements/default.txt /tmp/requirements.txt -COPY ./requirements/ee.txt /tmp/ee-requirements.txt -RUN pip install --no-cache-dir --upgrade \ - -r /tmp/requirements.txt \ - -r /tmp/ee-requirements.txt && \ - pip uninstall -y py && \ - playwright install chromium && \ - playwright install-deps chromium && \ - ln -s /usr/local/bin/supervisord /usr/bin/supervisord - -# Cleanup for CVEs and size reduction -# https://github.com/tornadoweb/tornado/issues/3107 -# xserver-common and xvfb included by playwright installation but not needed after -# perl-base is part of the base Python Debian image but not needed for Danswer functionality -# perl-base could only be removed with --allow-remove-essential -RUN apt-get update && \ - apt-get remove -y --allow-remove-essential \ - perl-base \ - xserver-common \ - xvfb \ - cmake \ - libldap-2.5-0 \ - libxmlsec1-dev \ - pkg-config \ - gcc && \ - apt-get install -y libxmlsec1-openssl && \ - apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* && \ - rm -f /usr/local/lib/python3.11/site-packages/tornado/test/test.key - -# Set up application files -WORKDIR /app - -# Enterprise Version Files -COPY ./ee /app/ee -COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf - -# Set up application files -COPY ./danswer /app/danswer -COPY ./shared_configs /app/shared_configs -COPY ./alembic /app/alembic -COPY ./alembic.ini /app/alembic.ini -COPY supervisord.conf /usr/etc/supervisord.conf - -# Integration test stuff -COPY ./requirements/dev.txt /tmp/dev-requirements.txt -RUN pip install --no-cache-dir --upgrade \ - -r /tmp/dev-requirements.txt -COPY ./tests/integration /app/tests/integration - -ENV PYTHONPATH /app - -CMD ["pytest", "-s", "/app/tests/integration"] diff --git a/backend/tests/integration/common_utils/chat.py b/backend/tests/integration/common_utils/chat.py deleted file mode 100644 index cd33d4edcaf..00000000000 --- a/backend/tests/integration/common_utils/chat.py +++ /dev/null @@ -1,66 +0,0 @@ -import requests -from sqlalchemy.orm import Session - -from danswer.db.models import User - - -def test_create_chat_session_and_send_messages(db_session: Session) -> None: - # Create a test user - test_user = User(email="test@example.com", hashed_password="dummy_hash") - db_session.add(test_user) - db_session.commit() - - base_url = "http://localhost:8080" # Adjust this to your API's base URL - headers = {"Authorization": f"Bearer {test_user.id}"} - - # Create a new chat session - create_session_response = requests.post( - f"{base_url}/chat/create-chat-session", - json={ - "description": "Test Chat", - "persona_id": 1, - }, # Assuming persona_id 1 exists - headers=headers, - ) - assert create_session_response.status_code == 200 - chat_session_id = create_session_response.json()["chat_session_id"] - - # Send first message - first_message = "Hello, this is a test message." - send_message_response = requests.post( - f"{base_url}/chat/send-message", - json={ - "chat_session_id": chat_session_id, - "message": first_message, - "prompt_id": None, - "retrieval_options": {"top_k": 3}, - "stream_response": False, - }, - headers=headers, - ) - assert send_message_response.status_code == 200 - - # Send second message - second_message = "Can you provide more information?" - send_message_response = requests.post( - f"{base_url}/chat/send-message", - json={ - "chat_session_id": chat_session_id, - "message": second_message, - "prompt_id": None, - "retrieval_options": {"top_k": 3}, - "stream_response": False, - }, - headers=headers, - ) - assert send_message_response.status_code == 200 - - # Verify chat session details - get_session_response = requests.get( - f"{base_url}/chat/get-chat-session/{chat_session_id}", headers=headers - ) - assert get_session_response.status_code == 200 - session_details = get_session_response.json() - assert session_details["chat_session_id"] == chat_session_id - assert session_details["description"] == "Test Chat" - assert len(session_details["messages"]) == 4 # 2 user messages + 2 AI responses diff --git a/backend/tests/integration/common_utils/connectors.py b/backend/tests/integration/common_utils/connectors.py deleted file mode 100644 index e7734cec3c8..00000000000 --- a/backend/tests/integration/common_utils/connectors.py +++ /dev/null @@ -1,114 +0,0 @@ -import uuid -from typing import cast - -import requests -from pydantic import BaseModel - -from danswer.configs.constants import DocumentSource -from danswer.db.enums import ConnectorCredentialPairStatus -from tests.integration.common_utils.constants import API_SERVER_URL - - -class ConnectorCreationDetails(BaseModel): - connector_id: int - credential_id: int - cc_pair_id: int - - -class ConnectorClient: - @staticmethod - def create_connector( - name_prefix: str = "test_connector", credential_id: int | None = None - ) -> ConnectorCreationDetails: - unique_id = uuid.uuid4() - - connector_name = f"{name_prefix}_{unique_id}" - connector_data = { - "name": connector_name, - "source": DocumentSource.NOT_APPLICABLE, - "input_type": "load_state", - "connector_specific_config": {}, - "refresh_freq": 60, - "disabled": True, - } - response = requests.post( - f"{API_SERVER_URL}/manage/admin/connector", - json=connector_data, - ) - response.raise_for_status() - connector_id = response.json()["id"] - - # associate the credential with the connector - if not credential_id: - print("ID not specified, creating new credential") - # Create a new credential - credential_data = { - "credential_json": {}, - "admin_public": True, - "source": DocumentSource.NOT_APPLICABLE, - } - response = requests.post( - f"{API_SERVER_URL}/manage/credential", - json=credential_data, - ) - response.raise_for_status() - credential_id = cast(int, response.json()["id"]) - - cc_pair_metadata = {"name": f"test_cc_pair_{unique_id}", "is_public": True} - response = requests.put( - f"{API_SERVER_URL}/manage/connector/{connector_id}/credential/{credential_id}", - json=cc_pair_metadata, - ) - response.raise_for_status() - - # fetch the conenector credential pair id using the indexing status API - response = requests.get( - f"{API_SERVER_URL}/manage/admin/connector/indexing-status" - ) - response.raise_for_status() - indexing_statuses = response.json() - - cc_pair_id = None - for status in indexing_statuses: - if ( - status["connector"]["id"] == connector_id - and status["credential"]["id"] == credential_id - ): - cc_pair_id = status["cc_pair_id"] - break - - if cc_pair_id is None: - raise ValueError("Could not find the connector credential pair id") - - print( - f"Created connector with connector_id: {connector_id}, credential_id: {credential_id}, cc_pair_id: {cc_pair_id}" - ) - return ConnectorCreationDetails( - connector_id=int(connector_id), - credential_id=int(credential_id), - cc_pair_id=int(cc_pair_id), - ) - - @staticmethod - def update_connector_status( - cc_pair_id: int, status: ConnectorCredentialPairStatus - ) -> None: - response = requests.put( - f"{API_SERVER_URL}/manage/admin/cc-pair/{cc_pair_id}/status", - json={"status": status}, - ) - response.raise_for_status() - - @staticmethod - def delete_connector(connector_id: int, credential_id: int) -> None: - response = requests.post( - f"{API_SERVER_URL}/manage/admin/deletion-attempt", - json={"connector_id": connector_id, "credential_id": credential_id}, - ) - response.raise_for_status() - - @staticmethod - def get_connectors() -> list[dict]: - response = requests.get(f"{API_SERVER_URL}/manage/connector") - response.raise_for_status() - return response.json() diff --git a/backend/tests/integration/common_utils/constants.py b/backend/tests/integration/common_utils/constants.py deleted file mode 100644 index efc98dde7de..00000000000 --- a/backend/tests/integration/common_utils/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -API_SERVER_PROTOCOL = os.getenv("API_SERVER_PROTOCOL") or "http" -API_SERVER_HOST = os.getenv("API_SERVER_HOST") or "localhost" -API_SERVER_PORT = os.getenv("API_SERVER_PORT") or "8080" -API_SERVER_URL = f"{API_SERVER_PROTOCOL}://{API_SERVER_HOST}:{API_SERVER_PORT}" -MAX_DELAY = 30 diff --git a/backend/tests/integration/common_utils/document_sets.py b/backend/tests/integration/common_utils/document_sets.py deleted file mode 100644 index dc898611108..00000000000 --- a/backend/tests/integration/common_utils/document_sets.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import cast - -import requests - -from danswer.server.features.document_set.models import DocumentSet -from danswer.server.features.document_set.models import DocumentSetCreationRequest -from tests.integration.common_utils.constants import API_SERVER_URL - - -class DocumentSetClient: - @staticmethod - def create_document_set( - doc_set_creation_request: DocumentSetCreationRequest, - ) -> int: - response = requests.post( - f"{API_SERVER_URL}/manage/admin/document-set", - json=doc_set_creation_request.model_dump(), - ) - response.raise_for_status() - return cast(int, response.json()) - - @staticmethod - def fetch_document_sets() -> list[DocumentSet]: - response = requests.get(f"{API_SERVER_URL}/manage/document-set") - response.raise_for_status() - - document_sets = [ - DocumentSet.parse_obj(doc_set_data) for doc_set_data in response.json() - ] - return document_sets diff --git a/backend/tests/integration/common_utils/llm.py b/backend/tests/integration/common_utils/llm.py deleted file mode 100644 index ba8b89d6b4d..00000000000 --- a/backend/tests/integration/common_utils/llm.py +++ /dev/null @@ -1,62 +0,0 @@ -import os -from typing import cast - -import requests -from pydantic import BaseModel -from pydantic import PrivateAttr - -from danswer.server.manage.llm.models import LLMProviderUpsertRequest -from tests.integration.common_utils.constants import API_SERVER_URL - - -class LLMProvider(BaseModel): - provider: str - api_key: str - default_model_name: str - api_base: str | None = None - api_version: str | None = None - is_default: bool = True - - # only populated after creation - _provider_id: int | None = PrivateAttr() - - def create(self) -> int: - llm_provider = LLMProviderUpsertRequest( - name=self.provider, - provider=self.provider, - default_model_name=self.default_model_name, - api_key=self.api_key, - api_base=self.api_base, - api_version=self.api_version, - custom_config=None, - fast_default_model_name=None, - is_public=True, - groups=[], - display_model_names=None, - model_names=None, - ) - - response = requests.put( - f"{API_SERVER_URL}/admin/llm/provider", - json=llm_provider.dict(), - ) - response.raise_for_status() - - self._provider_id = cast(int, response.json()["id"]) - return self._provider_id - - def delete(self) -> None: - response = requests.delete( - f"{API_SERVER_URL}/admin/llm/provider/{self._provider_id}" - ) - response.raise_for_status() - - -def seed_default_openai_provider() -> LLMProvider: - llm = LLMProvider( - provider="openai", - default_model_name="gpt-4o-mini", - api_key=os.environ["OPENAI_API_KEY"], - ) - llm.create() - return llm diff --git a/backend/tests/integration/common_utils/reset.py b/backend/tests/integration/common_utils/reset.py deleted file mode 100644 index 3815aa9f972..00000000000 --- a/backend/tests/integration/common_utils/reset.py +++ /dev/null @@ -1,172 +0,0 @@ -import logging -import time - -import psycopg2 -import requests - -from alembic import command -from alembic.config import Config -from danswer.configs.app_configs import POSTGRES_HOST -from danswer.configs.app_configs import POSTGRES_PASSWORD -from danswer.configs.app_configs import POSTGRES_PORT -from danswer.configs.app_configs import POSTGRES_USER -from danswer.db.engine import build_connection_string -from danswer.db.engine import get_session_context_manager -from danswer.db.engine import SYNC_DB_API -from danswer.db.search_settings import get_current_search_settings -from danswer.db.swap_index import check_index_swap -from danswer.document_index.vespa.index import DOCUMENT_ID_ENDPOINT -from danswer.document_index.vespa.index import VespaIndex -from danswer.indexing.models import IndexingSetting -from danswer.main import setup_postgres -from danswer.main import setup_vespa -from tests.integration.common_utils.llm import seed_default_openai_provider - - -def _run_migrations( - database_url: str, direction: str = "upgrade", revision: str = "head" -) -> None: - # hide info logs emitted during migration - logging.getLogger("alembic").setLevel(logging.CRITICAL) - - # Create an Alembic configuration object - alembic_cfg = Config("alembic.ini") - alembic_cfg.set_section_option("logger_alembic", "level", "WARN") - - # Set the SQLAlchemy URL in the Alembic configuration - alembic_cfg.set_main_option("sqlalchemy.url", database_url) - - # Run the migration - if direction == "upgrade": - command.upgrade(alembic_cfg, revision) - elif direction == "downgrade": - command.downgrade(alembic_cfg, revision) - else: - raise ValueError( - f"Invalid direction: {direction}. Must be 'upgrade' or 'downgrade'." - ) - - logging.getLogger("alembic").setLevel(logging.INFO) - - -def reset_postgres(database: str = "postgres") -> None: - """Reset the Postgres database.""" - - # NOTE: need to delete all rows to allow migrations to be rolled back - # as there are a few downgrades that don't properly handle data in tables - conn = psycopg2.connect( - dbname=database, - user=POSTGRES_USER, - password=POSTGRES_PASSWORD, - host=POSTGRES_HOST, - port=POSTGRES_PORT, - ) - cur = conn.cursor() - - # Disable triggers to prevent foreign key constraints from being checked - cur.execute("SET session_replication_role = 'replica';") - - # Fetch all table names in the current database - cur.execute( - """ - SELECT tablename - FROM pg_tables - WHERE schemaname = 'public' - """ - ) - - tables = cur.fetchall() - - for table in tables: - table_name = table[0] - - # Don't touch migration history - if table_name == "alembic_version": - continue - - # Don't touch Kombu - if table_name == "kombu_message" or table_name == "kombu_queue": - continue - - cur.execute(f'DELETE FROM "{table_name}"') - - # Re-enable triggers - cur.execute("SET session_replication_role = 'origin';") - - conn.commit() - cur.close() - conn.close() - - # downgrade to base + upgrade back to head - conn_str = build_connection_string( - db=database, - user=POSTGRES_USER, - password=POSTGRES_PASSWORD, - host=POSTGRES_HOST, - port=POSTGRES_PORT, - db_api=SYNC_DB_API, - ) - _run_migrations( - conn_str, - direction="downgrade", - revision="base", - ) - _run_migrations( - conn_str, - direction="upgrade", - revision="head", - ) - - # do the same thing as we do on API server startup - with get_session_context_manager() as db_session: - setup_postgres(db_session) - - -def reset_vespa() -> None: - """Wipe all data from the Vespa index.""" - with get_session_context_manager() as db_session: - # swap to the correct default model - check_index_swap(db_session) - - search_settings = get_current_search_settings(db_session) - index_name = search_settings.index_name - - setup_vespa( - document_index=VespaIndex(index_name=index_name, secondary_index_name=None), - index_setting=IndexingSetting.from_db_model(search_settings), - secondary_index_setting=None, - ) - - for _ in range(5): - try: - continuation = None - should_continue = True - while should_continue: - params = {"selection": "true", "cluster": "danswer_index"} - if continuation: - params = {**params, "continuation": continuation} - response = requests.delete( - DOCUMENT_ID_ENDPOINT.format(index_name=index_name), params=params - ) - response.raise_for_status() - - response_json = response.json() - - continuation = response_json.get("continuation") - should_continue = bool(continuation) - - break - except Exception as e: - print(f"Error deleting documents: {e}") - time.sleep(5) - - -def reset_all() -> None: - """Reset both Postgres and Vespa.""" - print("Resetting Postgres...") - reset_postgres() - print("Resetting Vespa...") - reset_vespa() - print("Seeding LLM Providers...") - seed_default_openai_provider() - print("Finished resetting all.") diff --git a/backend/tests/integration/common_utils/seed_documents.py b/backend/tests/integration/common_utils/seed_documents.py deleted file mode 100644 index b6720c9aebe..00000000000 --- a/backend/tests/integration/common_utils/seed_documents.py +++ /dev/null @@ -1,72 +0,0 @@ -import uuid - -import requests -from pydantic import BaseModel - -from danswer.configs.constants import DocumentSource -from tests.integration.common_utils.connectors import ConnectorClient -from tests.integration.common_utils.constants import API_SERVER_URL - - -class SimpleTestDocument(BaseModel): - id: str - content: str - - -class SeedDocumentResponse(BaseModel): - cc_pair_id: int - documents: list[SimpleTestDocument] - - -class TestDocumentClient: - @staticmethod - def seed_documents( - num_docs: int = 5, cc_pair_id: int | None = None - ) -> SeedDocumentResponse: - if not cc_pair_id: - connector_details = ConnectorClient.create_connector() - cc_pair_id = connector_details.cc_pair_id - - # Create and ingest some documents - documents: list[dict] = [] - for _ in range(num_docs): - document_id = f"test-doc-{uuid.uuid4()}" - document = { - "document": { - "id": document_id, - "sections": [ - { - "text": f"This is test document {document_id}", - "link": f"{document_id}", - } - ], - "source": DocumentSource.NOT_APPLICABLE, - # just for testing metadata - "metadata": {"document_id": document_id}, - "semantic_identifier": f"Test Document {document_id}", - "from_ingestion_api": True, - }, - "cc_pair_id": cc_pair_id, - } - documents.append(document) - response = requests.post( - f"{API_SERVER_URL}/danswer-api/ingestion", - json=document, - ) - response.raise_for_status() - - print("Seeding completed successfully.") - return SeedDocumentResponse( - cc_pair_id=cc_pair_id, - documents=[ - SimpleTestDocument( - id=document["document"]["id"], - content=document["document"]["sections"][0]["text"], - ) - for document in documents - ], - ) - - -if __name__ == "__main__": - seed_documents_resp = TestDocumentClient.seed_documents() diff --git a/backend/tests/integration/common_utils/user_groups.py b/backend/tests/integration/common_utils/user_groups.py deleted file mode 100644 index 0cd44066463..00000000000 --- a/backend/tests/integration/common_utils/user_groups.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import cast - -import requests - -from ee.danswer.server.user_group.models import UserGroup -from ee.danswer.server.user_group.models import UserGroupCreate -from tests.integration.common_utils.constants import API_SERVER_URL - - -class UserGroupClient: - @staticmethod - def create_user_group(user_group_creation_request: UserGroupCreate) -> int: - response = requests.post( - f"{API_SERVER_URL}/manage/admin/user-group", - json=user_group_creation_request.model_dump(), - ) - response.raise_for_status() - return cast(int, response.json()["id"]) - - @staticmethod - def fetch_user_groups() -> list[UserGroup]: - response = requests.get(f"{API_SERVER_URL}/manage/admin/user-group") - response.raise_for_status() - return [UserGroup(**ug) for ug in response.json()] diff --git a/backend/tests/integration/common_utils/vespa.py b/backend/tests/integration/common_utils/vespa.py deleted file mode 100644 index aff7ef5eca6..00000000000 --- a/backend/tests/integration/common_utils/vespa.py +++ /dev/null @@ -1,27 +0,0 @@ -import requests - -from danswer.document_index.vespa.index import DOCUMENT_ID_ENDPOINT - - -class TestVespaClient: - def __init__(self, index_name: str): - self.index_name = index_name - self.vespa_document_url = DOCUMENT_ID_ENDPOINT.format(index_name=index_name) - - def get_documents_by_id( - self, document_ids: list[str], wanted_doc_count: int = 1_000 - ) -> dict: - selection = " or ".join( - f"{self.index_name}.document_id=='{document_id}'" - for document_id in document_ids - ) - params = { - "selection": selection, - "wantedDocumentCount": wanted_doc_count, - } - response = requests.get( - self.vespa_document_url, - params=params, # type: ignore - ) - response.raise_for_status() - return response.json() diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py deleted file mode 100644 index 6c46e9f875e..00000000000 --- a/backend/tests/integration/conftest.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import Generator - -import pytest -from sqlalchemy.orm import Session - -from danswer.db.engine import get_session_context_manager -from danswer.db.search_settings import get_current_search_settings -from tests.integration.common_utils.reset import reset_all -from tests.integration.common_utils.vespa import TestVespaClient - - -@pytest.fixture -def db_session() -> Generator[Session, None, None]: - with get_session_context_manager() as session: - yield session - - -@pytest.fixture -def vespa_client(db_session: Session) -> TestVespaClient: - search_settings = get_current_search_settings(db_session) - return TestVespaClient(index_name=search_settings.index_name) - - -@pytest.fixture -def reset() -> None: - reset_all() diff --git a/backend/tests/integration/tests/connector/test_deletion.py b/backend/tests/integration/tests/connector/test_deletion.py deleted file mode 100644 index 78ad2378af9..00000000000 --- a/backend/tests/integration/tests/connector/test_deletion.py +++ /dev/null @@ -1,190 +0,0 @@ -import time - -from danswer.db.enums import ConnectorCredentialPairStatus -from danswer.server.features.document_set.models import DocumentSetCreationRequest -from tests.integration.common_utils.connectors import ConnectorClient -from tests.integration.common_utils.constants import MAX_DELAY -from tests.integration.common_utils.document_sets import DocumentSetClient -from tests.integration.common_utils.seed_documents import TestDocumentClient -from tests.integration.common_utils.user_groups import UserGroupClient -from tests.integration.common_utils.user_groups import UserGroupCreate -from tests.integration.common_utils.vespa import TestVespaClient - - -def test_connector_deletion(reset: None, vespa_client: TestVespaClient) -> None: - # create connectors - c1_details = ConnectorClient.create_connector(name_prefix="tc1") - c2_details = ConnectorClient.create_connector(name_prefix="tc2") - c1_seed_res = TestDocumentClient.seed_documents( - num_docs=5, cc_pair_id=c1_details.cc_pair_id - ) - c2_seed_res = TestDocumentClient.seed_documents( - num_docs=5, cc_pair_id=c2_details.cc_pair_id - ) - - # create document sets - doc_set_1_id = DocumentSetClient.create_document_set( - DocumentSetCreationRequest( - name="Test Document Set 1", - description="Intially connector to be deleted, should be empty after test", - cc_pair_ids=[c1_details.cc_pair_id], - is_public=True, - users=[], - groups=[], - ) - ) - - doc_set_2_id = DocumentSetClient.create_document_set( - DocumentSetCreationRequest( - name="Test Document Set 2", - description="Intially both connectors, should contain undeleted connector after test", - cc_pair_ids=[c1_details.cc_pair_id, c2_details.cc_pair_id], - is_public=True, - users=[], - groups=[], - ) - ) - - # wait for document sets to be synced - start = time.time() - while True: - doc_sets = DocumentSetClient.fetch_document_sets() - doc_set_1 = next( - (doc_set for doc_set in doc_sets if doc_set.id == doc_set_1_id), None - ) - doc_set_2 = next( - (doc_set for doc_set in doc_sets if doc_set.id == doc_set_2_id), None - ) - - if not doc_set_1 or not doc_set_2: - raise RuntimeError("Document set not found") - - if doc_set_1.is_up_to_date and doc_set_2.is_up_to_date: - break - - if time.time() - start > MAX_DELAY: - raise TimeoutError("Document sets were not synced within the max delay") - - time.sleep(2) - - print("Document sets created and synced") - - # if so, create ACLs - user_group_1 = UserGroupClient.create_user_group( - UserGroupCreate( - name="Test User Group 1", user_ids=[], cc_pair_ids=[c1_details.cc_pair_id] - ) - ) - user_group_2 = UserGroupClient.create_user_group( - UserGroupCreate( - name="Test User Group 2", - user_ids=[], - cc_pair_ids=[c1_details.cc_pair_id, c2_details.cc_pair_id], - ) - ) - - # wait for user groups to be available - start = time.time() - while True: - user_groups = {ug.id: ug for ug in UserGroupClient.fetch_user_groups()} - - if not ( - user_group_1 in user_groups.keys() and user_group_2 in user_groups.keys() - ): - raise RuntimeError("User groups not found") - - if ( - user_groups[user_group_1].is_up_to_date - and user_groups[user_group_2].is_up_to_date - ): - break - - if time.time() - start > MAX_DELAY: - raise TimeoutError("User groups were not synced within the max delay") - - time.sleep(2) - - print("User groups created and synced") - - # delete connector 1 - ConnectorClient.update_connector_status( - cc_pair_id=c1_details.cc_pair_id, status=ConnectorCredentialPairStatus.PAUSED - ) - ConnectorClient.delete_connector( - connector_id=c1_details.connector_id, credential_id=c1_details.credential_id - ) - - start = time.time() - while True: - connectors = ConnectorClient.get_connectors() - - if c1_details.connector_id not in [c["id"] for c in connectors]: - break - - if time.time() - start > MAX_DELAY: - raise TimeoutError("Connector 1 was not deleted within the max delay") - - time.sleep(2) - - print("Connector 1 deleted") - - # validate vespa documents - c1_vespa_docs = vespa_client.get_documents_by_id( - [doc.id for doc in c1_seed_res.documents] - )["documents"] - c2_vespa_docs = vespa_client.get_documents_by_id( - [doc.id for doc in c2_seed_res.documents] - )["documents"] - - assert len(c1_vespa_docs) == 0 - assert len(c2_vespa_docs) == 5 - - for doc in c2_vespa_docs: - assert doc["fields"]["access_control_list"] == { - "PUBLIC": 1, - "group:Test User Group 2": 1, - } - assert doc["fields"]["document_sets"] == {"Test Document Set 2": 1} - - # check that only connector 1 is deleted - # TODO: check for the CC pair rather than the connector once the refactor is done - all_connectors = ConnectorClient.get_connectors() - assert len(all_connectors) == 1 - assert all_connectors[0]["id"] == c2_details.connector_id - - # validate document sets - all_doc_sets = DocumentSetClient.fetch_document_sets() - assert len(all_doc_sets) == 2 - - doc_set_1_found = False - doc_set_2_found = False - for doc_set in all_doc_sets: - if doc_set.id == doc_set_1_id: - doc_set_1_found = True - assert doc_set.cc_pair_descriptors == [] - - if doc_set.id == doc_set_2_id: - doc_set_2_found = True - assert len(doc_set.cc_pair_descriptors) == 1 - assert doc_set.cc_pair_descriptors[0].id == c2_details.cc_pair_id - - assert doc_set_1_found - assert doc_set_2_found - - # validate user groups - all_user_groups = UserGroupClient.fetch_user_groups() - assert len(all_user_groups) == 2 - - user_group_1_found = False - user_group_2_found = False - for user_group in all_user_groups: - if user_group.id == user_group_1: - user_group_1_found = True - assert user_group.cc_pairs == [] - if user_group.id == user_group_2: - user_group_2_found = True - assert len(user_group.cc_pairs) == 1 - assert user_group.cc_pairs[0].id == c2_details.cc_pair_id - - assert user_group_1_found - assert user_group_2_found diff --git a/backend/tests/integration/tests/dev_apis/test_simple_chat_api.py b/backend/tests/integration/tests/dev_apis/test_simple_chat_api.py deleted file mode 100644 index b00c2e3d1e6..00000000000 --- a/backend/tests/integration/tests/dev_apis/test_simple_chat_api.py +++ /dev/null @@ -1,36 +0,0 @@ -import requests - -from tests.integration.common_utils.connectors import ConnectorClient -from tests.integration.common_utils.constants import API_SERVER_URL -from tests.integration.common_utils.seed_documents import TestDocumentClient - - -def test_send_message_simple_with_history(reset: None) -> None: - # create connectors - c1_details = ConnectorClient.create_connector(name_prefix="tc1") - c1_seed_res = TestDocumentClient.seed_documents( - num_docs=5, cc_pair_id=c1_details.cc_pair_id - ) - - response = requests.post( - f"{API_SERVER_URL}/chat/send-message-simple-with-history", - json={ - "messages": [{"message": c1_seed_res.documents[0].content, "role": "user"}], - "persona_id": 0, - "prompt_id": 0, - }, - ) - assert response.status_code == 200 - - response_json = response.json() - - # Check that the top document is the correct document - assert response_json["simple_search_docs"][0]["id"] == c1_seed_res.documents[0].id - - # assert that the metadata is correct - for doc in c1_seed_res.documents: - found_doc = next( - (x for x in response_json["simple_search_docs"] if x["id"] == doc.id), None - ) - assert found_doc - assert found_doc["metadata"]["document_id"] == doc.id diff --git a/backend/tests/integration/tests/document_set/test_syncing.py b/backend/tests/integration/tests/document_set/test_syncing.py deleted file mode 100644 index 9a6b42ab5df..00000000000 --- a/backend/tests/integration/tests/document_set/test_syncing.py +++ /dev/null @@ -1,78 +0,0 @@ -import time - -from danswer.server.features.document_set.models import DocumentSetCreationRequest -from tests.integration.common_utils.document_sets import DocumentSetClient -from tests.integration.common_utils.seed_documents import TestDocumentClient -from tests.integration.common_utils.vespa import TestVespaClient - - -def test_multiple_document_sets_syncing_same_connnector( - reset: None, vespa_client: TestVespaClient -) -> None: - # Seed documents - seed_result = TestDocumentClient.seed_documents(num_docs=5) - cc_pair_id = seed_result.cc_pair_id - - # Create first document set - doc_set_1_id = DocumentSetClient.create_document_set( - DocumentSetCreationRequest( - name="Test Document Set 1", - description="First test document set", - cc_pair_ids=[cc_pair_id], - is_public=True, - users=[], - groups=[], - ) - ) - - doc_set_2_id = DocumentSetClient.create_document_set( - DocumentSetCreationRequest( - name="Test Document Set 2", - description="Second test document set", - cc_pair_ids=[cc_pair_id], - is_public=True, - users=[], - groups=[], - ) - ) - - # wait for syncing to be complete - max_delay = 45 - start = time.time() - while True: - doc_sets = DocumentSetClient.fetch_document_sets() - doc_set_1 = next( - (doc_set for doc_set in doc_sets if doc_set.id == doc_set_1_id), None - ) - doc_set_2 = next( - (doc_set for doc_set in doc_sets if doc_set.id == doc_set_2_id), None - ) - - if not doc_set_1 or not doc_set_2: - raise RuntimeError("Document set not found") - - if doc_set_1.is_up_to_date and doc_set_2.is_up_to_date: - assert [ccp.id for ccp in doc_set_1.cc_pair_descriptors] == [ - ccp.id for ccp in doc_set_2.cc_pair_descriptors - ] - break - - if time.time() - start > max_delay: - raise TimeoutError("Document sets were not synced within the max delay") - - time.sleep(2) - - # get names so we can compare to what is in vespa - doc_sets = DocumentSetClient.fetch_document_sets() - doc_set_names = {doc_set.name for doc_set in doc_sets} - - # make sure documents are as expected - seeded_document_ids = [doc.id for doc in seed_result.documents] - - result = vespa_client.get_documents_by_id([doc.id for doc in seed_result.documents]) - documents = result["documents"] - assert len(documents) == len(seed_result.documents) - assert all(doc["fields"]["document_id"] in seeded_document_ids for doc in documents) - assert all( - set(doc["fields"]["document_sets"].keys()) == doc_set_names for doc in documents - ) diff --git a/backend/tests/regression/answer_quality/README.md b/backend/tests/regression/answer_quality/README.md deleted file mode 100644 index 27a0bd5ae96..00000000000 --- a/backend/tests/regression/answer_quality/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Search Quality Test Script - -This Python script automates the process of running search quality tests for a backend system. - -## Features - -- Loads configuration from a YAML file -- Sets up Docker environment -- Manages environment variables -- Switches to specified Git branch -- Uploads test documents -- Runs search quality tests -- Cleans up Docker containers (optional) - -## Usage - -1. Ensure you have the required dependencies installed. -2. Configure the `search_test_config.yaml` file based on the `search_test_config.yaml.template` file. -3. Configure the `.env_eval` file in `deployment/docker_compose` with the correct environment variables. -4. Set up the PYTHONPATH permanently: - Add the following line to your shell configuration file (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.bash_profile`): - ``` - export PYTHONPATH=$PYTHONPATH:/path/to/danswer/backend - ``` - Replace `/path/to/danswer` with the actual path to your Danswer repository. - After adding this line, restart your terminal or run `source ~/.bashrc` (or the appropriate config file) to apply the changes. -5. Navigate to Danswer repo: -``` -cd path/to/danswer -``` -6. Navigate to the answer_quality folder: -``` -cd backend/tests/regression/answer_quality -``` -7. To launch the evaluation environment, run the launch_eval_env.py script (this step can be skipped if you are running the env outside of docker, just leave "environment_name" blank): -``` -python launch_eval_env.py -``` -8. Run the file_uploader.py script to upload the zip files located at the path "zipped_documents_file" -``` -python file_uploader.py -``` -9. Run the run_qa.py script to ask questions from the jsonl located at the path "questions_file". This will hit the "query/answer-with-quote" API endpoint. -``` -python run_qa.py -``` - -Note: All data will be saved even after the containers are shut down. There are instructions below to re-launching docker containers using this data. - -If you decide to run multiple UIs at the same time, the ports will increment upwards from 3000 (E.g. http://localhost:3001). - -To see which port the desired instance is on, look at the ports on the nginx container by running `docker ps` or using docker desktop. - -Docker daemon must be running for this to work. - -## Configuration - -Edit `search_test_config.yaml` to set: - -- output_folder - - This is the folder where the folders for each test will go - - These folders will contain the postgres/vespa data as well as the results for each test -- zipped_documents_file - - The path to the zip file containing the files you'd like to test against -- questions_file - - The path to the yaml containing the questions you'd like to test with -- commit_sha - - Set this to the SHA of the commit you want to run the test against - - You must clear all local changes if you want to use this option - - Set this to null if you want it to just use the code as is -- clean_up_docker_containers - - Set this to true to automatically delete all docker containers, networks and volumes after the test -- launch_web_ui - - Set this to true if you want to use the UI during/after the testing process -- only_state - - Whether to only run Vespa and Postgres -- only_retrieve_docs - - Set true to only retrieve documents, not LLM response - - This is to save on API costs -- use_cloud_gpu - - Set to true or false depending on if you want to use the remote gpu - - Only need to set this if use_cloud_gpu is true -- model_server_ip - - This is the ip of the remote model server - - Only need to set this if use_cloud_gpu is true -- model_server_port - - This is the port of the remote model server - - Only need to set this if use_cloud_gpu is true -- environment_name - - Use this if you would like to relaunch a previous test instance - - Input the env_name of the test you'd like to re-launch - - Leave empty to launch referencing local default network locations -- limit - - Max number of questions you'd like to ask against the dataset - - Set to null for no limit -- llm - - Fill this out according to the normal LLM seeding - - -## Relaunching From Existing Data - -To launch an existing set of containers that has already completed indexing, set the environment_name variable. This will launch the docker containers mounted on the volumes of the indicated env_name and will not automatically index any documents or run any QA. - -Once these containers are launched you can run file_uploader.py or run_qa.py (assuming you have run the steps in the Usage section above). -- file_uploader.py will upload and index additional zipped files located at the zipped_documents_file path. -- run_qa.py will ask questions located at the questions_file path against the indexed documents. diff --git a/backend/tests/regression/answer_quality/__init__.py b/backend/tests/regression/answer_quality/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/tests/regression/answer_quality/api_utils.py b/backend/tests/regression/answer_quality/api_utils.py deleted file mode 100644 index 5a46032c62f..00000000000 --- a/backend/tests/regression/answer_quality/api_utils.py +++ /dev/null @@ -1,207 +0,0 @@ -import requests -from retry import retry - -from danswer.configs.constants import DocumentSource -from danswer.configs.constants import MessageType -from danswer.connectors.models import InputType -from danswer.db.enums import IndexingStatus -from danswer.one_shot_answer.models import DirectQARequest -from danswer.one_shot_answer.models import ThreadMessage -from danswer.search.models import IndexFilters -from danswer.search.models import OptionalSearchSetting -from danswer.search.models import RetrievalDetails -from danswer.server.documents.models import ConnectorBase -from tests.regression.answer_quality.cli_utils import get_api_server_host_port - -GENERAL_HEADERS = {"Content-Type": "application/json"} - - -def _api_url_builder(env_name: str, api_path: str) -> str: - if env_name: - return f"http://localhost:{get_api_server_host_port(env_name)}" + api_path - else: - return "http://localhost:8080" + api_path - - -@retry(tries=5, delay=5) -def get_answer_from_query( - query: str, only_retrieve_docs: bool, env_name: str -) -> tuple[list[str], str]: - filters = IndexFilters( - source_type=None, - document_set=None, - time_cutoff=None, - tags=None, - access_control_list=None, - ) - - messages = [ThreadMessage(message=query, sender=None, role=MessageType.USER)] - - new_message_request = DirectQARequest( - messages=messages, - prompt_id=0, - persona_id=0, - retrieval_options=RetrievalDetails( - run_search=OptionalSearchSetting.ALWAYS, - real_time=True, - filters=filters, - enable_auto_detect_filters=False, - ), - chain_of_thought=False, - return_contexts=True, - skip_gen_ai_answer_generation=only_retrieve_docs, - ) - - url = _api_url_builder(env_name, "/query/answer-with-quote/") - headers = { - "Content-Type": "application/json", - } - - body = new_message_request.model_dump() - body["user"] = None - try: - response_json = requests.post(url, headers=headers, json=body).json() - context_data_list = response_json.get("contexts", {}).get("contexts", []) - answer = response_json.get("answer", "") or "" - except Exception as e: - print("Failed to answer the questions:") - print(f"\t {str(e)}") - raise e - - return context_data_list, answer - - -@retry(tries=10, delay=10) -def check_indexing_status(env_name: str) -> tuple[int, bool]: - url = _api_url_builder(env_name, "/manage/admin/connector/indexing-status/") - try: - indexing_status_dict = requests.get(url, headers=GENERAL_HEADERS).json() - except Exception as e: - print("Failed to check indexing status, API server is likely starting up:") - print(f"\t {str(e)}") - print("trying again") - raise e - - ongoing_index_attempts = False - doc_count = 0 - for index_attempt in indexing_status_dict: - status = index_attempt["last_status"] - if status == IndexingStatus.IN_PROGRESS or status == IndexingStatus.NOT_STARTED: - ongoing_index_attempts = True - elif status == IndexingStatus.SUCCESS: - doc_count += 16 - doc_count += index_attempt["docs_indexed"] - doc_count -= 16 - - # all the +16 and -16 are to account for the fact that the indexing status - # is only updated every 16 documents and will tells us how many are - # chunked, not indexed. probably need to fix this. in the future! - if doc_count: - doc_count += 16 - return doc_count, ongoing_index_attempts - - -def run_cc_once(env_name: str, connector_id: int, credential_id: int) -> None: - url = _api_url_builder(env_name, "/manage/admin/connector/run-once/") - body = { - "connector_id": connector_id, - "credential_ids": [credential_id], - "from_beginning": True, - } - print("body:", body) - response = requests.post(url, headers=GENERAL_HEADERS, json=body) - if response.status_code == 200: - print("Connector created successfully:", response.json()) - else: - print("Failed status_code:", response.status_code) - print("Failed text:", response.text) - - -def create_cc_pair(env_name: str, connector_id: int, credential_id: int) -> None: - url = _api_url_builder( - env_name, f"/manage/connector/{connector_id}/credential/{credential_id}" - ) - - body = {"name": "zip_folder_contents", "is_public": True, "groups": []} - print("body:", body) - response = requests.put(url, headers=GENERAL_HEADERS, json=body) - if response.status_code == 200: - print("Connector created successfully:", response.json()) - else: - print("Failed status_code:", response.status_code) - print("Failed text:", response.text) - - -def _get_existing_connector_names(env_name: str) -> list[str]: - url = _api_url_builder(env_name, "/manage/connector") - - body = { - "credential_json": {}, - "admin_public": True, - } - response = requests.get(url, headers=GENERAL_HEADERS, json=body) - if response.status_code == 200: - connectors = response.json() - return [connector["name"] for connector in connectors] - else: - raise RuntimeError(response.__dict__) - - -def create_connector(env_name: str, file_paths: list[str]) -> int: - url = _api_url_builder(env_name, "/manage/admin/connector") - connector_name = base_connector_name = "search_eval_connector" - existing_connector_names = _get_existing_connector_names(env_name) - - count = 1 - while connector_name in existing_connector_names: - connector_name = base_connector_name + "_" + str(count) - count += 1 - - connector = ConnectorBase( - name=connector_name, - source=DocumentSource.FILE, - input_type=InputType.LOAD_STATE, - connector_specific_config={"file_locations": file_paths}, - refresh_freq=None, - prune_freq=None, - indexing_start=None, - ) - - body = connector.model_dump() - response = requests.post(url, headers=GENERAL_HEADERS, json=body) - if response.status_code == 200: - return response.json()["id"] - else: - raise RuntimeError(response.__dict__) - - -def create_credential(env_name: str) -> int: - url = _api_url_builder(env_name, "/manage/credential") - body = { - "credential_json": {}, - "admin_public": True, - "source": DocumentSource.FILE, - } - response = requests.post(url, headers=GENERAL_HEADERS, json=body) - if response.status_code == 200: - print("credential created successfully:", response.json()) - return response.json()["id"] - else: - raise RuntimeError(response.__dict__) - - -@retry(tries=10, delay=2, backoff=2) -def upload_file(env_name: str, zip_file_path: str) -> list[str]: - files = [ - ("files", open(zip_file_path, "rb")), - ] - - api_path = _api_url_builder(env_name, "/manage/admin/connector/file/upload") - try: - response = requests.post(api_path, files=files) - response.raise_for_status() # Raises an HTTPError for bad responses - print("file uploaded successfully:", response.json()) - return response.json()["file_paths"] - except Exception as e: - print("File upload failed, waiting for API server to come up and trying again") - raise e diff --git a/backend/tests/regression/answer_quality/cli_utils.py b/backend/tests/regression/answer_quality/cli_utils.py deleted file mode 100644 index 874a6292dbc..00000000000 --- a/backend/tests/regression/answer_quality/cli_utils.py +++ /dev/null @@ -1,321 +0,0 @@ -import json -import os -import socket -import subprocess -import sys -import time -from datetime import datetime -from threading import Thread -from typing import IO - -import yaml -from retry import retry - - -def _run_command(command: str, stream_output: bool = False) -> tuple[str, str]: - process = subprocess.Popen( - command, - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - ) - - stdout_lines: list[str] = [] - stderr_lines: list[str] = [] - - def process_stream(stream: IO[str], lines: list[str]) -> None: - for line in stream: - lines.append(line) - if stream_output: - print( - line, - end="", - file=sys.stdout if stream == process.stdout else sys.stderr, - ) - - stdout_thread = Thread(target=process_stream, args=(process.stdout, stdout_lines)) - stderr_thread = Thread(target=process_stream, args=(process.stderr, stderr_lines)) - - stdout_thread.start() - stderr_thread.start() - - stdout_thread.join() - stderr_thread.join() - - process.wait() - - if process.returncode != 0: - raise RuntimeError(f"Command failed with error: {''.join(stderr_lines)}") - - return "".join(stdout_lines), "".join(stderr_lines) - - -def get_current_commit_sha() -> str: - print("Getting current commit SHA...") - stdout, _ = _run_command("git rev-parse HEAD") - sha = stdout.strip() - print(f"Current commit SHA: {sha}") - return sha - - -def switch_to_commit(commit_sha: str) -> None: - print(f"Switching to commit: {commit_sha}...") - _run_command(f"git checkout {commit_sha}") - print(f"Successfully switched to commit: {commit_sha}") - print("Repository updated successfully.") - - -def get_docker_container_env_vars(env_name: str) -> dict: - """ - Retrieves environment variables from "background" and "api_server" Docker containers. - """ - print(f"Getting environment variables for containers with env_name: {env_name}") - - combined_env_vars = {} - for container_type in ["background", "api_server"]: - container_name = _run_command( - f"docker ps -a --format '{{{{.Names}}}}' | awk '/{container_type}/ && /{env_name}/'" - )[0].strip() - if not container_name: - raise RuntimeError( - f"No {container_type} container found with env_name: {env_name}" - ) - - env_vars_json = _run_command( - f"docker inspect --format='{{{{json .Config.Env}}}}' {container_name}" - )[0] - env_vars_list = json.loads(env_vars_json.strip()) - - for env_var in env_vars_list: - key, value = env_var.split("=", 1) - combined_env_vars[key] = value - - return combined_env_vars - - -def manage_data_directories(env_name: str, base_path: str, use_cloud_gpu: bool) -> None: - # Use the user's home directory as the base path - target_path = os.path.join(os.path.expanduser(base_path), env_name) - directories = { - "DANSWER_POSTGRES_DATA_DIR": os.path.join(target_path, "postgres/"), - "DANSWER_VESPA_DATA_DIR": os.path.join(target_path, "vespa/"), - } - if not use_cloud_gpu: - directories["DANSWER_INDEX_MODEL_CACHE_DIR"] = os.path.join( - target_path, "index_model_cache/" - ) - directories["DANSWER_INFERENCE_MODEL_CACHE_DIR"] = os.path.join( - target_path, "inference_model_cache/" - ) - - # Create directories if they don't exist - for env_var, directory in directories.items(): - os.makedirs(directory, exist_ok=True) - os.environ[env_var] = directory - print(f"Set {env_var} to: {directory}") - results_output_path = os.path.join(target_path, "evaluations_output/") - os.makedirs(results_output_path, exist_ok=True) - - -def set_env_variables( - remote_server_ip: str, - remote_server_port: str, - use_cloud_gpu: bool, - llm_config: dict, -) -> None: - env_vars: dict = {} - env_vars["ENV_SEED_CONFIGURATION"] = json.dumps({"llms": [llm_config]}) - env_vars["ENABLE_PAID_ENTERPRISE_EDITION_FEATURES"] = "true" - if use_cloud_gpu: - env_vars["MODEL_SERVER_HOST"] = remote_server_ip - env_vars["MODEL_SERVER_PORT"] = remote_server_port - env_vars["INDEXING_MODEL_SERVER_HOST"] = remote_server_ip - - for env_var_name, env_var in env_vars.items(): - os.environ[env_var_name] = env_var - print(f"Set {env_var_name} to: {env_var}") - - -def _is_port_in_use(port: int) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("localhost", port)) == 0 - - -def start_docker_compose( - env_name: str, launch_web_ui: bool, use_cloud_gpu: bool, only_state: bool = False -) -> None: - print("Starting Docker Compose...") - os.chdir(os.path.dirname(__file__)) - os.chdir("../../../../deployment/docker_compose/") - command = f"docker compose -f docker-compose.search-testing.yml -p danswer-stack-{env_name} up -d" - command += " --build" - command += " --force-recreate" - - if only_state: - command += " index relational_db" - else: - if use_cloud_gpu: - command += " --scale indexing_model_server=0" - command += " --scale inference_model_server=0" - if launch_web_ui: - web_ui_port = 3000 - while _is_port_in_use(web_ui_port): - web_ui_port += 1 - print(f"UI will be launched at http://localhost:{web_ui_port}") - os.environ["NGINX_PORT"] = str(web_ui_port) - else: - command += " --scale web_server=0" - command += " --scale nginx=0" - - print("Docker Command:\n", command) - - _run_command(command, stream_output=True) - print("Containers have been launched") - - -def cleanup_docker(env_name: str) -> None: - print( - f"Deleting Docker containers, volumes, and networks for project env_name: {env_name}" - ) - - stdout, _ = _run_command("docker ps -a --format '{{json .}}'") - - containers = [json.loads(line) for line in stdout.splitlines()] - if not env_name: - env_name = datetime.now().strftime("-%Y") - project_name = f"danswer-stack{env_name}" - containers_to_delete = [ - c for c in containers if c["Names"].startswith(project_name) - ] - - if not containers_to_delete: - print(f"No containers found for project: {project_name}") - else: - container_ids = " ".join([c["ID"] for c in containers_to_delete]) - _run_command(f"docker rm -f {container_ids}") - - print( - f"Successfully deleted {len(containers_to_delete)} containers for project: {project_name}" - ) - - stdout, _ = _run_command("docker volume ls --format '{{.Name}}'") - - volumes = stdout.splitlines() - - volumes_to_delete = [v for v in volumes if v.startswith(project_name)] - - if not volumes_to_delete: - print(f"No volumes found for project: {project_name}") - return - - # Delete filtered volumes - volume_names = " ".join(volumes_to_delete) - _run_command(f"docker volume rm {volume_names}") - - print( - f"Successfully deleted {len(volumes_to_delete)} volumes for project: {project_name}" - ) - stdout, _ = _run_command("docker network ls --format '{{.Name}}'") - - networks = stdout.splitlines() - - networks_to_delete = [n for n in networks if env_name in n] - - if not networks_to_delete: - print(f"No networks found containing env_name: {env_name}") - else: - network_names = " ".join(networks_to_delete) - _run_command(f"docker network rm {network_names}") - - print( - f"Successfully deleted {len(networks_to_delete)} networks containing env_name: {env_name}" - ) - - -@retry(tries=5, delay=5, backoff=2) -def get_api_server_host_port(env_name: str) -> str: - """ - This pulls all containers with the provided env_name - It then grabs the JSON specific container with a name containing "api_server" - It then grabs the port info from the JSON and strips out the relevent data - """ - container_name = "api_server" - - stdout, _ = _run_command("docker ps -a --format '{{json .}}'") - containers = [json.loads(line) for line in stdout.splitlines()] - server_jsons = [] - - for container in containers: - if container_name in container["Names"] and env_name in container["Names"]: - server_jsons.append(container) - - if not server_jsons: - raise RuntimeError( - f"No container found containing: {container_name} and {env_name}" - ) - elif len(server_jsons) > 1: - raise RuntimeError( - f"Too many containers matching {container_name} found, please indicate a env_name" - ) - server_json = server_jsons[0] - - # This is in case the api_server has multiple ports - client_port = "8080" - ports = server_json.get("Ports", "") - port_infos = ports.split(",") if ports else [] - port_dict = {} - for port_info in port_infos: - port_arr = port_info.split(":")[-1].split("->") if port_info else [] - if len(port_arr) == 2: - port_dict[port_arr[1]] = port_arr[0] - - # Find the host port where client_port is in the key - matching_ports = [value for key, value in port_dict.items() if client_port in key] - - if len(matching_ports) > 1: - raise RuntimeError(f"Too many ports matching {client_port} found") - if not matching_ports: - raise RuntimeError( - f"No port found containing: {client_port} for container: {container_name} and env_name: {env_name}" - ) - return matching_ports[0] - - -# Added function to restart Vespa container -def restart_vespa_container(env_name: str) -> None: - print(f"Restarting Vespa container for env_name: {env_name}") - - # Find the Vespa container - stdout, _ = _run_command( - f"docker ps -a --format '{{{{.Names}}}}' | awk '/index-1/ && /{env_name}/'" - ) - container_name = stdout.strip() - - if not container_name: - raise RuntimeError(f"No Vespa container found with env_name: {env_name}") - - # Restart the container - _run_command(f"docker restart {container_name}") - - print(f"Vespa container '{container_name}' has begun restarting") - - time.sleep(30) - print(f"Vespa container '{container_name}' has been restarted") - - -if __name__ == "__main__": - """ - Running this just cleans up the docker environment for the container indicated by environment_name - If no environment_name is indicated, will just clean up all danswer docker containers/volumes/networks - Note: vespa/postgres mounts are not deleted - """ - current_dir = os.path.dirname(os.path.abspath(__file__)) - config_path = os.path.join(current_dir, "search_test_config.yaml") - with open(config_path, "r") as file: - config = yaml.safe_load(file) - - if not isinstance(config, dict): - raise TypeError("config must be a dictionary") - cleanup_docker(config["environment_name"]) diff --git a/backend/tests/regression/answer_quality/file_uploader.py b/backend/tests/regression/answer_quality/file_uploader.py deleted file mode 100644 index 8cbc632b5b7..00000000000 --- a/backend/tests/regression/answer_quality/file_uploader.py +++ /dev/null @@ -1,108 +0,0 @@ -import csv -import os -import tempfile -import time -import zipfile -from pathlib import Path -from types import SimpleNamespace - -import yaml - -from tests.regression.answer_quality.api_utils import check_indexing_status -from tests.regression.answer_quality.api_utils import create_cc_pair -from tests.regression.answer_quality.api_utils import create_connector -from tests.regression.answer_quality.api_utils import create_credential -from tests.regression.answer_quality.api_utils import run_cc_once -from tests.regression.answer_quality.api_utils import upload_file - - -def unzip_and_get_file_paths(zip_file_path: str) -> list[str]: - persistent_dir = tempfile.mkdtemp() - with zipfile.ZipFile(zip_file_path, "r") as zip_ref: - zip_ref.extractall(persistent_dir) - - file_paths = [] - for root, _, files in os.walk(persistent_dir): - for file in sorted(files): - file_paths.append(os.path.join(root, file)) - - return file_paths - - -def create_temp_zip_from_files(file_paths: list[str]) -> str: - persistent_dir = tempfile.mkdtemp() - zip_file_path = os.path.join(persistent_dir, "temp.zip") - - with zipfile.ZipFile(zip_file_path, "w") as zip_file: - for file_path in file_paths: - zip_file.write(file_path, Path(file_path).name) - - return zip_file_path - - -def upload_test_files(zip_file_path: str, env_name: str) -> None: - print("zip:", zip_file_path) - file_paths = upload_file(env_name, zip_file_path) - - conn_id = create_connector(env_name, file_paths) - cred_id = create_credential(env_name) - - create_cc_pair(env_name, conn_id, cred_id) - run_cc_once(env_name, conn_id, cred_id) - - -def manage_file_upload(zip_file_path: str, env_name: str) -> None: - start_time = time.time() - unzipped_file_paths = unzip_and_get_file_paths(zip_file_path) - total_file_count = len(unzipped_file_paths) - problem_file_list: list[str] = [] - - while True: - doc_count, ongoing_index_attempts = check_indexing_status(env_name) - - if ongoing_index_attempts: - print( - f"{doc_count} docs indexed but waiting for ongoing indexing jobs to finish..." - ) - elif not doc_count: - print("No docs indexed, waiting for indexing to start") - temp_zip_file_path = create_temp_zip_from_files(unzipped_file_paths) - upload_test_files(temp_zip_file_path, env_name) - os.unlink(temp_zip_file_path) - elif (doc_count + len(problem_file_list)) < total_file_count: - print(f"No ongooing indexing attempts but only {doc_count} docs indexed") - remaining_files = unzipped_file_paths[doc_count + len(problem_file_list) :] - problem_file_list.append(remaining_files.pop(0)) - print( - f"Removing first doc and grabbed last {len(remaining_files)} docs to try agian" - ) - temp_zip_file_path = create_temp_zip_from_files(remaining_files) - upload_test_files(temp_zip_file_path, env_name) - os.unlink(temp_zip_file_path) - else: - print(f"Successfully uploaded {doc_count} docs!") - break - - time.sleep(10) - - if problem_file_list: - problem_file_csv_path = os.path.join(current_dir, "problem_files.csv") - with open(problem_file_csv_path, "w", newline="") as csvfile: - csvwriter = csv.writer(csvfile) - csvwriter.writerow(["Problematic File Paths"]) - for problem_file in problem_file_list: - csvwriter.writerow([problem_file]) - - for file in unzipped_file_paths: - os.unlink(file) - print(f"Total time taken: {(time.time() - start_time)/60} minutes") - - -if __name__ == "__main__": - current_dir = os.path.dirname(os.path.abspath(__file__)) - config_path = os.path.join(current_dir, "search_test_config.yaml") - with open(config_path, "r") as file: - config = SimpleNamespace(**yaml.safe_load(file)) - file_location = config.zipped_documents_file - env_name = config.environment_name - manage_file_upload(file_location, env_name) diff --git a/backend/tests/regression/answer_quality/launch_eval_env.py b/backend/tests/regression/answer_quality/launch_eval_env.py deleted file mode 100644 index e701a1d42cf..00000000000 --- a/backend/tests/regression/answer_quality/launch_eval_env.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from types import SimpleNamespace - -import yaml - -from tests.regression.answer_quality.cli_utils import manage_data_directories -from tests.regression.answer_quality.cli_utils import set_env_variables -from tests.regression.answer_quality.cli_utils import start_docker_compose -from tests.regression.answer_quality.cli_utils import switch_to_commit - - -def load_config(config_filename: str) -> SimpleNamespace: - current_dir = os.path.dirname(os.path.abspath(__file__)) - config_path = os.path.join(current_dir, config_filename) - with open(config_path, "r") as file: - return SimpleNamespace(**yaml.safe_load(file)) - - -def main() -> None: - config = load_config("search_test_config.yaml") - if config.environment_name: - env_name = config.environment_name - print("launching danswer with environment name:", env_name) - else: - print("No env name defined. Not launching docker.") - print( - "Please define a name in the config yaml to start a new env " - "or use an existing env" - ) - return - - set_env_variables( - config.model_server_ip, - config.model_server_port, - config.use_cloud_gpu, - config.llm, - ) - manage_data_directories(env_name, config.output_folder, config.use_cloud_gpu) - if config.commit_sha: - switch_to_commit(config.commit_sha) - - start_docker_compose( - env_name, config.launch_web_ui, config.use_cloud_gpu, config.only_state - ) - - -if __name__ == "__main__": - main() diff --git a/backend/tests/regression/answer_quality/run_qa.py b/backend/tests/regression/answer_quality/run_qa.py deleted file mode 100644 index 5de034b3740..00000000000 --- a/backend/tests/regression/answer_quality/run_qa.py +++ /dev/null @@ -1,196 +0,0 @@ -import json -import multiprocessing -import os -import shutil -import time - -import yaml - -from tests.regression.answer_quality.api_utils import get_answer_from_query -from tests.regression.answer_quality.cli_utils import get_current_commit_sha -from tests.regression.answer_quality.cli_utils import get_docker_container_env_vars - -RESULTS_FILENAME = "results.jsonl" -METADATA_FILENAME = "metadata.yaml" - - -def _populate_results_file(output_folder_path: str, all_qa_output: list[dict]) -> None: - output_file_path = os.path.join(output_folder_path, RESULTS_FILENAME) - with open(output_file_path, "a", encoding="utf-8") as file: - for qa_output in all_qa_output: - file.write(json.dumps(qa_output) + "\n") - file.flush() - - -def _update_metadata_file(test_output_folder: str, invalid_answer_count: int) -> None: - metadata_path = os.path.join(test_output_folder, METADATA_FILENAME) - with open(metadata_path, "r", encoding="utf-8") as file: - metadata = yaml.safe_load(file) - - metadata["number_of_failed_questions"] = invalid_answer_count - with open(metadata_path, "w", encoding="utf-8") as yaml_file: - yaml.dump(metadata, yaml_file) - - -def _read_questions_jsonl(questions_file_path: str) -> list[dict]: - questions = [] - with open(questions_file_path, "r") as file: - for line in file: - json_obj = json.loads(line) - questions.append(json_obj) - return questions - - -def _get_test_output_folder(config: dict) -> str: - base_output_folder = os.path.expanduser(config["output_folder"]) - if config["env_name"]: - base_output_folder = os.path.join( - base_output_folder, config["env_name"], "evaluations_output" - ) - else: - base_output_folder = os.path.join(base_output_folder, "no_defined_env_name") - - counter = 1 - output_folder_path = os.path.join(base_output_folder, "run_1") - while os.path.exists(output_folder_path): - output_folder_path = os.path.join( - output_folder_path.replace(f"run_{counter-1}", f"run_{counter}"), - ) - counter += 1 - - os.makedirs(output_folder_path, exist_ok=True) - - return output_folder_path - - -def _initialize_files(config: dict) -> tuple[str, list[dict]]: - test_output_folder = _get_test_output_folder(config) - - questions_file_path = config["questions_file"] - - questions = _read_questions_jsonl(questions_file_path) - - metadata = { - "commit_sha": get_current_commit_sha(), - "env_name": config["env_name"], - "test_config": config, - "number_of_questions_in_dataset": len(questions), - } - - env_vars = get_docker_container_env_vars(config["env_name"]) - if env_vars["ENV_SEED_CONFIGURATION"]: - del env_vars["ENV_SEED_CONFIGURATION"] - if env_vars["GPG_KEY"]: - del env_vars["GPG_KEY"] - if metadata["test_config"]["llm"]["api_key"]: - del metadata["test_config"]["llm"]["api_key"] - metadata.update(env_vars) - metadata_path = os.path.join(test_output_folder, METADATA_FILENAME) - print("saving metadata to:", metadata_path) - with open(metadata_path, "w", encoding="utf-8") as yaml_file: - yaml.dump(metadata, yaml_file) - - copied_questions_file_path = os.path.join( - test_output_folder, os.path.basename(questions_file_path) - ) - shutil.copy2(questions_file_path, copied_questions_file_path) - - zipped_files_path = config["zipped_documents_file"] - copied_zipped_documents_path = os.path.join( - test_output_folder, os.path.basename(zipped_files_path) - ) - shutil.copy2(zipped_files_path, copied_zipped_documents_path) - - zipped_files_folder = os.path.dirname(zipped_files_path) - jsonl_file_path = os.path.join(zipped_files_folder, "target_docs.jsonl") - if os.path.exists(jsonl_file_path): - copied_jsonl_path = os.path.join(test_output_folder, "target_docs.jsonl") - shutil.copy2(jsonl_file_path, copied_jsonl_path) - - return test_output_folder, questions - - -def _process_question(question_data: dict, config: dict, question_number: int) -> dict: - query = question_data["question"] - context_data_list, answer = get_answer_from_query( - query=query, - only_retrieve_docs=config["only_retrieve_docs"], - env_name=config["env_name"], - ) - print(f"On question number {question_number}") - print(f"query: {query}") - - if not context_data_list: - print("No answer or context found") - else: - print(f"answer: {answer[:50]}...") - print(f"{len(context_data_list)} context docs found") - print("\n") - - output = { - "question_data": question_data, - "answer": answer, - "context_data_list": context_data_list, - } - - return output - - -def _process_and_write_query_results(config: dict) -> None: - start_time = time.time() - test_output_folder, questions = _initialize_files(config) - print("saving test results to folder:", test_output_folder) - - if config["limit"] is not None: - questions = questions[: config["limit"]] - - # Use multiprocessing to process questions - with multiprocessing.Pool() as pool: - results = pool.starmap( - _process_question, - [(question, config, i + 1) for i, question in enumerate(questions)], - ) - - _populate_results_file(test_output_folder, results) - - invalid_answer_count = 0 - for result in results: - if len(result["context_data_list"]) == 0: - invalid_answer_count += 1 - - _update_metadata_file(test_output_folder, invalid_answer_count) - - if invalid_answer_count: - print(f"Warning: {invalid_answer_count} questions failed!") - print("Suggest restarting the vespa container and rerunning") - - time_to_finish = time.time() - start_time - minutes, seconds = divmod(int(time_to_finish), 60) - print( - f"Took {minutes:02d}:{seconds:02d} to ask and answer {len(results)} questions" - ) - print("saved test results to folder:", test_output_folder) - - -def run_qa_test_and_save_results(env_name: str = "") -> None: - current_dir = os.path.dirname(os.path.abspath(__file__)) - config_path = os.path.join(current_dir, "search_test_config.yaml") - with open(config_path, "r") as file: - config = yaml.safe_load(file) - - if not isinstance(config, dict): - raise TypeError("config must be a dictionary") - - if not env_name: - env_name = config["environment_name"] - - config["env_name"] = env_name - _process_and_write_query_results(config) - - -if __name__ == "__main__": - """ - To run a different set of questions, update the questions_file in search_test_config.yaml - If there is more than one instance of Danswer running, specify the env_name in search_test_config.yaml - """ - run_qa_test_and_save_results() diff --git a/backend/tests/regression/answer_quality/search_test_config.yaml.template b/backend/tests/regression/answer_quality/search_test_config.yaml.template deleted file mode 100644 index eb813df57f3..00000000000 --- a/backend/tests/regression/answer_quality/search_test_config.yaml.template +++ /dev/null @@ -1,52 +0,0 @@ -# Copy this to search_test_config.yaml and fill in the values to run the eval pipeline -# Don't forget to also update the .env_eval file with the correct values - -# Directory where test results will be saved -output_folder: "~/danswer_test_results" - -# Path to the zip file containing sample documents -zipped_documents_file: "~/sampledocs.zip" - -# Path to the YAML file containing sample questions -questions_file: "~/sample_questions.yaml" - -# Git commit SHA to use (null means use current code as is) -commit_sha: null - -# Whether to launch a web UI for the test -launch_web_ui: false - -# Only retrieve documents, not LLM response -only_retrieve_docs: false - -# Whether to use a cloud GPU for processing -use_cloud_gpu: false - -# IP address of the model server (placeholder) -model_server_ip: "PUT_PUBLIC_CLOUD_IP_HERE" - -# Port of the model server (placeholder) -model_server_port: "PUT_PUBLIC_CLOUD_PORT_HERE" - -# Name for existing testing env (empty string uses default ports) -environment_name: "" - -# Limit on number of tests to run (null means no limit) -limit: null - -# LLM configuration -llm: - # Name of the LLM - name: "default_test_llm" - - # Provider of the LLM (e.g., OpenAI) - provider: "openai" - - # API key - api_key: "PUT_API_KEY_HERE" - - # Default model name to use - default_model_name: "gpt-4o" - - # List of model names to use for testing - model_names: ["gpt-4o"] diff --git a/backend/tests/unit/danswer/connectors/confluence/test_rate_limit_handler.py b/backend/tests/unit/danswer/connectors/confluence/test_rate_limit_handler.py deleted file mode 100644 index 92bccaa050d..00000000000 --- a/backend/tests/unit/danswer/connectors/confluence/test_rate_limit_handler.py +++ /dev/null @@ -1,59 +0,0 @@ -from unittest.mock import Mock -from unittest.mock import patch - -import pytest -from requests import HTTPError - -from danswer.connectors.confluence.rate_limit_handler import ( - make_confluence_call_handle_rate_limit, -) - - -@pytest.fixture -def mock_confluence_call() -> Mock: - return Mock() - - -@pytest.mark.parametrize( - "status_code,text,retry_after", - [ - (429, "Rate limit exceeded", "5"), - (200, "Rate limit exceeded", None), - (429, "Some other error", "5"), - ], -) -def test_rate_limit_handling( - mock_confluence_call: Mock, status_code: int, text: str, retry_after: str | None -) -> None: - with patch("time.sleep") as mock_sleep: - mock_confluence_call.side_effect = [ - HTTPError( - response=Mock( - status_code=status_code, - text=text, - headers={"Retry-After": retry_after} if retry_after else {}, - ) - ), - ] * 2 + ["Success"] - - handled_call = make_confluence_call_handle_rate_limit(mock_confluence_call) - result = handled_call() - - assert result == "Success" - assert mock_confluence_call.call_count == 3 - assert mock_sleep.call_count == 2 - if retry_after: - mock_sleep.assert_called_with(int(retry_after)) - - -def test_non_rate_limit_error(mock_confluence_call: Mock) -> None: - mock_confluence_call.side_effect = HTTPError( - response=Mock(status_code=500, text="Internal Server Error") - ) - - handled_call = make_confluence_call_handle_rate_limit(mock_confluence_call) - - with pytest.raises(HTTPError): - handled_call() - - assert mock_confluence_call.call_count == 1 diff --git a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_html_utils.py b/backend/tests/unit/danswer/connectors/cross_connector_utils/test_html_utils.py deleted file mode 100644 index f14a92faa3a..00000000000 --- a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_html_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -import pathlib - -from danswer.file_processing.html_utils import parse_html_page_basic - - -def test_parse_table() -> None: - dir_path = pathlib.Path(__file__).parent.resolve() - with open(f"{dir_path}/test_table.html", "r") as file: - content = file.read() - - parsed = parse_html_page_basic(content) - expected = "\n\thello\tthere\tgeneral\n\tkenobi\ta\tb\n\tc\td\te" - assert expected in parsed diff --git a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_rate_limit.py b/backend/tests/unit/danswer/connectors/cross_connector_utils/test_rate_limit.py deleted file mode 100644 index 471ef424f15..00000000000 --- a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_rate_limit.py +++ /dev/null @@ -1,29 +0,0 @@ -import time - -from danswer.connectors.cross_connector_utils.rate_limit_wrapper import ( - rate_limit_builder, -) - - -def test_rate_limit_basic() -> None: - call_cnt = 0 - - @rate_limit_builder(max_calls=2, period=5) - def func() -> None: - nonlocal call_cnt - call_cnt += 1 - - start = time.time() - - # Make calls that shouldn't be rate-limited - func() - func() - time_to_finish_non_ratelimited = time.time() - start - - # Make a call which SHOULD be rate-limited - func() - time_to_finish_ratelimited = time.time() - start - - assert call_cnt == 3 - assert time_to_finish_non_ratelimited < 1 - assert time_to_finish_ratelimited > 5 diff --git a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_table.html b/backend/tests/unit/danswer/connectors/cross_connector_utils/test_table.html deleted file mode 100644 index 2357f0b3f25..00000000000 --- a/backend/tests/unit/danswer/connectors/cross_connector_utils/test_table.html +++ /dev/null @@ -1,43 +0,0 @@ -

This page is to ensure we’re able to parse a table into a tsv

- - - - - - - - - - - - - - - - - - -
-

hello

-
-

there

-
-

general

-
-

kenobi

-
-

a

-
-

b

-
-

c

-
-

d

-
-

e

-
-

diff --git a/backend/tests/unit/danswer/connectors/gmail/test_connector.py b/backend/tests/unit/danswer/connectors/gmail/test_connector.py deleted file mode 100644 index 2689e2a2751..00000000000 --- a/backend/tests/unit/danswer/connectors/gmail/test_connector.py +++ /dev/null @@ -1,225 +0,0 @@ -import datetime - -import pytest -from pytest_mock import MockFixture - -from danswer.configs.constants import DocumentSource -from danswer.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc -from danswer.connectors.gmail.connector import GmailConnector -from danswer.connectors.models import Document - - -def test_email_to_document() -> None: - connector = GmailConnector() - email_id = "18cabedb1ea46b03" - email_subject = "Danswer Test Subject" - email_sender = "Google " - email_recipient = "test.mail@gmail.com" - email_date = "Wed, 27 Dec 2023 15:38:49 GMT" - email_labels = ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "STARRED", "INBOX"] - full_email = { - "id": email_id, - "threadId": email_id, - "labelIds": email_labels, - "snippet": "A new sign-in. We noticed a new sign-in to your Google Account. If this was you, you don't need to do", - "payload": { - "partId": "", - "mimeType": "multipart/alternative", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": email_recipient}, - {"name": "Date", "value": email_date}, - { - "name": "Message-ID", - "value": "", - }, - {"name": "Subject", "value": email_subject}, - {"name": "From", "value": email_sender}, - {"name": "To", "value": email_recipient}, - ], - "body": {"size": 0}, - "parts": [ - { - "partId": "0", - "mimeType": "text/plain", - "filename": "", - "headers": [ - { - "name": "Content-Type", - "value": 'text/plain; charset="UTF-8"; format=flowed; delsp=yes', - }, - {"name": "Content-Transfer-Encoding", "value": "base64"}, - ], - "body": { - "size": 9, - "data": "dGVzdCBkYXRh", - }, - }, - { - "partId": "1", - "mimeType": "text/html", - "filename": "", - "headers": [ - {"name": "Content-Type", "value": 'text/html; charset="UTF-8"'}, - { - "name": "Content-Transfer-Encoding", - "value": "quoted-printable", - }, - ], - "body": { - "size": 9, - "data": "dGVzdCBkYXRh", - }, - }, - ], - }, - "sizeEstimate": 12048, - "historyId": "697762", - "internalDate": "1703691529000", - } - doc = connector._email_to_document(full_email) - assert type(doc) == Document - assert doc.source == DocumentSource.GMAIL - assert doc.title == "Danswer Test Subject" - assert doc.doc_updated_at == datetime.datetime( - 2023, 12, 27, 15, 38, 49, tzinfo=datetime.timezone.utc - ) - assert doc.metadata == { - "labels": email_labels, - "from": email_sender, - "to": email_recipient, - "date": email_date, - "subject": email_subject, - } - - -def test_fetch_mails_from_gmail_empty(mocker: MockFixture) -> None: - mock_discovery = mocker.patch("danswer.connectors.gmail.connector.discovery") - mock_discovery.build.return_value.users.return_value.messages.return_value.list.return_value.execute.return_value = { - "messages": [] - } - connector = GmailConnector() - connector.creds = mocker.Mock() - with pytest.raises(StopIteration): - next(connector.load_from_state()) - - -def test_fetch_mails_from_gmail(mocker: MockFixture) -> None: - mock_discovery = mocker.patch("danswer.connectors.gmail.connector.discovery") - email_id = "18cabedb1ea46b03" - email_subject = "Danswer Test Subject" - email_sender = "Google " - email_recipient = "test.mail@gmail.com" - mock_discovery.build.return_value.users.return_value.messages.return_value.list.return_value.execute.return_value = { - "messages": [{"id": email_id, "threadId": email_id}], - "nextPageToken": "14473313008248105741", - "resultSizeEstimate": 201, - } - mock_discovery.build.return_value.users.return_value.messages.return_value.get.return_value.execute.return_value = { - "id": email_id, - "threadId": email_id, - "labelIds": ["UNREAD", "IMPORTANT", "CATEGORY_UPDATES", "STARRED", "INBOX"], - "snippet": "A new sign-in. We noticed a new sign-in to your Google Account. If this was you, you don't need to do", - "payload": { - "partId": "", - "mimeType": "multipart/alternative", - "filename": "", - "headers": [ - {"name": "Delivered-To", "value": email_recipient}, - {"name": "Date", "value": "Wed, 27 Dec 2023 15:38:49 GMT"}, - { - "name": "Message-ID", - "value": "", - }, - {"name": "Subject", "value": email_subject}, - {"name": "From", "value": email_sender}, - {"name": "To", "value": email_recipient}, - ], - "body": {"size": 0}, - "parts": [ - { - "partId": "0", - "mimeType": "text/plain", - "filename": "", - "headers": [ - { - "name": "Content-Type", - "value": 'text/plain; charset="UTF-8"; format=flowed; delsp=yes', - }, - {"name": "Content-Transfer-Encoding", "value": "base64"}, - ], - "body": { - "size": 9, - "data": "dGVzdCBkYXRh", - }, - }, - { - "partId": "1", - "mimeType": "text/html", - "filename": "", - "headers": [ - {"name": "Content-Type", "value": 'text/html; charset="UTF-8"'}, - { - "name": "Content-Transfer-Encoding", - "value": "quoted-printable", - }, - ], - "body": { - "size": 9, - "data": "dGVzdCBkYXRh", - }, - }, - ], - }, - "sizeEstimate": 12048, - "historyId": "697762", - "internalDate": "1703691529000", - } - - connector = GmailConnector() - connector.creds = mocker.Mock() - docs = next(connector.load_from_state()) - assert len(docs) == 1 - doc: Document = docs[0] - assert type(doc) == Document - assert doc.id == email_id - assert doc.title == email_subject - assert email_recipient in doc.sections[0].text - assert email_sender in doc.sections[0].text - - -def test_build_time_range_query() -> None: - time_range_start = 1703066296.159339 - time_range_end = 1704984791.657404 - query = GmailConnector._build_time_range_query(time_range_start, time_range_end) - assert query == "after:1703066296 before:1704984791" - query = GmailConnector._build_time_range_query(time_range_start, None) - assert query == "after:1703066296" - query = GmailConnector._build_time_range_query(None, time_range_end) - assert query == "before:1704984791" - query = GmailConnector._build_time_range_query(0.0, time_range_end) - assert query == "before:1704984791" - query = GmailConnector._build_time_range_query(None, None) - assert query is None - - -def test_time_str_to_utc() -> None: - str_to_dt = { - "Tue, 5 Oct 2021 09:38:25 GMT": datetime.datetime( - 2021, 10, 5, 9, 38, 25, tzinfo=datetime.timezone.utc - ), - "Sat, 24 Jul 2021 09:21:20 +0000 (UTC)": datetime.datetime( - 2021, 7, 24, 9, 21, 20, tzinfo=datetime.timezone.utc - ), - "Thu, 29 Jul 2021 04:20:37 -0400 (EDT)": datetime.datetime( - 2021, 7, 29, 8, 20, 37, tzinfo=datetime.timezone.utc - ), - "30 Jun 2023 18:45:01 +0300": datetime.datetime( - 2023, 6, 30, 15, 45, 1, tzinfo=datetime.timezone.utc - ), - "22 Mar 2020 20:12:18 +0000 (GMT)": datetime.datetime( - 2020, 3, 22, 20, 12, 18, tzinfo=datetime.timezone.utc - ), - } - for strptime, expected_datetime in str_to_dt.items(): - assert time_str_to_utc(strptime) == expected_datetime diff --git a/backend/tests/unit/danswer/connectors/mediawiki/__init__.py b/backend/tests/unit/danswer/connectors/mediawiki/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/backend/tests/unit/danswer/connectors/mediawiki/test_mediawiki_family.py b/backend/tests/unit/danswer/connectors/mediawiki/test_mediawiki_family.py deleted file mode 100644 index 35a189f6dd4..00000000000 --- a/backend/tests/unit/danswer/connectors/mediawiki/test_mediawiki_family.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Final - -import pytest -from pytest_mock import MockFixture -from pywikibot.families.wikipedia_family import Family as WikipediaFamily # type: ignore[import-untyped] -from pywikibot.family import Family # type: ignore[import-untyped] - -from danswer.connectors.mediawiki import family - -NON_BUILTIN_WIKIS: Final[list[tuple[str, str]]] = [ - ("https://fallout.fandom.com", "falloutwiki"), - ("https://harrypotter.fandom.com/wiki/", "harrypotterwiki"), - ("https://artofproblemsolving.com/wiki", "artofproblemsolving"), - ("https://www.bogleheads.org/wiki/Main_Page", "bogleheadswiki"), - ("https://bogleheads.org/wiki/Main_Page", "bogleheadswiki"), - ("https://www.dandwiki.com/wiki/", "dungeonsanddragons"), - ("https://wiki.factorio.com/", "factoriowiki"), -] - - -# TODO: Add support for more builtin family types from `pywikibot.families`. -@pytest.mark.parametrize( - "url, name, expected", - [ - ( - "https://en.wikipedia.org", - "wikipedia", - WikipediaFamily, - ), # Support urls with protocol - ( - "wikipedia.org", - "wikipedia", - WikipediaFamily, - ), # Support urls without subdomain - ( - "en.wikipedia.org", - "wikipedia", - WikipediaFamily, - ), # Support urls with subdomain - ("m.wikipedia.org", "wikipedia", WikipediaFamily), - ("de.wikipedia.org", "wikipedia", WikipediaFamily), - ], -) -def test_family_class_dispatch_builtins( - url: str, name: str, expected: type[Family] -) -> None: - """Test that the family class dispatch function returns the correct family class in several scenarios.""" - assert family.family_class_dispatch(url, name) == expected - - -@pytest.mark.parametrize("url, name", NON_BUILTIN_WIKIS) -def test_family_class_dispatch_on_non_builtins_generates_new_class_fast( - url: str, name: str, mocker: MockFixture -) -> None: - """Test that using the family class dispatch function on an unknown url generates a new family class.""" - mock_generate_family_class = mocker.patch.object(family, "generate_family_class") - family.family_class_dispatch(url, name) - mock_generate_family_class.assert_called_once_with(url, name) - - -@pytest.mark.slow -@pytest.mark.parametrize("url, name", NON_BUILTIN_WIKIS) -def test_family_class_dispatch_on_non_builtins_generates_new_class_slow( - url: str, name: str -) -> None: - """Test that using the family class dispatch function on an unknown url generates a new family class. - - This test is slow because it actually performs the network calls to generate the family classes. - """ - generated_family_class = family.generate_family_class(url, name) - assert issubclass(generated_family_class, Family) - dispatch_family_class = family.family_class_dispatch(url, name) - assert dispatch_family_class == generated_family_class diff --git a/backend/tests/unit/danswer/connectors/mediawiki/test_wiki.py b/backend/tests/unit/danswer/connectors/mediawiki/test_wiki.py deleted file mode 100644 index 2a2c841a466..00000000000 --- a/backend/tests/unit/danswer/connectors/mediawiki/test_wiki.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -import datetime -from collections.abc import Iterable - -import pytest -import pywikibot # type: ignore[import-untyped] -from pytest_mock import MockFixture - -from danswer.connectors.mediawiki import wiki - - -@pytest.fixture -def site() -> pywikibot.Site: - return pywikibot.Site("en", "wikipedia") - - -def test_pywikibot_timestamp_to_utc_datetime() -> None: - timestamp_without_tzinfo = pywikibot.Timestamp(2023, 12, 27, 15, 38, 49) - timestamp_min_timezone = timestamp_without_tzinfo.astimezone(datetime.timezone.min) - timestamp_max_timezone = timestamp_without_tzinfo.astimezone(datetime.timezone.max) - assert timestamp_min_timezone.tzinfo == datetime.timezone.min - assert timestamp_max_timezone.tzinfo == datetime.timezone.max - for timestamp in [ - timestamp_without_tzinfo, - timestamp_min_timezone, - timestamp_max_timezone, - ]: - dt = wiki.pywikibot_timestamp_to_utc_datetime(timestamp) - assert dt.tzinfo == datetime.timezone.utc - - -class MockPage(pywikibot.Page): - def __init__( - self, site: pywikibot.Site, title: str, _has_categories: bool = False - ) -> None: - super().__init__(site, title) - self._has_categories = _has_categories - self.header = "This is a header" - self._sections = ["This is a section", "This is another section"] - - @property - def _sections_helper(self) -> list[str]: - return [ - f"== Section {i} ==\n{section}\n" - for i, section in enumerate(self._sections) - ] - - @property - def text(self) -> str: - text = self.header + "\n" - for section in self._sections_helper: - text += section - return text - - @property - def pageid(self) -> str: - return "1" - - def full_url(self) -> str: - return "Test URL" - - def categories( - self, - with_sort_key: bool = False, - total: int | None = None, - content: bool = False, - ) -> Iterable[pywikibot.Page]: - if not self._has_categories: - return [] - return [ - MockPage(self.site, "Test Category1"), - MockPage(self.site, "Test Category2"), - ] - - @property - def latest_revision(self) -> pywikibot.page.Revision: - return pywikibot.page.Revision( - timestamp=pywikibot.Timestamp(2023, 12, 27, 15, 38, 49) - ) - - -def test_get_doc_from_page(site: pywikibot.Site) -> None: - test_page = MockPage(site, "Test Page", _has_categories=True) - doc = wiki.get_doc_from_page(test_page, site, wiki.DocumentSource.MEDIAWIKI) - assert doc.source == wiki.DocumentSource.MEDIAWIKI - assert doc.title == test_page.title() - assert doc.doc_updated_at == wiki.pywikibot_timestamp_to_utc_datetime( - test_page.latest_revision.timestamp - ) - assert len(doc.sections) == 3 - for section, expected_section in zip( - doc.sections, test_page._sections_helper + [test_page.header] - ): - assert ( - section.text.strip() == expected_section.strip() - ) # Extra whitespace before/after is okay - assert section.link and section.link.startswith(test_page.full_url()) - assert doc.semantic_identifier == test_page.title() - assert doc.metadata == { - "categories": [category.title() for category in test_page.categories()] - } - assert doc.id == test_page.pageid - - -def test_mediawiki_connector_recurse_depth() -> None: - """Test that the recurse_depth parameter is parsed correctly. - - -1 should be parsed as `True` (for unbounded recursion) - 0 or greater should be parsed as an integer - Negative values less than -1 should raise a ValueError - - This is the specification dictated by the `pywikibot` library. We do not need to test behavior beyond this. - """ - hostname = "wikipedia.org" - categories: list[str] = [] - pages = ["Test Page"] - - # Recurse depth less than -1 raises ValueError - with pytest.raises(ValueError): - recurse_depth = -2 - wiki.MediaWikiConnector(hostname, categories, pages, recurse_depth) - - # Recurse depth of -1 gets parsed as `True` - recurse_depth = -1 - connector = wiki.MediaWikiConnector(hostname, categories, pages, recurse_depth) - assert connector.recurse_depth is True - - # Recurse depth of 0 or greater gets parsed as an integer - recurse_depth = 0 - connector = wiki.MediaWikiConnector(hostname, categories, pages, recurse_depth) - assert connector.recurse_depth == recurse_depth - - -def test_load_from_state_calls_poll_source_with_nones(mocker: MockFixture) -> None: - connector = wiki.MediaWikiConnector("wikipedia.org", [], [], 0, "test") - poll_source = mocker.patch.object(connector, "poll_source") - connector.load_from_state() - poll_source.assert_called_once_with(None, None) diff --git a/backend/tests/unit/danswer/direct_qa/test_qa_utils.py b/backend/tests/unit/danswer/direct_qa/test_qa_utils.py deleted file mode 100644 index d3974fe47ab..00000000000 --- a/backend/tests/unit/danswer/direct_qa/test_qa_utils.py +++ /dev/null @@ -1,194 +0,0 @@ -import textwrap - -import pytest - -from danswer.configs.constants import DocumentSource -from danswer.llm.answering.stream_processing.quotes_processing import ( - match_quotes_to_docs, -) -from danswer.llm.answering.stream_processing.quotes_processing import ( - separate_answer_quotes, -) -from danswer.search.models import InferenceChunk - - -def test_separate_answer_quotes() -> None: - # Test case 1: Basic quote separation - test_answer = textwrap.dedent( - """ - It seems many people love dogs - Quote: A dog is a man's best friend - Quote: Air Bud was a movie about dogs and people loved it - """ - ).strip() - answer, quotes = separate_answer_quotes(test_answer) - assert answer == "It seems many people love dogs" - assert isinstance(quotes, list) - assert quotes[0] == "A dog is a man's best friend" - assert quotes[1] == "Air Bud was a movie about dogs and people loved it" - - # Test case 2: Lowercase 'quote' allowed - test_answer = textwrap.dedent( - """ - It seems many people love dogs - quote: A dog is a man's best friend - Quote: Air Bud was a movie about dogs and people loved it - """ - ).strip() - answer, quotes = separate_answer_quotes(test_answer) - assert answer == "It seems many people love dogs" - assert isinstance(quotes, list) - assert quotes[0] == "A dog is a man's best friend" - assert quotes[1] == "Air Bud was a movie about dogs and people loved it" - - # Test case 3: No Answer - test_answer = textwrap.dedent( - """ - Quote: This one has no answer - """ - ).strip() - answer, quotes = separate_answer_quotes(test_answer) - assert answer is None - assert quotes is None - - # Test case 4: Multiline Quote - test_answer = textwrap.dedent( - """ - It seems many people love dogs - quote: A well known saying is: - A dog is a man's best friend - Quote: Air Bud was a movie about dogs and people loved it - """ - ).strip() - answer, quotes = separate_answer_quotes(test_answer) - assert answer == "It seems many people love dogs" - assert isinstance(quotes, list) - assert quotes[0] == "A well known saying is:\nA dog is a man's best friend" - assert quotes[1] == "Air Bud was a movie about dogs and people loved it" - - # Test case 5: Random patterns not picked up - test_answer = textwrap.dedent( - """ - It seems many people love quote: dogs - quote: Quote: A well known saying is: - A dog is a man's best friend - Quote: Answer: Air Bud was a movie about dogs and quote: people loved it - """ - ).strip() - answer, quotes = separate_answer_quotes(test_answer) - assert answer == "It seems many people love quote: dogs" - assert isinstance(quotes, list) - assert quotes[0] == "Quote: A well known saying is:\nA dog is a man's best friend" - assert ( - quotes[1] == "Answer: Air Bud was a movie about dogs and quote: people loved it" - ) - - -@pytest.mark.skip( - reason="Using fuzzy match is too slow anyway, doesn't matter if it's broken" -) -def test_fuzzy_match_quotes_to_docs() -> None: - chunk_0_text = textwrap.dedent( - """ - Here's a doc with some LINK embedded in the text - THIS SECTION IS A LINK - Some more text - """ - ).strip() - chunk_1_text = textwrap.dedent( - """ - Some completely different text here - ANOTHER LINK embedded in this text - ending in a DIFFERENT-LINK - """ - ).strip() - test_chunk_0 = InferenceChunk( - document_id="test doc 0", - source_type=DocumentSource.FILE, - chunk_id=0, - content=chunk_0_text, - source_links={ - 0: "doc 0 base", - 23: "first line link", - 49: "second line link", - }, - blurb="anything", - semantic_identifier="anything", - title="whatever", - section_continuation=False, - recency_bias=1, - boost=0, - hidden=False, - score=1, - metadata={}, - match_highlights=[], - updated_at=None, - ) - test_chunk_1 = InferenceChunk( - document_id="test doc 1", - source_type=DocumentSource.FILE, - chunk_id=0, - content=chunk_1_text, - source_links={0: "doc 1 base", 36: "2nd line link", 82: "last link"}, - blurb="whatever", - semantic_identifier="whatever", - title="whatever", - section_continuation=False, - recency_bias=1, - boost=0, - hidden=False, - score=1, - metadata={}, - match_highlights=[], - updated_at=None, - ) - - test_quotes = [ - "a doc with some", # Basic case - "a doc with some LINK", # Should take the start of quote, even if a link is in it - "a doc with some \nLINK", # Requires a newline deletion fuzzy match - "a doc with some link", # Capitalization insensitive - "embedded in this text", # Fuzzy match to first doc - "SECTION IS A LINK", # Match exact link - "some more text", # Match the end, after every link offset - "different taxt", # Substitution - "embedded in this texts", # Cannot fuzzy match to first doc, fuzzy match to second doc - "DIFFERENT-LINK", # Exact link match at the end - "Some complitali", # Too many edits, shouldn't match anything - ] - results = match_quotes_to_docs( - test_quotes, [test_chunk_0, test_chunk_1], fuzzy_search=True - ) - assert results == { - "a doc with some": {"document": "test doc 0", "link": "doc 0 base"}, - "a doc with some LINK": { - "document": "test doc 0", - "link": "doc 0 base", - }, - "a doc with some \nLINK": { - "document": "test doc 0", - "link": "doc 0 base", - }, - "a doc with some link": { - "document": "test doc 0", - "link": "doc 0 base", - }, - "embedded in this text": { - "document": "test doc 0", - "link": "first line link", - }, - "SECTION IS A LINK": { - "document": "test doc 0", - "link": "second line link", - }, - "some more text": { - "document": "test doc 0", - "link": "second line link", - }, - "different taxt": {"document": "test doc 1", "link": "doc 1 base"}, - "embedded in this texts": { - "document": "test doc 1", - "link": "2nd line link", - }, - "DIFFERENT-LINK": {"document": "test doc 1", "link": "last link"}, - } diff --git a/backend/tests/unit/danswer/indexing/test_chunker.py b/backend/tests/unit/danswer/indexing/test_chunker.py deleted file mode 100644 index f3a72fe17a3..00000000000 --- a/backend/tests/unit/danswer/indexing/test_chunker.py +++ /dev/null @@ -1,51 +0,0 @@ -from danswer.configs.constants import DocumentSource -from danswer.connectors.models import Document -from danswer.connectors.models import Section -from danswer.indexing.chunker import Chunker -from danswer.indexing.embedder import DefaultIndexingEmbedder - - -def test_chunk_document() -> None: - short_section_1 = "This is a short section." - long_section = ( - "This is a long section that should be split into multiple chunks. " * 100 - ) - short_section_2 = "This is another short section." - short_section_3 = "This is another short section again." - short_section_4 = "Final short section." - semantic_identifier = "Test Document" - - document = Document( - id="test_doc", - source=DocumentSource.WEB, - semantic_identifier=semantic_identifier, - metadata={"tags": ["tag1", "tag2"]}, - doc_updated_at=None, - sections=[ - Section(text=short_section_1, link="link1"), - Section(text=short_section_2, link="link2"), - Section(text=long_section, link="link3"), - Section(text=short_section_3, link="link4"), - Section(text=short_section_4, link="link5"), - ], - ) - - embedder = DefaultIndexingEmbedder( - model_name="intfloat/e5-base-v2", - normalize=True, - query_prefix=None, - passage_prefix=None, - ) - - chunker = Chunker( - tokenizer=embedder.embedding_model.tokenizer, - enable_multipass=False, - ) - chunks = chunker.chunk(document) - - assert len(chunks) == 5 - assert short_section_1 in chunks[0].content - assert short_section_3 in chunks[-1].content - assert short_section_4 in chunks[-1].content - assert "tag1" in chunks[0].metadata_suffix_keyword - assert "tag2" in chunks[0].metadata_suffix_semantic diff --git a/backend/tests/unit/danswer/llm/answering/stream_processing/test_citation_processing.py b/backend/tests/unit/danswer/llm/answering/stream_processing/test_citation_processing.py deleted file mode 100644 index 473ccf2451a..00000000000 --- a/backend/tests/unit/danswer/llm/answering/stream_processing/test_citation_processing.py +++ /dev/null @@ -1,306 +0,0 @@ -from datetime import datetime - -import pytest - -from danswer.chat.models import CitationInfo -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import LlmDoc -from danswer.configs.constants import DocumentSource -from danswer.llm.answering.stream_processing.citation_processing import ( - extract_citations_from_stream, -) -from danswer.llm.answering.stream_processing.utils import DocumentIdOrderMapping - - -""" -This module contains tests for the citation extraction functionality in Danswer. - -The tests focus on the `extract_citations_from_stream` function, which processes -a stream of tokens and extracts citations, replacing them with properly formatted -versions including links where available. - -Key components: -- mock_docs: A list of mock LlmDoc objects used for testing. -- mock_doc_mapping: A dictionary mapping document IDs to their ranks. -- process_text: A helper function that simulates the citation extraction process. -- test_citation_extraction: A parametrized test function covering various citation scenarios. - -To add new test cases: -1. Add a new tuple to the @pytest.mark.parametrize decorator of test_citation_extraction. -2. Each tuple should contain: - - A descriptive test name (string) - - Input tokens (list of strings) - - Expected output text (string) - - Expected citations (list of document IDs) -""" - - -mock_docs = [ - LlmDoc( - document_id=f"doc_{int(id/2)}", - content="Document is a doc", - blurb=f"Document #{id}", - semantic_identifier=f"Doc {id}", - source_type=DocumentSource.WEB, - metadata={}, - updated_at=datetime.now(), - link=f"https://{int(id/2)}.com" if int(id / 2) % 2 == 0 else None, - source_links={0: "https://mintlify.com/docs/settings/broken-links"}, - ) - for id in range(10) -] - -mock_doc_mapping = { - "doc_0": 1, - "doc_1": 2, - "doc_2": 3, - "doc_3": 4, - "doc_4": 5, - "doc_5": 6, -} - - -@pytest.fixture -def mock_data() -> tuple[list[LlmDoc], dict[str, int]]: - return mock_docs, mock_doc_mapping - - -def process_text( - tokens: list[str], mock_data: tuple[list[LlmDoc], dict[str, int]] -) -> tuple[str, list[CitationInfo]]: - mock_docs, mock_doc_id_to_rank_map = mock_data - mapping = DocumentIdOrderMapping(order_mapping=mock_doc_id_to_rank_map) - result = list( - extract_citations_from_stream( - tokens=iter(tokens), - context_docs=mock_docs, - doc_id_to_rank_map=mapping, - stop_stream=None, - ) - ) - final_answer_text = "" - citations = [] - for piece in result: - if isinstance(piece, DanswerAnswerPiece): - final_answer_text += piece.answer_piece or "" - elif isinstance(piece, CitationInfo): - citations.append(piece) - return final_answer_text, citations - - -@pytest.mark.parametrize( - "test_name, input_tokens, expected_text, expected_citations", - [ - ( - "Single citation", - ["Gro", "wth! [", "1", "]", "."], - "Growth! [[1]](https://0.com).", - ["doc_0"], - ), - ( - "Repeated citations", - ["Test! ", "[", "1", "]", ". And so", "me more ", "[", "2", "]", "."], - "Test! [[1]](https://0.com). And some more [[1]](https://0.com).", - ["doc_0"], - ), - ( - "Citations at sentence boundaries", - [ - "Citation at the ", - "end of a sen", - "tence.", - "[", - "2", - "]", - " Another sen", - "tence.", - "[", - "4", - "]", - ], - "Citation at the end of a sentence.[[1]](https://0.com) Another sentence.[[2]]()", - ["doc_0", "doc_1"], - ), - ( - "Citations at beginning, middle, and end", - [ - "[", - "1", - "]", - " Citation at ", - "the beginning. ", - "[", - "3", - "]", - " In the mid", - "dle. At the end ", - "[", - "5", - "]", - ".", - ], - "[[1]](https://0.com) Citation at the beginning. [[2]]() In the middle. At the end [[3]](https://2.com).", - ["doc_0", "doc_1", "doc_2"], - ), - ( - "Mixed valid and invalid citations", - [ - "Mixed valid and in", - "valid citations ", - "[", - "1", - "]", - "[", - "99", - "]", - "[", - "3", - "]", - "[", - "100", - "]", - "[", - "5", - "]", - ".", - ], - "Mixed valid and invalid citations [[1]](https://0.com)[99][[2]]()[100][[3]](https://2.com).", - ["doc_0", "doc_1", "doc_2"], - ), - ( - "Hardest!", - [ - "Multiple cit", - "ations in one ", - "sentence [", - "1", - "]", - "[", - "4", - "]", - "[", - "5", - "]", - ". ", - ], - "Multiple citations in one sentence [[1]](https://0.com)[[2]]()[[3]](https://2.com).", - ["doc_0", "doc_1", "doc_2"], - ), - ( - "Repeated citations with text", - ["[", "1", "]", "Aasf", "asda", "sff ", "[", "1", "]", " ."], - "[[1]](https://0.com)Aasfasdasff [[1]](https://0.com) .", - ["doc_0"], - ), - ( - "Consecutive identical citations!", - [ - "Citations [", - "1", - "]", - "[", - "1]", - "", - "[2", - "", - "]", - ". ", - ], - "Citations [[1]](https://0.com).", - ["doc_0"], - ), - ( - "Consecutive identical citations!", - [ - "test [1]tt[1]t", - "", - ], - "test [[1]](https://0.com)ttt", - ["doc_0"], - ), - ( - "Consecutive identical citations!", - [ - "test [1]t[1]t[1]", - "", - ], - "test [[1]](https://0.com)tt", - ["doc_0"], - ), - ( - "Repeated citations with text", - ["[", "1", "]", "Aasf", "asda", "sff ", "[", "1", "]", " ."], - "[[1]](https://0.com)Aasfasdasff [[1]](https://0.com) .", - ["doc_0"], - ), - ( - "Repeated citations with text", - ["[1][", "1", "]t", "[2]"], - "[[1]](https://0.com)t", - ["doc_0"], - ), - ( - "Repeated citations with text", - ["[1][", "1", "]t]", "[2]"], - "[[1]](https://0.com)t]", - ["doc_0"], - ), - ( - "Repeated citations with text", - ["[1][", "3", "]t]", "[2]"], - "[[1]](https://0.com)[[2]]()t]", - ["doc_0", "doc_1"], - ), - ( - "Repeated citations with text", - ["[1", "][", "3", "]t]", "[2]"], - "[[1]](https://0.com)[[2]]()t]", - ["doc_0", "doc_1"], - ), - ( - "Citations with extraneous citations", - [ - "[[1]](https://0.com) Citation", - " at ", - "the beginning. ", - "[", - "3", - "]", - " In the mid", - "dle. At the end ", - "[", - "5", - "]", - ".", - ], - "[[1]](https://0.com) Citation at the beginning. [[2]]() In the middle. At the end [[3]](https://2.com).", - ["doc_0", "doc_1", "doc_2"], - ), - ( - "Citations with extraneous citations, split up", - [ - "[[1]](", - "https://0.com) Citation at ", - "the beginning. ", - ], - "[[1]](https://0.com) Citation at the beginning. ", - ["doc_0"], - ), - ], -) -def test_citation_extraction( - mock_data: tuple[list[LlmDoc], dict[str, int]], - test_name: str, - input_tokens: list[str], - expected_text: str, - expected_citations: list[str], -) -> None: - final_answer_text, citations = process_text(input_tokens, mock_data) - assert ( - final_answer_text.strip() == expected_text.strip() - ), f"Test '{test_name}' failed: Final answer text does not match expected output." - assert [ - citation.document_id for citation in citations - ] == expected_citations, ( - f"Test '{test_name}' failed: Citations do not match expected output." - ) diff --git a/backend/tests/unit/danswer/llm/answering/stream_processing/test_quote_processing.py b/backend/tests/unit/danswer/llm/answering/stream_processing/test_quote_processing.py deleted file mode 100644 index e80c5c4f657..00000000000 --- a/backend/tests/unit/danswer/llm/answering/stream_processing/test_quote_processing.py +++ /dev/null @@ -1,351 +0,0 @@ -import json -from datetime import datetime - -from danswer.chat.models import DanswerAnswerPiece -from danswer.chat.models import DanswerQuotes -from danswer.chat.models import LlmDoc -from danswer.configs.constants import DocumentSource -from danswer.llm.answering.stream_processing.quotes_processing import ( - process_model_tokens, -) - -mock_docs = [ - LlmDoc( - document_id=f"doc_{int(id/2)}", - content="Document is a doc", - blurb=f"Document #{id}", - semantic_identifier=f"Doc {id}", - source_type=DocumentSource.WEB, - metadata={}, - updated_at=datetime.now(), - link=f"https://{int(id/2)}.com" if int(id / 2) % 2 == 0 else None, - source_links={0: "https://mintlify.com/docs/settings/broken-links"}, - ) - for id in range(10) -] - - -tokens_with_quotes = [ - "{", - "\n ", - '"answer": "Yes', - ", Danswer allows", - " customized prompts. This", - " feature", - " is currently being", - " developed and implemente", - "d to", - " improve", - " the accuracy", - " of", - " Language", - " Models (", - "LL", - "Ms) for", - " different", - " companies", - ".", - " The custom", - "ized prompts feature", - " woul", - "d allow users to ad", - "d person", - "alized prom", - "pts through", - " an", - " interface or", - " metho", - "d,", - " which would then be used to", - " train", - " the LLM.", - " This enhancement", - " aims to make", - " Danswer more", - " adaptable to", - " different", - " business", - " contexts", - " by", - " tail", - "oring it", - " to the specific language", - " an", - "d terminology", - " used within", - " a", - " company.", - " Additionally", - ",", - " Danswer already", - " supports creating", - " custom AI", - " Assistants with", - " different", - " prom", - "pts and backing", - " knowledge", - " sets", - ",", - " which", - " is", - " a form", - " of prompt", - " customization. However, it", - "'s important to nLogging Details LiteLLM-Success Call: Noneote that some", - " aspects", - " of prompt", - " customization,", - " such as for", - " Sl", - "ack", - "b", - "ots, may", - " still", - " be in", - " development or have", - ' limitations.",', - '\n "quotes": [', - '\n "We', - " woul", - "d like to ad", - "d customized prompts for", - " different", - " companies to improve the accuracy of", - " Language", - " Model", - " (LLM)", - '.",\n "A', - " new", - " feature that", - " allows users to add personalize", - "d prompts.", - " This would involve", - " creating", - " an interface or method for", - " users to input", - " their", - " own", - " prom", - "pts,", - " which would then be used to", - ' train the LLM.",', - '\n "Create', - " custom AI Assistants with", - " different prompts and backing knowledge", - ' sets.",', - '\n "This', - " PR", - " fixes", - " https", - "://github.com/dan", - "swer-ai/dan", - "swer/issues/1", - "584", - " by", - " setting", - " the system", - " default", - " prompt for", - " sl", - "ackbots const", - "rained by", - " ", - "document sets", - ".", - " It", - " probably", - " isn", - "'t ideal", - " -", - " it", - " might", - " be pref", - "erable to be", - " able to select", - " a prompt for", - " the", - " slackbot from", - " the", - " admin", - " panel", - " -", - " but it sol", - "ves the immediate problem", - " of", - " the slack", - " listener", - " cr", - "ashing when", - " configure", - "d this", - ' way."\n ]', - "\n}", - "", -] - - -def test_process_model_tokens_answer() -> None: - gen = process_model_tokens(tokens=iter(tokens_with_quotes), context_docs=mock_docs) - - s_json = "".join(tokens_with_quotes) - j = json.loads(s_json) - expected_answer = j["answer"] - actual = "" - for o in gen: - if isinstance(o, DanswerAnswerPiece): - if o.answer_piece: - actual += o.answer_piece - - assert expected_answer == actual - - -def test_simple_json_answer() -> None: - tokens = [ - "```", - "json", - "\n", - "{", - '"answer": "This is a simple ', - "answer.", - '",\n"', - 'quotes": []', - "\n}", - "\n", - "```", - ] - gen = process_model_tokens(tokens=iter(tokens), context_docs=mock_docs) - - expected_answer = "This is a simple answer." - actual = "".join( - o.answer_piece - for o in gen - if isinstance(o, DanswerAnswerPiece) and o.answer_piece - ) - - assert expected_answer == actual - - -def test_json_answer_with_quotes() -> None: - tokens = [ - "```", - "json", - "\n", - "{", - '"answer": "This ', - "is a ", - "split ", - "answer.", - '",\n"', - 'quotes": []', - "\n}", - "\n", - "```", - ] - gen = process_model_tokens(tokens=iter(tokens), context_docs=mock_docs) - - expected_answer = "This is a split answer." - actual = "".join( - o.answer_piece - for o in gen - if isinstance(o, DanswerAnswerPiece) and o.answer_piece - ) - - assert expected_answer == actual - - -def test_json_answer_split_tokens() -> None: - tokens = [ - "```", - "json", - "\n", - "{", - '\n"', - 'answer": "This ', - "is a ", - "split ", - "answer.", - '",\n"', - 'quotes": []', - "\n}", - "\n", - "```", - ] - gen = process_model_tokens(tokens=iter(tokens), context_docs=mock_docs) - - expected_answer = "This is a split answer." - actual = "".join( - o.answer_piece - for o in gen - if isinstance(o, DanswerAnswerPiece) and o.answer_piece - ) - - assert expected_answer == actual - - -def test_lengthy_prefixed_json_with_quotes() -> None: - tokens = [ - "This is my response in json\n\n", - "```", - "json", - "\n", - "{", - '"answer": "This is a simple ', - "answer.", - '",\n"', - 'quotes": ["Document"]', - "\n}", - "\n", - "```", - ] - - gen = process_model_tokens(tokens=iter(tokens), context_docs=mock_docs) - - actual_answer = "" - actual_count = 0 - for o in gen: - if isinstance(o, DanswerAnswerPiece): - if o.answer_piece: - actual_answer += o.answer_piece - continue - - if isinstance(o, DanswerQuotes): - for q in o.quotes: - assert q.quote == "Document" - actual_count += 1 - assert "This is a simple answer." == actual_answer - assert 1 == actual_count - - -def test_prefixed_json_with_quotes() -> None: - tokens = [ - "```", - "json", - "\n", - "{", - '"answer": "This is a simple ', - "answer.", - '",\n"', - 'quotes": ["Document"]', - "\n}", - "\n", - "```", - ] - - gen = process_model_tokens(tokens=iter(tokens), context_docs=mock_docs) - - actual_answer = "" - actual_count = 0 - for o in gen: - if isinstance(o, DanswerAnswerPiece): - if o.answer_piece: - actual_answer += o.answer_piece - continue - - if isinstance(o, DanswerQuotes): - for q in o.quotes: - assert q.quote == "Document" - actual_count += 1 - - assert "This is a simple answer." == actual_answer - assert 1 == actual_count diff --git a/backend/tests/unit/danswer/llm/answering/test_prune_and_merge.py b/backend/tests/unit/danswer/llm/answering/test_prune_and_merge.py deleted file mode 100644 index 9d28339a1f5..00000000000 --- a/backend/tests/unit/danswer/llm/answering/test_prune_and_merge.py +++ /dev/null @@ -1,230 +0,0 @@ -import pytest - -from danswer.configs.constants import DocumentSource -from danswer.llm.answering.prune_and_merge import _merge_sections -from danswer.search.models import InferenceChunk -from danswer.search.models import InferenceSection - - -# This large test accounts for all of the following: -# 1. Merging of adjacent sections -# 2. Merging of non-adjacent sections -# 3. Merging of sections where there are multiple documents -# 4. Verifying the contents of merged sections -# 5. Verifying the order/score of the merged sections - - -def create_inference_chunk( - document_id: str, chunk_id: int, content: str, score: float | None -) -> InferenceChunk: - """ - Create an InferenceChunk with hardcoded values for testing purposes. - """ - return InferenceChunk( - chunk_id=chunk_id, - document_id=document_id, - semantic_identifier=f"{document_id}_{chunk_id}", - title="whatever", - blurb=f"{document_id}_{chunk_id}", - content=content, - source_links={0: "fake_link"}, - section_continuation=False, - source_type=DocumentSource.WEB, - boost=0, - recency_bias=1.0, - score=score, - hidden=False, - metadata={}, - match_highlights=[], - updated_at=None, - ) - - -# Document 1, top connected sections -DOC_1_FILLER_1 = create_inference_chunk("doc1", 2, "Content 2", 1.0) -DOC_1_FILLER_2 = create_inference_chunk("doc1", 3, "Content 3", 2.0) -DOC_1_TOP_CHUNK = create_inference_chunk("doc1", 4, "Content 4", None) -DOC_1_MID_CHUNK = create_inference_chunk("doc1", 5, "Content 5", 4.0) -DOC_1_FILLER_3 = create_inference_chunk("doc1", 6, "Content 6", 5.0) -DOC_1_FILLER_4 = create_inference_chunk("doc1", 7, "Content 7", 6.0) -# This chunk below has the top score for testing -DOC_1_BOTTOM_CHUNK = create_inference_chunk("doc1", 8, "Content 8", 70.0) -DOC_1_FILLER_5 = create_inference_chunk("doc1", 9, "Content 9", None) -DOC_1_FILLER_6 = create_inference_chunk("doc1", 10, "Content 10", 9.0) -# Document 1, separate section -DOC_1_FILLER_7 = create_inference_chunk("doc1", 13, "Content 13", 10.0) -DOC_1_FILLER_8 = create_inference_chunk("doc1", 14, "Content 14", 11.0) -DOC_1_DISCONNECTED = create_inference_chunk("doc1", 15, "Content 15", 12.0) -DOC_1_FILLER_9 = create_inference_chunk("doc1", 16, "Content 16", 13.0) -DOC_1_FILLER_10 = create_inference_chunk("doc1", 17, "Content 17", 14.0) -# Document 2 -DOC_2_FILLER_1 = create_inference_chunk("doc2", 1, "Doc 2 Content 1", 15.0) -DOC_2_FILLER_2 = create_inference_chunk("doc2", 2, "Doc 2 Content 2", 16.0) -# This chunk below has top score for testing -DOC_2_TOP_CHUNK = create_inference_chunk("doc2", 3, "Doc 2 Content 3", 170.0) -DOC_2_FILLER_3 = create_inference_chunk("doc2", 4, "Doc 2 Content 4", 18.0) -DOC_2_BOTTOM_CHUNK = create_inference_chunk("doc2", 5, "Doc 2 Content 5", 19.0) -DOC_2_FILLER_4 = create_inference_chunk("doc2", 6, "Doc 2 Content 6", 20.0) -DOC_2_FILLER_5 = create_inference_chunk("doc2", 7, "Doc 2 Content 7", 21.0) - - -# Doc 2 has the highest score so it comes first -EXPECTED_CONTENT_1 = """ -Doc 2 Content 1 -Doc 2 Content 2 -Doc 2 Content 3 -Doc 2 Content 4 -Doc 2 Content 5 -Doc 2 Content 6 -Doc 2 Content 7 -""".strip() - - -EXPECTED_CONTENT_2 = """ -Content 2 -Content 3 -Content 4 -Content 5 -Content 6 -Content 7 -Content 8 -Content 9 -Content 10 - -... - -Content 13 -Content 14 -Content 15 -Content 16 -Content 17 -""".strip() - - -@pytest.mark.parametrize( - "sections,expected_contents,expected_center_chunks", - [ - ( - # Sections - [ - # Document 1, top/middle/bot connected + disconnected section - InferenceSection( - center_chunk=DOC_1_TOP_CHUNK, - chunks=[ - DOC_1_FILLER_1, - DOC_1_FILLER_2, - DOC_1_TOP_CHUNK, - DOC_1_MID_CHUNK, - DOC_1_FILLER_3, - ], - combined_content="N/A", # Not used - ), - InferenceSection( - center_chunk=DOC_1_MID_CHUNK, - chunks=[ - DOC_1_FILLER_2, - DOC_1_TOP_CHUNK, - DOC_1_MID_CHUNK, - DOC_1_FILLER_3, - DOC_1_FILLER_4, - ], - combined_content="N/A", - ), - InferenceSection( - center_chunk=DOC_1_BOTTOM_CHUNK, - chunks=[ - DOC_1_FILLER_3, - DOC_1_FILLER_4, - DOC_1_BOTTOM_CHUNK, - DOC_1_FILLER_5, - DOC_1_FILLER_6, - ], - combined_content="N/A", - ), - InferenceSection( - center_chunk=DOC_1_DISCONNECTED, - chunks=[ - DOC_1_FILLER_7, - DOC_1_FILLER_8, - DOC_1_DISCONNECTED, - DOC_1_FILLER_9, - DOC_1_FILLER_10, - ], - combined_content="N/A", - ), - InferenceSection( - center_chunk=DOC_2_TOP_CHUNK, - chunks=[ - DOC_2_FILLER_1, - DOC_2_FILLER_2, - DOC_2_TOP_CHUNK, - DOC_2_FILLER_3, - DOC_2_BOTTOM_CHUNK, - ], - combined_content="N/A", - ), - InferenceSection( - center_chunk=DOC_2_BOTTOM_CHUNK, - chunks=[ - DOC_2_TOP_CHUNK, - DOC_2_FILLER_3, - DOC_2_BOTTOM_CHUNK, - DOC_2_FILLER_4, - DOC_2_FILLER_5, - ], - combined_content="N/A", - ), - ], - # Expected Content - [EXPECTED_CONTENT_1, EXPECTED_CONTENT_2], - # Expected Center Chunks (highest scores) - [DOC_2_TOP_CHUNK, DOC_1_BOTTOM_CHUNK], - ), - ], -) -def test_merge_sections( - sections: list[InferenceSection], - expected_contents: list[str], - expected_center_chunks: list[InferenceChunk], -) -> None: - sections.sort(key=lambda section: section.center_chunk.score or 0, reverse=True) - merged_sections = _merge_sections(sections) - assert merged_sections[0].combined_content == expected_contents[0] - assert merged_sections[1].combined_content == expected_contents[1] - assert merged_sections[0].center_chunk == expected_center_chunks[0] - assert merged_sections[1].center_chunk == expected_center_chunks[1] - - -@pytest.mark.parametrize( - "sections,expected_content,expected_center_chunk", - [ - ( - # Sections - [ - InferenceSection( - center_chunk=DOC_1_TOP_CHUNK, - chunks=[DOC_1_TOP_CHUNK], - combined_content="N/A", # Not used - ), - InferenceSection( - center_chunk=DOC_1_MID_CHUNK, - chunks=[DOC_1_MID_CHUNK], - combined_content="N/A", - ), - ], - # Expected Content - "Content 4\nContent 5", - # Expected Center Chunks (highest scores) - DOC_1_MID_CHUNK, - ), - ], -) -def test_merge_minimal_sections( - sections: list[InferenceSection], - expected_content: str, - expected_center_chunk: InferenceChunk, -) -> None: - sections.sort(key=lambda section: section.center_chunk.score or 0, reverse=True) - merged_sections = _merge_sections(sections) - assert merged_sections[0].combined_content == expected_content - assert merged_sections[0].center_chunk == expected_center_chunk diff --git a/backend/throttle.ctrl b/backend/throttle.ctrl deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/deployment/.gitignore b/deployment/.gitignore deleted file mode 100644 index ddc4da4ba4b..00000000000 --- a/deployment/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.env* -secrets.yaml diff --git a/deployment/README.md b/deployment/README.md deleted file mode 100644 index 9454cbbe5ea..00000000000 --- a/deployment/README.md +++ /dev/null @@ -1,80 +0,0 @@ - - -# Deploying Danswer -The two options provided here are the easiest ways to get Danswer up and running. - -- Docker Compose is simpler and default values are already preset to run right out of the box with a single command. -As everything is running on a single machine, this may not be as scalable depending on your hardware, traffic and needs. - -- Kubernetes deployment is also provided. Depending on your existing infrastructure, this may be more suitable for -production deployment but there are a few caveats. - - User auth is turned on by default for Kubernetes (with the assumption this is for production use) - so you must either update the deployments to turn off user auth or provide the values shown in the example - secrets.yaml file. - - The example provided assumes a blank slate for existing Kubernetes deployments/services. You may need to adjust the - deployments or services according to your setup. This may require existing Kubernetes knowledge or additional - setup time. - -All the features of Danswer are fully available regardless of the deployment option. - -For information on setting up connectors, check out https://docs.danswer.dev/connectors/overview - - -## Docker Compose: -Docker Compose provides the easiest way to get Danswer up and running. - -Requirements: Docker and docker compose - -This section is for getting started quickly without setting up GPUs. For deployments to leverage GPU, please refer to [this](https://github.com/danswer-ai/danswer/blob/main/deployment/docker_compose/README.md) documentation. - -1. To run Danswer, navigate to `docker_compose` directory and run the following: - - `docker compose -f docker-compose.dev.yml -p danswer-stack up -d --pull always --force-recreate` - - or run: `docker compose -f docker-compose.dev.yml -p danswer-stack up -d --build --force-recreate` -to build from source - - Downloading images or packages/requirements may take 15+ minutes depending on your internet connection. - - -2. To shut down the deployment, run: - - To stop the containers: `docker compose -f docker-compose.dev.yml -p danswer-stack stop` - - To delete the containers: `docker compose -f docker-compose.dev.yml -p danswer-stack down` - - -3. To completely remove Danswer run: - - **WARNING, this will also erase your indexed data and users** - - `docker compose -f docker-compose.dev.yml -p danswer-stack down -v` - - -Additional steps for user auth and https if you do want to use Docker Compose for production: - -1. Set up a `.env` file in this directory with relevant environment variables. - - Refer to `env.prod.template` - - To turn on user auth, set: - - GOOGLE_OAUTH_CLIENT_ID=\ - - GOOGLE_OAUTH_CLIENT_SECRET=\ - - Refer to https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid - -2. Set up https: - - Set up a `.env.nginx` file in this directory based on `env.nginx.template`. - - `chmod +x init-letsencrypt.sh` + `./init-letsencrypt.sh` to set up https certificate. - -3. Follow the above steps but replacing dev with prod. - - -## Kubernetes: -Depending on your deployment needs Kubernetes may be more suitable. The yamls provided will work out of the box but the -intent is for you to customize the deployment to fit your own needs. There is no data replication or auto-scaling built -in for the provided example. - -Requirements: a Kubernetes cluster and kubectl - -**NOTE: This setup does not explicitly enable https, the assumption is you would have this already set up for your -prod cluster** - -1. To run Danswer, navigate to `kubernetes` directory and run the following: - - `kubectl apply -f .` - -2. To remove Danswer, run: - - **WARNING, this will also erase your indexed data and users** - - `kubectl delete -f .` - - To not delete the persistent volumes (Document indexes and Users), specify the specific `.yaml` files instead of - `.` without specifying delete on persistent-volumes.yaml. diff --git a/deployment/data/nginx/app.conf.template b/deployment/data/nginx/app.conf.template deleted file mode 100644 index b698c744bf5..00000000000 --- a/deployment/data/nginx/app.conf.template +++ /dev/null @@ -1,90 +0,0 @@ -# Log format to include request latency -log_format custom_main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for" ' - 'rt=$request_time'; - -upstream api_server { - # fail_timeout=0 means we always retry an upstream even if it failed - # to return a good HTTP response - - # for UNIX domain socket setups - #server unix:/tmp/gunicorn.sock fail_timeout=0; - - # for a TCP configuration - # TODO: use gunicorn to manage multiple processes - server api_server:8080 fail_timeout=0; -} - -upstream web_server { - server web_server:3000 fail_timeout=0; -} - -server { - listen 80; - server_name ${DOMAIN}; - - client_max_body_size 5G; # Maximum upload size - - access_log /var/log/nginx/access.log custom_main; - - # Match both /api/* and /openapi.json in a single rule - location ~ ^/(api|openapi.json)(/.*)?$ { - # Rewrite /api prefixed matched paths - rewrite ^/api(/.*)$ $1 break; - - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - # need to use 1.1 to support chunked transfers - proxy_http_version 1.1; - proxy_buffering off; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://api_server; - } - - location / { - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - proxy_http_version 1.1; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://web_server; - } - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } -} - -server { - listen 443 ssl; - server_name ${DOMAIN}; - - client_max_body_size 5G; # Maximum upload size - - location / { - proxy_http_version 1.1; - proxy_buffering off; - proxy_pass http://localhost:80; - } - - ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; -} diff --git a/deployment/data/nginx/app.conf.template.dev b/deployment/data/nginx/app.conf.template.dev deleted file mode 100644 index a7a0efa192b..00000000000 --- a/deployment/data/nginx/app.conf.template.dev +++ /dev/null @@ -1,69 +0,0 @@ -# Override log format to include request latency -log_format custom_main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for" ' - 'rt=$request_time'; - -upstream api_server { - # fail_timeout=0 means we always retry an upstream even if it failed - # to return a good HTTP response - - # for UNIX domain socket setups - #server unix:/tmp/gunicorn.sock fail_timeout=0; - - # for a TCP configuration - # TODO: use gunicorn to manage multiple processes - server api_server:8080 fail_timeout=0; -} - -upstream web_server { - server web_server:3000 fail_timeout=0; -} - -server { - listen 80; - server_name ${DOMAIN}; - - client_max_body_size 5G; # Maximum upload size - - access_log /var/log/nginx/access.log custom_main; - - # Match both /api/* and /openapi.json in a single rule - location ~ ^/(api|openapi.json)(/.*)?$ { - # Rewrite /api prefixed matched paths - rewrite ^/api(/.*)$ $1 break; - - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - # need to use 1.1 to support chunked transfers - proxy_http_version 1.1; - proxy_buffering off; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://api_server; - } - - location / { - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - proxy_http_version 1.1; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://web_server; - } -} - diff --git a/deployment/data/nginx/app.conf.template.no-letsencrypt b/deployment/data/nginx/app.conf.template.no-letsencrypt deleted file mode 100644 index 4d5096374a4..00000000000 --- a/deployment/data/nginx/app.conf.template.no-letsencrypt +++ /dev/null @@ -1,84 +0,0 @@ -# Log format to include request latency -log_format custom_main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for" ' - 'rt=$request_time'; - -upstream api_server { - # fail_timeout=0 means we always retry an upstream even if it failed - # to return a good HTTP response - - # for UNIX domain socket setups - #server unix:/tmp/gunicorn.sock fail_timeout=0; - - # for a TCP configuration - # TODO: use gunicorn to manage multiple processes - server api_server:8080 fail_timeout=0; -} - -upstream web_server { - server web_server:3000 fail_timeout=0; -} - -server { - listen 80; - server_name ${DOMAIN}; - - client_max_body_size 5G; # Maximum upload size - - access_log /var/log/nginx/access.log custom_main; - - # Match both /api/* and /openapi.json in a single rule - location ~ ^/(api|openapi.json)(/.*)?$ { - # Rewrite /api prefixed matched paths - rewrite ^/api(/.*)$ $1 break; - - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - # need to use 1.1 to support chunked transfers - proxy_http_version 1.1; - proxy_buffering off; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://api_server; - } - - location / { - # misc headers - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - - proxy_http_version 1.1; - - # we don't want nginx trying to do something clever with - # redirects, we set the Host: header above already. - proxy_redirect off; - proxy_pass http://web_server; - } -} - -server { - listen 443 ssl; - server_name ${DOMAIN}; - - client_max_body_size 5G; # Maximum upload size - - location / { - proxy_http_version 1.1; - proxy_buffering off; - proxy_pass http://localhost:80; - } - - ssl_certificate /etc/nginx/sslcerts/${SSL_CERT_FILE_NAME}; - ssl_certificate_key /etc/nginx/sslcerts/${SSL_CERT_KEY_FILE_NAME}; -} diff --git a/deployment/data/nginx/run-nginx.sh b/deployment/data/nginx/run-nginx.sh deleted file mode 100755 index fed6eb686e8..00000000000 --- a/deployment/data/nginx/run-nginx.sh +++ /dev/null @@ -1,26 +0,0 @@ -# fill in the template -envsubst '$DOMAIN $SSL_CERT_FILE_NAME $SSL_CERT_KEY_FILE_NAME' < "/etc/nginx/conf.d/$1" > /etc/nginx/conf.d/app.conf - -# wait for the api_server to be ready -echo "Waiting for API server to boot up; this may take a minute or two..." -echo "If this takes more than ~5 minutes, check the logs of the API server container for errors with the following command:" -echo -echo "docker logs danswer-stack_api_server-1" -echo - -while true; do - # Use curl to send a request and capture the HTTP status code - status_code=$(curl -o /dev/null -s -w "%{http_code}\n" "http://api_server:8080/health") - - # Check if the status code is 200 - if [ "$status_code" -eq 200 ]; then - echo "API server responded with 200, starting nginx..." - break # Exit the loop - else - echo "API server responded with $status_code, retrying in 5 seconds..." - sleep 5 # Sleep for 5 seconds before retrying - fi -done - -# Start nginx and reload every 6 hours -while :; do sleep 6h & wait; nginx -s reload; done & nginx -g "daemon off;" diff --git a/deployment/docker_compose/README.md b/deployment/docker_compose/README.md deleted file mode 100644 index a5f650b5303..00000000000 --- a/deployment/docker_compose/README.md +++ /dev/null @@ -1,40 +0,0 @@ - - -# Deploying Danswer using Docker Compose - -For general information, please read the instructions in this [README](https://github.com/danswer-ai/danswer/blob/main/deployment/README.md). - -## Deploy in a system without GPU support -This part is elaborated precisely in in this [README](https://github.com/danswer-ai/danswer/blob/main/deployment/README.md) in section *Docker Compose*. If you have any questions, please feel free to open an issue or get in touch in slack for support. - -## Deploy in a system with GPU support -Running Model servers with GPU support while indexing and querying can result in significant improvements in performance. This is highly recommended if you have access to resources. Currently, Danswer offloads embedding model and tokenizers to the GPU VRAM and the size needed depends on chosen embedding model. For example, the embedding model `nomic-ai/nomic-embed-text-v1` takes up about 1GB of VRAM. That means running this model for inference and embedding pipeline would require roughly 2GB of VRAM. - -### Setup -To be able to use NVIDIA runtime, following is mandatory: -- proper setup of NVIDIA driver in host system. -- installation of `nvidia-container-toolkit` for passing GPU runtime to containers - -You will find elaborate steps here: - -#### Installation of NVIDIA Drivers -Visit the official [NVIDIA drivers page](https://www.nvidia.com/Download/index.aspx) to download and install the proper drivers. Reboot your system once you have done so. - -Alternatively, you can choose to install the driver versions via package managers of your choice in UNIX based systems. - -#### Installation of `nvidia-container-toolkit` - -For GPUs to be accessible to containers, you will need the container toolkit. Please follow [these instructions](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) to install the necessary runtime based on your requirement. - -### Launching with GPU - -1. To run Danswer with GPU, navigate to `docker_compose` directory and run the following: - - `docker compose -f docker-compose.gpu-dev.yml -p danswer-stack up -d --pull always --force-recreate` - - or run: `docker compose -f docker-compose.gpu-dev.yml -p danswer-stack up -d --build --force-recreate` -to build from source - - Downloading images or packages/requirements may take 15+ minutes depending on your internet connection. - - -2. To shut down the deployment, run: - - To stop the containers: `docker compose -f docker-compose.gpu-dev.yml -p danswer-stack stop` - - To delete the containers: `docker compose -f docker-compose.gpu-dev.yml -p danswer-stack down` diff --git a/deployment/docker_compose/docker-compose.dev.yml b/deployment/docker_compose/docker-compose.dev.yml deleted file mode 100644 index bda8ffa65d5..00000000000 --- a/deployment/docker_compose/docker-compose.dev.yml +++ /dev/null @@ -1,352 +0,0 @@ -version: '3' -services: - api_server: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: > - /bin/sh -c "alembic upgrade head && - echo \"Starting Danswer Api Server\" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080" - depends_on: - - relational_db - - index - - inference_model_server - restart: always - ports: - - "8080:8080" - environment: - # Auth Settings - - AUTH_TYPE=${AUTH_TYPE:-disabled} - - SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-} - - ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-} - - VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-} - - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - - REQUIRE_EMAIL_VERIFICATION=${REQUIRE_EMAIL_VERIFICATION:-} - - SMTP_SERVER=${SMTP_SERVER:-} # For sending verification emails, if unspecified then defaults to 'smtp.gmail.com' - - SMTP_PORT=${SMTP_PORT:-587} # For sending verification emails, if unspecified then defaults to '587' - - SMTP_USER=${SMTP_USER:-} - - SMTP_PASS=${SMTP_PASS:-} - - EMAIL_FROM=${EMAIL_FROM:-} - - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-} - - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-} - - OPENID_CONFIG_URL=${OPENID_CONFIG_URL:-} - - TRACK_EXTERNAL_IDP_EXPIRY=${TRACK_EXTERNAL_IDP_EXPIRY:-} - # Gen AI Settings - - GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-} - - GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-} - - FAST_GEN_AI_MODEL_VERSION=${FAST_GEN_AI_MODEL_VERSION:-} - - GEN_AI_API_KEY=${GEN_AI_API_KEY:-} - - GEN_AI_API_ENDPOINT=${GEN_AI_API_ENDPOINT:-} - - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} - - QA_TIMEOUT=${QA_TIMEOUT:-} - - MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-} - - DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-} - - DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-} - - DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-} - - DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-} - - LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-} - - BING_API_KEY=${BING_API_KEY:-} - - DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-} - # if set, allows for the use of the token budget system - - TOKEN_BUDGET_GLOBALLY_ENABLED=${TOKEN_BUDGET_GLOBALLY_ENABLED:-} - # Enables the use of bedrock models - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - - AWS_REGION_NAME=${AWS_REGION_NAME:-} - # Query Options - - DOC_TIME_DECAY=${DOC_TIME_DECAY:-} # Recency Bias for search results, decay at 1 / (1 + DOC_TIME_DECAY * x years) - - HYBRID_ALPHA=${HYBRID_ALPHA:-} # Hybrid Search Alpha (0 for entirely keyword, 1 for entirely vector) - - EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-} - - MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-} - - LANGUAGE_HINT=${LANGUAGE_HINT:-} - - LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-} - - QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-} - # Other services - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - WEB_DOMAIN=${WEB_DOMAIN:-} # For frontend redirect auth purpose - # Don't change the NLP model configs unless you know what you're doing - - DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-} - - DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-} - - NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-} - - ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-} - - DISABLE_RERANK_FOR_STREAMING=${DISABLE_RERANK_FOR_STREAMING:-} - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - # Leave this on pretty please? Nothing sensitive is collected! - # https://docs.danswer.dev/more/telemetry - - DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-} - - LOG_LEVEL=${LOG_LEVEL:-info} # Set to debug to get more fine-grained logs - - LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-} # LiteLLM Verbose Logging - # Log all of Danswer prompts and interactions with the LLM - - LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-} - # If set to `true` will enable additional logs about Vespa query performance - # (time spent on finding the right docs + time spent fetching summaries from disk) - - LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-} - - LOG_ENDPOINT_LATENCY=${LOG_ENDPOINT_LATENCY:-} - - LOG_POSTGRES_LATENCY=${LOG_POSTGRES_LATENCY:-} - - LOG_POSTGRES_CONN_COUNTS=${LOG_POSTGRES_CONN_COUNTS:-} - - # Enterprise Edition only - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - - API_KEY_HASH_ROUNDS=${API_KEY_HASH_ROUNDS:-} - # Seeding configuration - - ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - background: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf - depends_on: - - relational_db - - index - - inference_model_server - - indexing_model_server - restart: always - environment: - - ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-} - # Gen AI Settings (Needed by DanswerBot) - - GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-} - - GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-} - - FAST_GEN_AI_MODEL_VERSION=${FAST_GEN_AI_MODEL_VERSION:-} - - GEN_AI_API_KEY=${GEN_AI_API_KEY:-} - - GEN_AI_API_ENDPOINT=${GEN_AI_API_ENDPOINT:-} - - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} - - QA_TIMEOUT=${QA_TIMEOUT:-} - - MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-} - - DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-} - - DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-} - - DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-} - - GENERATIVE_MODEL_ACCESS_CHECK_FREQ=${GENERATIVE_MODEL_ACCESS_CHECK_FREQ:-} - - DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-} - - LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-} - - BING_API_KEY=${BING_API_KEY:-} - # Query Options - - DOC_TIME_DECAY=${DOC_TIME_DECAY:-} # Recency Bias for search results, decay at 1 / (1 + DOC_TIME_DECAY * x years) - - HYBRID_ALPHA=${HYBRID_ALPHA:-} # Hybrid Search Alpha (0 for entirely keyword, 1 for entirely vector) - - EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-} - - MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-} - - LANGUAGE_HINT=${LANGUAGE_HINT:-} - - LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-} - - QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-} - # Other Services - - POSTGRES_HOST=relational_db - - POSTGRES_USER=${POSTGRES_USER:-} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-} - - POSTGRES_DB=${POSTGRES_DB:-} - - VESPA_HOST=index - - WEB_DOMAIN=${WEB_DOMAIN:-} # For frontend redirect auth purpose for OAuth2 connectors - # Don't change the NLP model configs unless you know what you're doing - - DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-} - - DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-} - - NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-} - - ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-} # Needed by DanswerBot - - ASYM_PASSAGE_PREFIX=${ASYM_PASSAGE_PREFIX:-} - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} - # Indexing Configs - - NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-} - - ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-} - - DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-} - - DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-} - - CONTINUE_ON_CONNECTOR_FAILURE=${CONTINUE_ON_CONNECTOR_FAILURE:-} - - EXPERIMENTAL_CHECKPOINTING_ENABLED=${EXPERIMENTAL_CHECKPOINTING_ENABLED:-} - - CONFLUENCE_CONNECTOR_LABELS_TO_SKIP=${CONFLUENCE_CONNECTOR_LABELS_TO_SKIP:-} - - JIRA_CONNECTOR_LABELS_TO_SKIP=${JIRA_CONNECTOR_LABELS_TO_SKIP:-} - - WEB_CONNECTOR_VALIDATE_URLS=${WEB_CONNECTOR_VALIDATE_URLS:-} - - JIRA_API_VERSION=${JIRA_API_VERSION:-} - - GONG_CONNECTOR_START_TIME=${GONG_CONNECTOR_START_TIME:-} - - NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP=${NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP:-} - - GITHUB_CONNECTOR_BASE_URL=${GITHUB_CONNECTOR_BASE_URL:-} - # Danswer SlackBot Configs - - DANSWER_BOT_SLACK_APP_TOKEN=${DANSWER_BOT_SLACK_APP_TOKEN:-} - - DANSWER_BOT_SLACK_BOT_TOKEN=${DANSWER_BOT_SLACK_BOT_TOKEN:-} - - DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER=${DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER:-} - - DANSWER_BOT_FEEDBACK_VISIBILITY=${DANSWER_BOT_FEEDBACK_VISIBILITY:-} - - DANSWER_BOT_DISPLAY_ERROR_MSGS=${DANSWER_BOT_DISPLAY_ERROR_MSGS:-} - - DANSWER_BOT_RESPOND_EVERY_CHANNEL=${DANSWER_BOT_RESPOND_EVERY_CHANNEL:-} - - DANSWER_BOT_DISABLE_COT=${DANSWER_BOT_DISABLE_COT:-} # Currently unused - - NOTIFY_SLACKBOT_NO_ANSWER=${NOTIFY_SLACKBOT_NO_ANSWER:-} - - DANSWER_BOT_MAX_QPM=${DANSWER_BOT_MAX_QPM:-} - - DANSWER_BOT_MAX_WAIT_TIME=${DANSWER_BOT_MAX_WAIT_TIME:-} - # Logging - # Leave this on pretty please? Nothing sensitive is collected! - # https://docs.danswer.dev/more/telemetry - - DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-} - - LOG_LEVEL=${LOG_LEVEL:-info} # Set to debug to get more fine-grained logs - - LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-} # LiteLLM Verbose Logging - # Log all of Danswer prompts and interactions with the LLM - - LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-} - - LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-} - - # Enterprise Edition stuff - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - web_server: - image: danswer/danswer-web-server:${IMAGE_TAG:-latest} - build: - context: ../../web - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} - - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} - - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} - - NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-} - - # Enterprise Edition only - - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} - # DO NOT TURN ON unless you have EXPLICIT PERMISSION from Danswer. - - NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED=${NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED:-false} - depends_on: - - api_server - restart: always - environment: - - INTERNAL_URL=http://api_server:8080 - - WEB_DOMAIN=${WEB_DOMAIN:-} - - THEME_IS_DARK=${THEME_IS_DARK:-} - - DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-} - - # Enterprise Edition only - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - - inference_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - indexing_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - INDEXING_ONLY=True - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - indexing_huggingface_model_cache:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - relational_db: - image: postgres:15.2-alpine - command: -c 'max_connections=150' - restart: always - environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - ports: - - "5432:5432" - volumes: - - db_volume:/var/lib/postgresql/data - - # This container name cannot have an underscore in it due to Vespa expectations of the URL - index: - image: vespaengine/vespa:8.277.17 - restart: always - ports: - - "19071:19071" - - "8081:8081" - volumes: - - vespa_volume:/opt/vespa/var - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - nginx: - image: nginx:1.23.4-alpine - restart: always - # nginx will immediately crash with `nginx: [emerg] host not found in upstream` - # if api_server / web_server are not up - depends_on: - - api_server - - web_server - environment: - - DOMAIN=localhost - ports: - - "80:80" - - "3000:80" # allow for localhost:3000 usage, since that is the norm - volumes: - - ../data/nginx:/etc/nginx/conf.d - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - # The specified script waits for the api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - # NOTE: we have to use dos2unix to remove Carriage Return chars from the file - # in order to make this work on both Unix-like systems and windows - command: > - /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh - && /etc/nginx/conf.d/run-nginx.sh app.conf.template.dev" - -volumes: - db_volume: - vespa_volume: # Created by the container itself - - model_cache_huggingface: - indexing_huggingface_model_cache: diff --git a/deployment/docker_compose/docker-compose.gpu-dev.yml b/deployment/docker_compose/docker-compose.gpu-dev.yml deleted file mode 100644 index 9079bd10dff..00000000000 --- a/deployment/docker_compose/docker-compose.gpu-dev.yml +++ /dev/null @@ -1,365 +0,0 @@ -version: '3' -services: - api_server: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: > - /bin/sh -c "alembic upgrade head && - echo \"Starting Danswer Api Server\" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080" - depends_on: - - relational_db - - index - - inference_model_server - restart: always - ports: - - "8080:8080" - environment: - # Auth Settings - - AUTH_TYPE=${AUTH_TYPE:-disabled} - - SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-} - - ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-} - - VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-} - - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-} - - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-} - - REQUIRE_EMAIL_VERIFICATION=${REQUIRE_EMAIL_VERIFICATION:-} - - SMTP_SERVER=${SMTP_SERVER:-} # For sending verification emails, if unspecified then defaults to 'smtp.gmail.com' - - SMTP_PORT=${SMTP_PORT:-587} # For sending verification emails, if unspecified then defaults to '587' - - SMTP_USER=${SMTP_USER:-} - - SMTP_PASS=${SMTP_PASS:-} - - EMAIL_FROM=${EMAIL_FROM:-} - - TRACK_EXTERNAL_IDP_EXPIRY=${TRACK_EXTERNAL_IDP_EXPIRY:-} - # Gen AI Settings - - GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-} - - GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-} - - FAST_GEN_AI_MODEL_VERSION=${FAST_GEN_AI_MODEL_VERSION:-} - - GEN_AI_API_KEY=${GEN_AI_API_KEY:-} - - GEN_AI_API_ENDPOINT=${GEN_AI_API_ENDPOINT:-} - - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} - - QA_TIMEOUT=${QA_TIMEOUT:-} - - MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-} - - DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-} - - DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-} - - DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-} - - DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-} - - DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-} - - LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-} - # if set, allows for the use of the token budget system - - TOKEN_BUDGET_GLOBALLY_ENABLED=${TOKEN_BUDGET_GLOBALLY_ENABLED:-} - # Enables the use of bedrock models - - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - - AWS_REGION_NAME=${AWS_REGION_NAME:-} - # Query Options - - DOC_TIME_DECAY=${DOC_TIME_DECAY:-} # Recency Bias for search results, decay at 1 / (1 + DOC_TIME_DECAY * x years) - - HYBRID_ALPHA=${HYBRID_ALPHA:-} # Hybrid Search Alpha (0 for entirely keyword, 1 for entirely vector) - - EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-} - - MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-} - - LANGUAGE_HINT=${LANGUAGE_HINT:-} - - LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-} - - QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-} - # Other services - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - WEB_DOMAIN=${WEB_DOMAIN:-} # For frontend redirect auth purpose - # Don't change the NLP model configs unless you know what you're doing - - DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-} - - DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-} - - NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-} - - ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-} - - DISABLE_RERANK_FOR_STREAMING=${DISABLE_RERANK_FOR_STREAMING:-} - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - # Leave this on pretty please? Nothing sensitive is collected! - # https://docs.danswer.dev/more/telemetry - - DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-} - - LOG_LEVEL=${LOG_LEVEL:-info} # Set to debug to get more fine-grained logs - - LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-} # LiteLLM Verbose Logging - # Log all of Danswer prompts and interactions with the LLM - - LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-} - # If set to `true` will enable additional logs about Vespa query performance - # (time spent on finding the right docs + time spent fetching summaries from disk) - - LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-} - - # Enterprise Edition only - - API_KEY_HASH_ROUNDS=${API_KEY_HASH_ROUNDS:-} - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - background: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: /usr/bin/supervisord - depends_on: - - relational_db - - index - - inference_model_server - - indexing_model_server - restart: always - environment: - - ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-} - # Gen AI Settings (Needed by DanswerBot) - - GEN_AI_MODEL_PROVIDER=${GEN_AI_MODEL_PROVIDER:-} - - GEN_AI_MODEL_VERSION=${GEN_AI_MODEL_VERSION:-} - - FAST_GEN_AI_MODEL_VERSION=${FAST_GEN_AI_MODEL_VERSION:-} - - GEN_AI_API_KEY=${GEN_AI_API_KEY:-} - - GEN_AI_API_ENDPOINT=${GEN_AI_API_ENDPOINT:-} - - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} - - QA_TIMEOUT=${QA_TIMEOUT:-} - - MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-} - - DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-} - - DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-} - - DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-} - - DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-} - - GENERATIVE_MODEL_ACCESS_CHECK_FREQ=${GENERATIVE_MODEL_ACCESS_CHECK_FREQ:-} - - DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-} - - LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-} - # Query Options - - DOC_TIME_DECAY=${DOC_TIME_DECAY:-} # Recency Bias for search results, decay at 1 / (1 + DOC_TIME_DECAY * x years) - - HYBRID_ALPHA=${HYBRID_ALPHA:-} # Hybrid Search Alpha (0 for entirely keyword, 1 for entirely vector) - - EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-} - - MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-} - - LANGUAGE_HINT=${LANGUAGE_HINT:-} - - LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-} - - QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-} - # Other Services - - POSTGRES_HOST=relational_db - - POSTGRES_USER=${POSTGRES_USER:-} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-} - - POSTGRES_DB=${POSTGRES_DB:-} - - VESPA_HOST=index - - WEB_DOMAIN=${WEB_DOMAIN:-} # For frontend redirect auth purpose for OAuth2 connectors - # Don't change the NLP model configs unless you know what you're doing - - DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-} - - DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-} - - NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-} - - ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-} # Needed by DanswerBot - - ASYM_PASSAGE_PREFIX=${ASYM_PASSAGE_PREFIX:-} - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} - # Indexing Configs - - NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-} - - ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-} - - DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-} - - DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-} - - CONTINUE_ON_CONNECTOR_FAILURE=${CONTINUE_ON_CONNECTOR_FAILURE:-} - - EXPERIMENTAL_CHECKPOINTING_ENABLED=${EXPERIMENTAL_CHECKPOINTING_ENABLED:-} - - CONFLUENCE_CONNECTOR_LABELS_TO_SKIP=${CONFLUENCE_CONNECTOR_LABELS_TO_SKIP:-} - - JIRA_CONNECTOR_LABELS_TO_SKIP=${JIRA_CONNECTOR_LABELS_TO_SKIP:-} - - WEB_CONNECTOR_VALIDATE_URLS=${WEB_CONNECTOR_VALIDATE_URLS:-} - - JIRA_API_VERSION=${JIRA_API_VERSION:-} - - GONG_CONNECTOR_START_TIME=${GONG_CONNECTOR_START_TIME:-} - - NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP=${NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP:-} - - GITHUB_CONNECTOR_BASE_URL=${GITHUB_CONNECTOR_BASE_URL:-} - # Danswer SlackBot Configs - - DANSWER_BOT_SLACK_APP_TOKEN=${DANSWER_BOT_SLACK_APP_TOKEN:-} - - DANSWER_BOT_SLACK_BOT_TOKEN=${DANSWER_BOT_SLACK_BOT_TOKEN:-} - - DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER=${DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER:-} - - DANSWER_BOT_FEEDBACK_VISIBILITY=${DANSWER_BOT_FEEDBACK_VISIBILITY:-} - - DANSWER_BOT_DISPLAY_ERROR_MSGS=${DANSWER_BOT_DISPLAY_ERROR_MSGS:-} - - DANSWER_BOT_RESPOND_EVERY_CHANNEL=${DANSWER_BOT_RESPOND_EVERY_CHANNEL:-} - - DANSWER_BOT_DISABLE_COT=${DANSWER_BOT_DISABLE_COT:-} # Currently unused - - NOTIFY_SLACKBOT_NO_ANSWER=${NOTIFY_SLACKBOT_NO_ANSWER:-} - - DANSWER_BOT_MAX_QPM=${DANSWER_BOT_MAX_QPM:-} - - DANSWER_BOT_MAX_WAIT_TIME=${DANSWER_BOT_MAX_WAIT_TIME:-} - # Logging - # Leave this on pretty please? Nothing sensitive is collected! - # https://docs.danswer.dev/more/telemetry - - DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-} - - LOG_LEVEL=${LOG_LEVEL:-info} # Set to debug to get more fine-grained logs - - LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-} # LiteLLM Verbose Logging - # Log all of Danswer prompts and interactions with the LLM - - LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-} - - LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-} - - # Enterprise Edition only - - API_KEY_HASH_ROUNDS=${API_KEY_HASH_ROUNDS:-} - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - web_server: - image: danswer/danswer-web-server:${IMAGE_TAG:-latest} - build: - context: ../../web - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} - - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} - - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} - - NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-} - - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} - depends_on: - - api_server - restart: always - environment: - - INTERNAL_URL=http://api_server:8080 - - WEB_DOMAIN=${WEB_DOMAIN:-} - - THEME_IS_DARK=${THEME_IS_DARK:-} - - # Enterprise Edition only - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - - - inference_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - # for GPU support, please read installation guidelines in the README.md - # bare minimum to get this working is to install nvidia-container-toolkit - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - indexing_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - # for GPU support, please read installation guidelines in the README.md - # bare minimum to get this working is to install nvidia-container-toolkit - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - INDEXING_ONLY=True - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - indexing_huggingface_model_cache:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - relational_db: - image: postgres:15.2-alpine - command: -c 'max_connections=150' - restart: always - environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - ports: - - "5432:5432" - volumes: - - db_volume:/var/lib/postgresql/data - - - # This container name cannot have an underscore in it due to Vespa expectations of the URL - index: - image: vespaengine/vespa:8.277.17 - restart: always - ports: - - "19071:19071" - - "8081:8081" - volumes: - - vespa_volume:/opt/vespa/var - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - nginx: - image: nginx:1.23.4-alpine - restart: always - # nginx will immediately crash with `nginx: [emerg] host not found in upstream` - # if api_server / web_server are not up - depends_on: - - api_server - - web_server - environment: - - DOMAIN=localhost - ports: - - "80:80" - - "3000:80" # allow for localhost:3000 usage, since that is the norm - volumes: - - ../data/nginx:/etc/nginx/conf.d - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - # The specified script waits for the api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - # NOTE: we have to use dos2unix to remove Carriage Return chars from the file - # in order to make this work on both Unix-like systems and windows - command: > - /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh - && /etc/nginx/conf.d/run-nginx.sh app.conf.template.dev" - - -volumes: - db_volume: - vespa_volume: - # Created by the container itself - model_cache_huggingface: - indexing_huggingface_model_cache: diff --git a/deployment/docker_compose/docker-compose.prod-no-letsencrypt.yml b/deployment/docker_compose/docker-compose.prod-no-letsencrypt.yml deleted file mode 100644 index 655243d6cb9..00000000000 --- a/deployment/docker_compose/docker-compose.prod-no-letsencrypt.yml +++ /dev/null @@ -1,212 +0,0 @@ -version: '3' -services: - api_server: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: > - /bin/sh -c "alembic upgrade head && - echo \"Starting Danswer Api Server\" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080" - depends_on: - - relational_db - - index - - inference_model_server - restart: always - env_file: - - .env - environment: - - AUTH_TYPE=${AUTH_TYPE:-oidc} - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - background: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf - depends_on: - - relational_db - - index - - inference_model_server - - indexing_model_server - restart: always - env_file: - - .env - environment: - - AUTH_TYPE=${AUTH_TYPE:-oidc} - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - web_server: - image: danswer/danswer-web-server:${IMAGE_TAG:-latest} - build: - context: ../../web - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} - - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} - - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} - - NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-} - - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} - depends_on: - - api_server - restart: always - env_file: - - .env - environment: - - INTERNAL_URL=http://api_server:8080 - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - inference_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - indexing_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - INDEXING_ONLY=True - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - indexing_huggingface_model_cache:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - relational_db: - image: postgres:15.2-alpine - command: -c 'max_connections=150' - restart: always - # POSTGRES_USER and POSTGRES_PASSWORD should be set in .env file - env_file: - - .env - volumes: - - db_volume:/var/lib/postgresql/data - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - # This container name cannot have an underscore in it due to Vespa expectations of the URL - index: - image: vespaengine/vespa:8.277.17 - restart: always - ports: - - "19071:19071" - - "8081:8081" - volumes: - - vespa_volume:/opt/vespa/var - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - nginx: - image: nginx:1.23.4-alpine - restart: always - # nginx will immediately crash with `nginx: [emerg] host not found in upstream` - # if api_server / web_server are not up - depends_on: - - api_server - - web_server - ports: - - "80:80" - - "443:443" - volumes: - - ../data/nginx:/etc/nginx/conf.d - - ../data/sslcerts:/etc/nginx/sslcerts - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - # The specified script waits for the api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - # NOTE: we have to use dos2unix to remove Carriage Return chars from the file - # in order to make this work on both Unix-like systems and windows - command: > - /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh - && /etc/nginx/conf.d/run-nginx.sh app.conf.template.no-letsencrypt" - env_file: - - .env.nginx - - -volumes: - db_volume: - vespa_volume: - # Created by the container itself - model_cache_huggingface: - indexing_huggingface_model_cache: diff --git a/deployment/docker_compose/docker-compose.prod.yml b/deployment/docker_compose/docker-compose.prod.yml deleted file mode 100644 index 40f018eadd9..00000000000 --- a/deployment/docker_compose/docker-compose.prod.yml +++ /dev/null @@ -1,229 +0,0 @@ -version: '3' -services: - api_server: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: > - /bin/sh -c "alembic upgrade head && - echo \"Starting Danswer Api Server\" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080" - depends_on: - - relational_db - - index - - inference_model_server - restart: always - env_file: - - .env - environment: - - AUTH_TYPE=${AUTH_TYPE:-oidc} - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - background: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf - depends_on: - - relational_db - - index - - inference_model_server - - indexing_model_server - restart: always - env_file: - - .env - environment: - - AUTH_TYPE=${AUTH_TYPE:-oidc} - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - web_server: - image: danswer/danswer-web-server:${IMAGE_TAG:-latest} - build: - context: ../../web - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} - - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} - - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} - - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} - depends_on: - - api_server - restart: always - env_file: - - .env - environment: - - INTERNAL_URL=http://api_server:8080 - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - relational_db: - image: postgres:15.2-alpine - command: -c 'max_connections=150' - restart: always - # POSTGRES_USER and POSTGRES_PASSWORD should be set in .env file - env_file: - - .env - volumes: - - db_volume:/var/lib/postgresql/data - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - inference_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - indexing_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - INDEXING_ONLY=True - # Set to debug to get more fine-grained logs - - LOG_LEVEL=${LOG_LEVEL:-info} - volumes: - # Not necessary, this is just to reduce download time during startup - - indexing_huggingface_model_cache:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - # This container name cannot have an underscore in it due to Vespa expectations of the URL - index: - image: vespaengine/vespa:8.277.17 - restart: always - ports: - - "19071:19071" - - "8081:8081" - volumes: - - vespa_volume:/opt/vespa/var - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - nginx: - image: nginx:1.23.4-alpine - restart: always - # nginx will immediately crash with `nginx: [emerg] host not found in upstream` - # if api_server / web_server are not up - depends_on: - - api_server - - web_server - ports: - - "80:80" - - "443:443" - volumes: - - ../data/nginx:/etc/nginx/conf.d - - ../data/certbot/conf:/etc/letsencrypt - - ../data/certbot/www:/var/www/certbot - # sleep a little bit to allow the web_server / api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - # The specified script waits for the api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - # NOTE: we have to use dos2unix to remove Carriage Return chars from the file - # in order to make this work on both Unix-like systems and windows - command: > - /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh - && /etc/nginx/conf.d/run-nginx.sh app.conf.template" - env_file: - - .env.nginx - - - # follows https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71 - certbot: - image: certbot/certbot - restart: always - volumes: - - ../data/certbot/conf:/etc/letsencrypt - - ../data/certbot/www:/var/www/certbot - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" - - -volumes: - db_volume: - vespa_volume: - # Created by the container itself - model_cache_huggingface: - indexing_huggingface_model_cache: diff --git a/deployment/docker_compose/docker-compose.search-testing.yml b/deployment/docker_compose/docker-compose.search-testing.yml deleted file mode 100644 index efb387eb083..00000000000 --- a/deployment/docker_compose/docker-compose.search-testing.yml +++ /dev/null @@ -1,215 +0,0 @@ -version: '3' -services: - api_server: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: > - /bin/sh -c "alembic upgrade head && - echo \"Starting Danswer Api Server\" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080" - depends_on: - - relational_db - - index - restart: always - ports: - - "8080" - env_file: - - .env_eval - environment: - - AUTH_TYPE=disabled - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - - ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-} - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - background: - image: danswer/danswer-backend:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile - command: /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf - depends_on: - - relational_db - - index - restart: always - env_file: - - .env_eval - environment: - - AUTH_TYPE=disabled - - POSTGRES_HOST=relational_db - - VESPA_HOST=index - - MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server} - - MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-} - - INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server} - - ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-} - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True - extra_hosts: - - "host.docker.internal:host-gateway" - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - web_server: - image: danswer/danswer-web-server:${IMAGE_TAG:-latest} - build: - context: ../../web - dockerfile: Dockerfile - args: - - NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false} - - NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false} - - NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-} - - NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-} - - NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-} - - # Enterprise Edition only - - NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-} - # DO NOT TURN ON unless you have EXPLICIT PERMISSION from Danswer. - - NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED=${NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED:-false} - depends_on: - - api_server - restart: always - environment: - - INTERNAL_URL=http://api_server:8080 - - WEB_DOMAIN=${WEB_DOMAIN:-} - - THEME_IS_DARK=${THEME_IS_DARK:-} - - # Enterprise Edition only - - ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false} - - - inference_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - LOG_LEVEL=${LOG_LEVEL:-debug} - - inference_model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - indexing_model_server: - image: danswer/danswer-model-server:${IMAGE_TAG:-latest} - build: - context: ../../backend - dockerfile: Dockerfile.model_server - command: > - /bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then - echo 'Skipping service...'; - exit 0; - else - exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000; - fi" - restart: on-failure - environment: - - MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-} - - INDEXING_ONLY=True - - LOG_LEVEL=${LOG_LEVEL:-debug} - - index_model_cache_huggingface:/root/.cache/huggingface/ - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - relational_db: - image: postgres:15.2-alpine - command: -c 'max_connections=150' - restart: always - environment: - - POSTGRES_USER=${POSTGRES_USER:-postgres} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} - ports: - - "5432" - volumes: - - db_volume:/var/lib/postgresql/data - - - # This container name cannot have an underscore in it due to Vespa expectations of the URL - index: - image: vespaengine/vespa:8.277.17 - restart: always - ports: - - "19071" - - "8081" - volumes: - - vespa_volume:/opt/vespa/var - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - - - nginx: - image: nginx:1.23.4-alpine - restart: always - # nginx will immediately crash with `nginx: [emerg] host not found in upstream` - # if api_server / web_server are not up - depends_on: - - api_server - - web_server - environment: - - DOMAIN=localhost - ports: - - "${NGINX_PORT:-3000}:80" # allow for localhost:3000 usage, since that is the norm - volumes: - - ../data/nginx:/etc/nginx/conf.d - logging: - driver: json-file - options: - max-size: "50m" - max-file: "6" - # The specified script waits for the api_server to start up. - # Without this we've seen issues where nginx shows no error logs but - # does not recieve any traffic - # NOTE: we have to use dos2unix to remove Carriage Return chars from the file - # in order to make this work on both Unix-like systems and windows - command: > - /bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh - && /etc/nginx/conf.d/run-nginx.sh app.conf.template.dev" - - -volumes: - db_volume: - driver: local - driver_opts: - type: none - o: bind - device: ${DANSWER_POSTGRES_DATA_DIR:-./postgres_data} - vespa_volume: - driver: local - driver_opts: - type: none - o: bind - device: ${DANSWER_VESPA_DATA_DIR:-./vespa_data} diff --git a/deployment/docker_compose/env.multilingual.template b/deployment/docker_compose/env.multilingual.template deleted file mode 100644 index e218305153f..00000000000 --- a/deployment/docker_compose/env.multilingual.template +++ /dev/null @@ -1,38 +0,0 @@ -# This env template shows how to configure Danswer for multilingual use -# In this case, it is configured for French and English -# To use it, copy it to .env in the docker_compose directory. -# Feel free to combine it with the other templates to suit your needs - - -# Rephrase the user query in specified languages using LLM, use comma separated values -MULTILINGUAL_QUERY_EXPANSION="English, French" -# Change the below to suit your specific needs, can be more explicit about the language of the response -LANGUAGE_HINT="IMPORTANT: Respond in the same language as my query!" -LANGUAGE_CHAT_NAMING_HINT="The name of the conversation must be in the same language as the user query." - -# A recent MIT license multilingual model: https://huggingface.co/intfloat/multilingual-e5-small -DOCUMENT_ENCODER_MODEL="intfloat/multilingual-e5-small" - -# The model above is trained with the following prefix for queries and passages to improve retrieval -# by letting the model know which of the two type is currently being embedded -ASYM_QUERY_PREFIX="query: " -ASYM_PASSAGE_PREFIX="passage: " - -# Depends model by model, the one shown above is tuned with this as True -NORMALIZE_EMBEDDINGS="True" - -# Use LLM to determine if chunks are relevant to the query -# May not work well for languages that do not have much training data in the LLM training set -# If using a common language like Spanish, French, Chinese, etc. this can be kept turned on -DISABLE_LLM_DOC_RELEVANCE="True" - -# Enables fine-grained embeddings for better retrieval -# At the cost of indexing speed (~5x slower), query time is same speed -# Since reranking is turned off and multilingual retrieval is generally harder -# it is advised to turn this one on -ENABLE_MULTIPASS_INDEXING="True" - -# Using a stronger LLM will help with multilingual tasks -# Since documents may be in multiple languages, and there are additional instructions to respond -# in the user query's language, it is advised to use the best model possible -GEN_AI_MODEL_VERSION="gpt-4" diff --git a/deployment/docker_compose/env.nginx.template b/deployment/docker_compose/env.nginx.template deleted file mode 100644 index 2fc2923a9e5..00000000000 --- a/deployment/docker_compose/env.nginx.template +++ /dev/null @@ -1,11 +0,0 @@ -# DOMAIN is necessary for https setup, EMAIL is optional -DOMAIN= -EMAIL= - -# If using the `no-letsencrypt` setup, the below are required. -# They specify the path within /danswer/deployment/data/sslcerts directory -# where the certificate / certificate key can be found. You can either -# name your certificate / certificate key files to follow the convention -# below or adjust these to match your naming conventions. -SSL_CERT_FILE_NAME=ssl.cert -SSL_CERT_KEY_FILE_NAME=ssl.key diff --git a/deployment/docker_compose/env.prod.template b/deployment/docker_compose/env.prod.template deleted file mode 100644 index 818bd1ed1bf..00000000000 --- a/deployment/docker_compose/env.prod.template +++ /dev/null @@ -1,72 +0,0 @@ -# Fill in the values and copy the contents of this file to .env in the deployment directory. -# Some valid default values are provided where applicable, delete the variables which you don't set values for. -# This is only necessary when using the docker-compose.prod.yml compose file. - - -# Could be something like danswer.companyname.com -WEB_DOMAIN=http://localhost:3000 - - -# Generative AI settings, uncomment as needed, will work with defaults -GEN_AI_MODEL_PROVIDER=openai -GEN_AI_MODEL_VERSION=gpt-4 -# Provide this as a global default/backup, this can also be set via the UI -#GEN_AI_API_KEY= -# Set to use Azure OpenAI or other services, such as https://danswer.openai.azure.com/ -#GEN_AI_API_ENDPOINT= -# Set up to use a specific API version, such as 2023-09-15-preview (example taken from Azure) -#GEN_AI_API_VERSION= - - -# If you want to setup a slack bot to answer questions automatically in Slack -# channels it is added to, you must specify the two below. -# More information in the guide here: https://docs.danswer.dev/slack_bot_setup -#DANSWER_BOT_SLACK_APP_TOKEN= -#DANSWER_BOT_SLACK_BOT_TOKEN= - - -# The following are for configuring User Authentication, supported flows are: -# disabled -# basic (standard username / password) -# google_oauth (login with google/gmail account) -# oidc (only in Danswer enterprise edition) -# saml (only in Danswer enterprise edition) -AUTH_TYPE=google_oauth - -# Set the values below to use with Google OAuth -GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= -SECRET= - -# if using basic auth and you want to require email verification, -# then uncomment / set the following -#REQUIRE_EMAIL_VERIFICATION=true -#SMTP_USER=your-email@company.com -#SMTP_PASS=your-gmail-password - -# The below are only needed if you aren't using gmail as your SMTP -#SMTP_SERVER= -#SMTP_PORT= -# When missing SMTP_USER, this is used instead -#EMAIL_FROM= - -# OpenID Connect (OIDC) -#OPENID_CONFIG_URL= - -# SAML config directory for OneLogin compatible setups -#SAML_CONF_DIR= - - -# How long before user needs to reauthenticate, default to 7 days. (cookie expiration time) -SESSION_EXPIRE_TIME_SECONDS=604800 - - -# Use the below to specify a list of allowed user domains, only checked if user Auth is turned on -# e.g. `VALID_EMAIL_DOMAINS=example.com,example.org` will only allow users -# with an @example.com or an @example.org email -#VALID_EMAIL_DOMAINS= - - -# Default values here are what Postgres uses by default, feel free to change. -POSTGRES_USER=postgres -POSTGRES_PASSWORD=password diff --git a/deployment/docker_compose/init-letsencrypt.sh b/deployment/docker_compose/init-letsencrypt.sh deleted file mode 100755 index 9eec409fada..00000000000 --- a/deployment/docker_compose/init-letsencrypt.sh +++ /dev/null @@ -1,116 +0,0 @@ -#!/bin/bash - -# .env.nginx file must be present in the same directory as this script and -# must set DOMAIN (and optionally EMAIL) -set -o allexport -source .env.nginx -set +o allexport - -# Function to determine correct docker compose command -docker_compose_cmd() { - if command -v docker-compose >/dev/null 2>&1; then - echo "docker-compose" - elif command -v docker compose >/dev/null 2>&1; then - echo "docker compose" - else - echo 'Error: docker-compose or docker compose is not installed.' >&2 - exit 1 - fi -} - -# Assign appropriate Docker Compose command -COMPOSE_CMD=$(docker_compose_cmd) - -# Only add www to domain list if domain wasn't explicitly set as a subdomain -if [[ ! $DOMAIN == www.* ]]; then - domains=("$DOMAIN" "www.$DOMAIN") -else - domains=("$DOMAIN") -fi - -rsa_key_size=4096 -data_path="../data/certbot" -email="$EMAIL" # Adding a valid address is strongly recommended -staging=0 # Set to 1 if you're testing your setup to avoid hitting request limits - -if [ -d "$data_path" ]; then - read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision - if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then - exit - fi -fi - - -if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then - echo "### Downloading recommended TLS parameters ..." - mkdir -p "$data_path/conf" - curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" - curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" - echo -fi - -echo "### Creating dummy certificate for $domains ..." -path="/etc/letsencrypt/live/$domains" -mkdir -p "$data_path/conf/live/$domains" -$COMPOSE_CMD -f docker-compose.prod.yml run --name danswer-stack --rm --entrypoint "\ - openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ - -keyout '$path/privkey.pem' \ - -out '$path/fullchain.pem' \ - -subj '/CN=localhost'" certbot -echo - - -echo "### Starting nginx ..." -$COMPOSE_CMD -f docker-compose.prod.yml -p danswer-stack up --force-recreate -d nginx -echo - -echo "Waiting for nginx to be ready, this may take a minute..." -while true; do - # Use curl to send a request and capture the HTTP status code - status_code=$(curl -o /dev/null -s -w "%{http_code}\n" "http://localhost/api/health") - - # Check if the status code is 200 - if [ "$status_code" -eq 200 ]; then - break # Exit the loop - else - echo "Nginx is not ready yet, retrying in 5 seconds..." - sleep 5 # Sleep for 5 seconds before retrying - fi -done - -echo "### Deleting dummy certificate for $domains ..." -$COMPOSE_CMD -f docker-compose.prod.yml run --name danswer-stack --rm --entrypoint "\ - rm -Rf /etc/letsencrypt/live/$domains && \ - rm -Rf /etc/letsencrypt/archive/$domains && \ - rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot -echo - - -echo "### Requesting Let's Encrypt certificate for $domains ..." -#Join $domains to -d args -domain_args="" -for domain in "${domains[@]}"; do - domain_args="$domain_args -d $domain" -done - -# Select appropriate email arg -case "$email" in - "") email_arg="--register-unsafely-without-email" ;; - *) email_arg="--email $email" ;; -esac - -# Enable staging mode if needed -if [ $staging != "0" ]; then staging_arg="--staging"; fi - -$COMPOSE_CMD -f docker-compose.prod.yml run --name danswer-stack --rm --entrypoint "\ - certbot certonly --webroot -w /var/www/certbot \ - $staging_arg \ - $email_arg \ - $domain_args \ - --rsa-key-size $rsa_key_size \ - --agree-tos \ - --force-renewal" certbot -echo - -echo "### Reloading nginx ..." -$COMPOSE_CMD -f docker-compose.prod.yml -p danswer-stack up --force-recreate -d diff --git a/deployment/helm/.gitignore b/deployment/helm/.gitignore deleted file mode 100644 index b442275d6b5..00000000000 --- a/deployment/helm/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -### Helm ### -# Chart dependencies -**/charts/*.tgz diff --git a/deployment/helm/.helmignore b/deployment/helm/.helmignore deleted file mode 100644 index 0e8a0eb36f4..00000000000 --- a/deployment/helm/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/deployment/helm/Chart.lock b/deployment/helm/Chart.lock deleted file mode 100644 index 918b44f6ebf..00000000000 --- a/deployment/helm/Chart.lock +++ /dev/null @@ -1,12 +0,0 @@ -dependencies: -- name: postgresql - repository: https://charts.bitnami.com/bitnami - version: 14.3.1 -- name: vespa - repository: https://unoplat.github.io/vespa-helm-charts - version: 0.2.3 -- name: nginx - repository: oci://registry-1.docker.io/bitnamicharts - version: 15.14.0 -digest: sha256:ab17b5d2c3883055cb4a26bf530043521be5220c24f804e954bb428273d16ba8 -generated: "2024-05-24T16:55:30.598279-07:00" diff --git a/deployment/helm/Chart.yaml b/deployment/helm/Chart.yaml deleted file mode 100644 index 791b4358d4b..00000000000 --- a/deployment/helm/Chart.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: v2 -name: danswer-stack -description: A Helm chart for Kubernetes -home: https://www.danswer.ai/ -sources: - - "https://github.com/danswer-ai/danswer" -type: application -version: 0.5.10-stackhpc.1 -appVersion: "v0.5.10" -annotations: - category: Productivity - licenses: MIT - images: | - - name: webserver - image: docker.io/danswer/danswer-web-server:latest - - name: background - image: docker.io/danswer/danswer-backend:latest - - name: vespa - image: vespaengine/vespa:8.277.17 -dependencies: - - name: postgresql - version: 14.3.1 - repository: https://charts.bitnami.com/bitnami - condition: postgresql.enabled - - name: vespa - version: 0.2.3 - repository: https://unoplat.github.io/vespa-helm-charts - condition: vespa.enabled - - name: nginx - version: 15.14.0 - repository: oci://registry-1.docker.io/bitnamicharts - condition: nginx.enabled - - diff --git a/deployment/helm/templates/_helpers.tpl b/deployment/helm/templates/_helpers.tpl deleted file mode 100644 index 483a5b5e5af..00000000000 --- a/deployment/helm/templates/_helpers.tpl +++ /dev/null @@ -1,83 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "danswer-stack.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "danswer-stack.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "danswer-stack.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "danswer-stack.labels" -}} -helm.sh/chart: {{ include "danswer-stack.chart" . }} -{{ include "danswer-stack.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "danswer-stack.selectorLabels" -}} -app.kubernetes.io/name: {{ include "danswer-stack.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "danswer-stack.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "danswer-stack.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} - -{{/* -Set secret name -*/}} -{{- define "danswer-stack.secretName" -}} -{{- default (default "danswer-secrets" .Values.auth.secretName) .Values.auth.existingSecret }} -{{- end }} - -{{/* -Create env vars from secrets -*/}} -{{- define "danswer-stack.envSecrets" -}} - {{- range $name, $key := .Values.auth.secretKeys }} -- name: {{ $name | upper | replace "-" "_" | quote }} - valueFrom: - secretKeyRef: - name: {{ include "danswer-stack.secretName" $ }} - key: {{ default $name $key }} - {{- end }} -{{- end }} - diff --git a/deployment/helm/templates/api-deployment.yaml b/deployment/helm/templates/api-deployment.yaml deleted file mode 100644 index a10932807b8..00000000000 --- a/deployment/helm/templates/api-deployment.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "danswer-stack.fullname" . }}-api-deployment - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - {{- if not .Values.api.autoscaling.enabled }} - replicas: {{ .Values.api.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "danswer-stack.selectorLabels" . | nindent 6 }} - {{- if .Values.api.deploymentLabels }} - {{- toYaml .Values.api.deploymentLabels | nindent 6 }} - {{- end }} - template: - metadata: - {{- with .Values.api.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "danswer-stack.labels" . | nindent 8 }} - {{- with .Values.api.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "danswer-stack.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.api.podSecurityContext | nindent 8 }} - containers: - - name: api-server - securityContext: - {{- toYaml .Values.api.securityContext | nindent 12 }} - image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.appVersionOverride | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.api.image.pullPolicy }} - command: - - "/bin/sh" - - "-c" - - | - alembic upgrade head && - echo "Starting Danswer Api Server" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080 - ports: - - name: api-server-port - containerPort: {{ .Values.api.service.port }} - protocol: TCP - resources: - {{- toYaml .Values.api.resources | nindent 12 }} - envFrom: - - configMapRef: - name: {{ .Values.config.envConfigMapName }} - env: - {{- include "danswer-stack.envSecrets" . | nindent 12}} diff --git a/deployment/helm/templates/api-hpa.yaml b/deployment/helm/templates/api-hpa.yaml deleted file mode 100644 index 378c39715ad..00000000000 --- a/deployment/helm/templates/api-hpa.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{{- if .Values.api.autoscaling.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "danswer-stack.fullname" . }}-api - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "danswer-stack.fullname" . }} - minReplicas: {{ .Values.api.autoscaling.minReplicas }} - maxReplicas: {{ .Values.api.autoscaling.maxReplicas }} - metrics: - {{- if .Values.api.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.api.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.api.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.api.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/deployment/helm/templates/api-service.yaml b/deployment/helm/templates/api-service.yaml deleted file mode 100644 index 1fd74d4ddf5..00000000000 --- a/deployment/helm/templates/api-service.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - # INTERNAL_URL env variable depends on this, don't change without changing INTERNAL_URL - name: {{ include "danswer-stack.fullname" . }}-api-service - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} - {{- if .Values.api.deploymentLabels }} - {{- toYaml .Values.api.deploymentLabels | nindent 4 }} - {{- end }} -spec: - type: {{ .Values.api.service.type }} - ports: - - port: {{ .Values.api.service.port }} - targetPort: api-server-port - protocol: TCP - name: api-server-port - selector: - {{- include "danswer-stack.selectorLabels" . | nindent 4 }} - {{- if .Values.api.deploymentLabels }} - {{- toYaml .Values.api.deploymentLabels | nindent 4 }} - {{- end }} diff --git a/deployment/helm/templates/background-deployment.yaml b/deployment/helm/templates/background-deployment.yaml deleted file mode 100644 index 05d4f89432b..00000000000 --- a/deployment/helm/templates/background-deployment.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "danswer-stack.fullname" . }}-background - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - {{- if not .Values.background.autoscaling.enabled }} - replicas: {{ .Values.background.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "danswer-stack.selectorLabels" . | nindent 6 }} - {{- if .Values.background.deploymentLabels }} - {{- toYaml .Values.background.deploymentLabels | nindent 6 }} - {{- end }} - template: - metadata: - {{- with .Values.background.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "danswer-stack.labels" . | nindent 8 }} - {{- with .Values.background.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "danswer-stack.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.background.podSecurityContext | nindent 8 }} - containers: - - name: background - securityContext: - {{- toYaml .Values.background.securityContext | nindent 12 }} - image: "{{ .Values.background.image.repository }}:{{ .Values.background.image.tag | default .Values.appVersionOverride | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.background.image.pullPolicy }} - command: ["/usr/bin/supervisord"] - resources: - {{- toYaml .Values.background.resources | nindent 12 }} - envFrom: - - configMapRef: - name: {{ .Values.config.envConfigMapName }} - env: - - name: ENABLE_MULTIPASS_INDEXING - value: "{{ .Values.background.enableMiniChunk }}" - {{- include "danswer-stack.envSecrets" . | nindent 12}} diff --git a/deployment/helm/templates/background-hpa.yaml b/deployment/helm/templates/background-hpa.yaml deleted file mode 100644 index 009daf10f05..00000000000 --- a/deployment/helm/templates/background-hpa.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{{- if .Values.background.autoscaling.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "danswer-stack.fullname" . }}-background - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "danswer-stack.fullname" . }} - minReplicas: {{ .Values.background.autoscaling.minReplicas }} - maxReplicas: {{ .Values.background.autoscaling.maxReplicas }} - metrics: - {{- if .Values.background.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.background.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.background.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.background.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/deployment/helm/templates/configmap.yaml b/deployment/helm/templates/configmap.yaml deleted file mode 100755 index 8119ae0459c..00000000000 --- a/deployment/helm/templates/configmap.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Values.config.envConfigMapName }} - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -data: - INTERNAL_URL: "http://{{ include "danswer-stack.fullname" . }}-api-service:{{ .Values.api.service.port | default 8080 }}" - POSTGRES_HOST: {{ .Release.Name }}-postgresql - VESPA_HOST: "document-index-service" - MODEL_SERVER_HOST: "{{ include "danswer-stack.fullname" . }}-inference-model-service" - INDEXING_MODEL_SERVER_HOST: "{{ include "danswer-stack.fullname" . }}-indexing-model-service" -{{- range $key, $value := .Values.configMap }} - {{ $key }}: "{{ $value }}" -{{- end }} \ No newline at end of file diff --git a/deployment/helm/templates/danswer-secret.yaml b/deployment/helm/templates/danswer-secret.yaml deleted file mode 100644 index 6b2aa317204..00000000000 --- a/deployment/helm/templates/danswer-secret.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if not .Values.auth.existingSecret -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "danswer-stack.secretName" . }} -type: Opaque -stringData: - {{- range $name, $value := .Values.auth.secrets }} - {{ $name }}: {{ $value | quote }} - {{- end }} -{{- end }} \ No newline at end of file diff --git a/deployment/helm/templates/indexing-model-deployment.yaml b/deployment/helm/templates/indexing-model-deployment.yaml deleted file mode 100644 index cc46f4f5b1b..00000000000 --- a/deployment/helm/templates/indexing-model-deployment.yaml +++ /dev/null @@ -1,57 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "danswer-stack.fullname" . }}-indexing-model - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - replicas: 1 - strategy: - {{- .Values.indexCapability.deployment.updateStrategy | toYaml | nindent 4 }} - selector: - matchLabels: - {{- include "danswer-stack.selectorLabels" . | nindent 6 }} - {{- if .Values.indexCapability.deploymentLabels }} - {{- toYaml .Values.indexCapability.deploymentLabels | nindent 6 }} - {{- end }} - template: - metadata: - {{- with .Values.indexCapability.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "danswer-stack.labels" . | nindent 8 }} - {{- with .Values.indexCapability.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - containers: - - name: indexing-model-server - image: "{{ .Values.indexCapability.deployment.image.repository }}:{{ .Values.indexCapability.deployment.image.tag | default .Values.appVersionOverride | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.indexCapability.deployment.image.pullPolicy }} - command: [ "uvicorn", "model_server.main:app", "--host", "0.0.0.0", "--port", "9000", "--limit-concurrency", "10" ] - {{- if .Values.indexCapability.deployment.resources }} - resources: - {{- toYaml .Values.indexCapability.deployment.resources | nindent 10 }} - {{- end }} - ports: - - containerPort: 9000 - envFrom: - - configMapRef: - name: {{ .Values.config.envConfigMapName }} - env: - - name: INDEXING_ONLY - value: "{{ default "True" .Values.indexCapability.indexingOnly }}" - {{- include "danswer-stack.envSecrets" . | nindent 10}} - volumeMounts: - {{- range .Values.indexCapability.volumeMounts }} - - name: {{ .name }} - mountPath: {{ .mountPath }} - {{- end }} - volumes: - {{- range .Values.indexCapability.volumes }} - - name: {{ .name }} - persistentVolumeClaim: - claimName: {{ .persistentVolumeClaim.claimName }} - {{- end }} diff --git a/deployment/helm/templates/indexing-model-pvc.yaml b/deployment/helm/templates/indexing-model-pvc.yaml deleted file mode 100644 index e5825557d5b..00000000000 --- a/deployment/helm/templates/indexing-model-pvc.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ .Values.indexCapability.indexingModelPVC.name }} -spec: - accessModes: - - {{ .Values.indexCapability.indexingModelPVC.accessMode | quote }} - resources: - requests: - storage: {{ .Values.indexCapability.indexingModelPVC.storage | quote }} \ No newline at end of file diff --git a/deployment/helm/templates/indexing-model-service.yaml b/deployment/helm/templates/indexing-model-service.yaml deleted file mode 100644 index fbbeb6bee86..00000000000 --- a/deployment/helm/templates/indexing-model-service.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "danswer-stack.fullname" . }}-indexing-model-service - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - selector: - {{- include "danswer-stack.selectorLabels" . | nindent 4 }} - {{- if .Values.indexCapability.deploymentLabels }} - {{- toYaml .Values.indexCapability.deploymentLabels | nindent 4 }} - {{- end }} - ports: - - name: {{ .Values.indexCapability.service.name }} - protocol: TCP - port: {{ .Values.indexCapability.service.port }} - targetPort: {{ .Values.indexCapability.service.port }} - type: {{ .Values.indexCapability.service.type }} \ No newline at end of file diff --git a/deployment/helm/templates/inference-model-deployment.yaml b/deployment/helm/templates/inference-model-deployment.yaml deleted file mode 100644 index 391a8e4289b..00000000000 --- a/deployment/helm/templates/inference-model-deployment.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "danswer-stack.fullname" . }}-inference-model - labels: - {{- range .Values.inferenceCapability.deployment.labels }} - {{ .key }}: {{ .value }} - {{- end }} -spec: - replicas: {{ .Values.inferenceCapability.deployment.replicas }} - selector: - matchLabels: - {{- range .Values.inferenceCapability.deployment.labels }} - {{ .key }}: {{ .value }} - {{- end }} - template: - metadata: - labels: - {{- range .Values.inferenceCapability.podLabels }} - {{ .key }}: {{ .value }} - {{- end }} - spec: - containers: - - name: {{ .Values.inferenceCapability.service.name }} - image: "{{ .Values.inferenceCapability.deployment.image.repository }}:{{ .Values.inferenceCapability.deployment.image.tag | default .Values.appVersionOverride | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.inferenceCapability.deployment.image.pullPolicy }} - command: {{ toYaml .Values.inferenceCapability.deployment.command | nindent 14 }} - ports: - - containerPort: {{ .Values.inferenceCapability.service.port }} - envFrom: - - configMapRef: - name: {{ .Values.config.envConfigMapName }} - env: - {{- include "danswer-stack.envSecrets" . | nindent 12}} - volumeMounts: - {{- range .Values.inferenceCapability.deployment.volumeMounts }} - - name: {{ .name }} - mountPath: {{ .mountPath }} - {{- end }} - volumes: - {{- range .Values.inferenceCapability.deployment.volumes }} - - name: {{ .name }} - persistentVolumeClaim: - claimName: {{ .persistentVolumeClaim.claimName }} - {{- end }} diff --git a/deployment/helm/templates/inference-model-pvc.yaml b/deployment/helm/templates/inference-model-pvc.yaml deleted file mode 100644 index fe47fa879a0..00000000000 --- a/deployment/helm/templates/inference-model-pvc.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ .Values.inferenceCapability.pvc.name }} -spec: - accessModes: - {{- toYaml .Values.inferenceCapability.pvc.accessModes | nindent 4 }} - resources: - requests: - storage: {{ .Values.inferenceCapability.pvc.storage }} diff --git a/deployment/helm/templates/inference-model-service.yaml b/deployment/helm/templates/inference-model-service.yaml deleted file mode 100644 index 74433ac11da..00000000000 --- a/deployment/helm/templates/inference-model-service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "danswer-stack.fullname" . }}-inference-model-service -spec: - type: {{ .Values.inferenceCapability.service.type }} - ports: - - port: {{ .Values.inferenceCapability.service.port }} - targetPort: {{ .Values.inferenceCapability.service.port }} - protocol: TCP - name: {{ .Values.inferenceCapability.service.name }} - selector: - {{- range .Values.inferenceCapability.deployment.labels }} - {{ .key }}: {{ .value }} - {{- end }} diff --git a/deployment/helm/templates/nginx-conf.yaml b/deployment/helm/templates/nginx-conf.yaml deleted file mode 100644 index 81ecbaaa2f6..00000000000 --- a/deployment/helm/templates/nginx-conf.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: danswer-nginx-conf -data: - nginx.conf: | - upstream api_server { - server {{ include "danswer-stack.fullname" . }}-api-service:{{ .Values.api.service.port }} fail_timeout=0; - } - - upstream web_server { - server {{ include "danswer-stack.fullname" . }}-webserver:{{ .Values.webserver.service.port }} fail_timeout=0; - } - - server { - listen 1024; - server_name $$DOMAIN; - - client_max_body_size 5G; # Maximum upload size - - location ~ ^/api(.*)$ { - rewrite ^/api(/.*)$ $1 break; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - proxy_http_version 1.1; - proxy_buffering off; - proxy_redirect off; - proxy_pass http://api_server; - } - - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - proxy_http_version 1.1; - proxy_redirect off; - proxy_pass http://web_server; - } - } diff --git a/deployment/helm/templates/serviceaccount.yaml b/deployment/helm/templates/serviceaccount.yaml deleted file mode 100644 index afd351217ba..00000000000 --- a/deployment/helm/templates/serviceaccount.yaml +++ /dev/null @@ -1,13 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "danswer-stack.serviceAccountName" . }} - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -automountServiceAccountToken: {{ .Values.serviceAccount.automount }} -{{- end }} diff --git a/deployment/helm/templates/tests/test-connection.yaml b/deployment/helm/templates/tests/test-connection.yaml deleted file mode 100644 index 60fbd1054c1..00000000000 --- a/deployment/helm/templates/tests/test-connection.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: "{{ include "danswer-stack.fullname" . }}-test-connection" - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} - annotations: - "helm.sh/hook": test -spec: - containers: - - name: wget - image: busybox - command: ['wget'] - args: ['{{ include "danswer-stack.fullname" . }}:{{ .Values.webserver.service.port }}'] - restartPolicy: Never diff --git a/deployment/helm/templates/webserver-deployment.yaml b/deployment/helm/templates/webserver-deployment.yaml deleted file mode 100644 index a50e4e79b7e..00000000000 --- a/deployment/helm/templates/webserver-deployment.yaml +++ /dev/null @@ -1,60 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "danswer-stack.fullname" . }}-webserver - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - {{- if not .Values.webserver.autoscaling.enabled }} - replicas: {{ .Values.webserver.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "danswer-stack.selectorLabels" . | nindent 6 }} - {{- if .Values.webserver.deploymentLabels }} - {{- toYaml .Values.webserver.deploymentLabels | nindent 6 }} - {{- end }} - template: - metadata: - {{- with .Values.webserver.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "danswer-stack.labels" . | nindent 8 }} - {{- with .Values.webserver.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - {{- with .Values.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - serviceAccountName: {{ include "danswer-stack.serviceAccountName" . }} - securityContext: - {{- toYaml .Values.webserver.podSecurityContext | nindent 8 }} - containers: - - name: web-server - securityContext: - {{- toYaml .Values.webserver.securityContext | nindent 12 }} - image: "{{ .Values.webserver.image.repository }}:{{ .Values.webserver.image.tag | default .Values.appVersionOverride | default .Chart.AppVersion }}" - imagePullPolicy: {{ .Values.webserver.image.pullPolicy }} - ports: - - name: http - containerPort: {{ .Values.webserver.service.port }} - protocol: TCP - resources: - {{- toYaml .Values.webserver.resources | nindent 12 }} - envFrom: - - configMapRef: - name: {{ .Values.config.envConfigMapName }} - env: - {{- include "danswer-stack.envSecrets" . | nindent 12}} - {{- with .Values.webserver.volumeMounts }} - volumeMounts: - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.webserver.volumes }} - volumes: - {{- toYaml . | nindent 8 }} - {{- end }} diff --git a/deployment/helm/templates/webserver-hpa.yaml b/deployment/helm/templates/webserver-hpa.yaml deleted file mode 100644 index b46820a7fac..00000000000 --- a/deployment/helm/templates/webserver-hpa.yaml +++ /dev/null @@ -1,32 +0,0 @@ -{{- if .Values.webserver.autoscaling.enabled }} -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "danswer-stack.fullname" . }}-webserver - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "danswer-stack.fullname" . }} - minReplicas: {{ .Values.webserver.autoscaling.minReplicas }} - maxReplicas: {{ .Values.webserver.autoscaling.maxReplicas }} - metrics: - {{- if .Values.webserver.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.webserver.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.webserver.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.webserver.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/deployment/helm/templates/webserver-service.yaml b/deployment/helm/templates/webserver-service.yaml deleted file mode 100644 index 3e33566fce1..00000000000 --- a/deployment/helm/templates/webserver-service.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: {{ include "danswer-stack.fullname" . }}-webserver - labels: - {{- include "danswer-stack.labels" . | nindent 4 }} - {{- if .Values.webserver.deploymentLabels }} - {{- toYaml .Values.webserver.deploymentLabels | nindent 4 }} - {{- end }} -spec: - type: {{ .Values.webserver.service.type }} - ports: - - port: {{ .Values.webserver.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "danswer-stack.selectorLabels" . | nindent 4 }} - {{- if .Values.webserver.deploymentLabels }} - {{- toYaml .Values.webserver.deploymentLabels | nindent 4 }} - {{- end }} diff --git a/deployment/helm/values.yaml b/deployment/helm/values.yaml deleted file mode 100644 index 00900095343..00000000000 --- a/deployment/helm/values.yaml +++ /dev/null @@ -1,473 +0,0 @@ -# Default values for danswer-stack. -# This is a YAML-formatted file. -# Declare variables to be passed into your templates. - -imagePullSecrets: [] -nameOverride: "" -fullnameOverride: "" -appVersionOverride: # e.g "v0.3.93" - -inferenceCapability: - service: - name: inference-model-server-service - type: ClusterIP - port: 9000 - pvc: - name: inference-model-pvc - accessModes: - - ReadWriteOnce - storage: 3Gi - deployment: - name: inference-model-server-deployment - replicas: 1 - labels: - - key: app - value: inference-model-server - image: - repository: danswer/danswer-model-server - tag: - pullPolicy: IfNotPresent - command: ["uvicorn", "model_server.main:app", "--host", "0.0.0.0", "--port", "9000"] - port: 9000 - volumeMounts: - - name: inference-model-storage - mountPath: /root/.cache - volumes: - - name: inference-model-storage - persistentVolumeClaim: - claimName: inference-model-pvc - podLabels: - - key: app - value: inference-model-server - -indexCapability: - deployment: - image: - repository: danswer/danswer-model-server - tag: - pullPolicy: IfNotPresent - resources: - # For example - # limits: - # nvidia.com/gpu: 1 - # The strategy to use for rolling out deployment updates - # If using GPU indexing with a limited number of GPUs available, - # this can be set to type: Recreate instead. - updateStrategy: - rollingUpdate: - maxSurge: 25% - maxUnavailable: 25% - type: RollingUpdate - service: - type: ClusterIP - port: 9000 - name: indexing-model-server-port - deploymentLabels: - app: indexing-model-server - podLabels: - app: indexing-model-server - indexingOnly: "True" - podAnnotations: {} - volumeMounts: - - name: indexing-model-storage - mountPath: /root/.cache - volumes: - - name: indexing-model-storage - persistentVolumeClaim: - claimName: indexing-model-storage - indexingModelPVC: - name: indexing-model-storage - accessMode: "ReadWriteOnce" - storage: "3Gi" - - -config: - envConfigMapName: env-configmap - -serviceAccount: - # Specifies whether a service account should be created - create: false - # Automatically mount a ServiceAccount's API credentials? - automount: true - # Annotations to add to the service account - annotations: {} - # The name of the service account to use. - # If not set and create is true, a name is generated using the fullname template - name: "" - -postgresql: - primary: - persistence: - size: 5Gi - enabled: true - auth: - existingSecret: danswer-secrets - secretKeys: - adminPasswordKey: postgres_password #overwriting as postgres typically expects 'postgres-password' - -nginx: - containerPorts: - http: 1024 - extraEnvVars: - - name: DOMAIN - value: localhost - service: - ports: - http: 80 - danswer: 3000 - targetPort: - http: http - danswer: http - - existingServerBlockConfigmap: danswer-nginx-conf - -webserver: - replicaCount: 1 - image: - repository: danswer/danswer-web-server - pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion. - tag: - deploymentLabels: - app: web-server - podAnnotations: {} - podLabels: - app: web-server - podSecurityContext: {} - # fsGroup: 2000 - - securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - - service: - type: ClusterIP - port: 3000 - - resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi - - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - - # Additional volumes on the output Deployment definition. - volumes: [] - # - name: foo - # secret: - # secretName: mysecret - # optional: false - - # Additional volumeMounts on the output Deployment definition. - volumeMounts: [] - # - name: foo - # mountPath: "/etc/foo" - # readOnly: true - - nodeSelector: {} - tolerations: [] - affinity: {} - -api: - replicaCount: 1 - image: - repository: danswer/danswer-backend - pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion. - tag: - deploymentLabels: - app: api-server - podAnnotations: {} - podLabels: - scope: danswer-backend - app: api-server - - podSecurityContext: {} - # fsGroup: 2000 - - securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - - service: - type: ClusterIP - port: 8080 - - resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # requests: - # cpu: 1000m # Requests 1 CPU core - # memory: 1Gi # Requests 1 GiB of memory - # limits: - # cpu: 2000m # Limits to 2 CPU cores - # memory: 2Gi # Limits to 2 GiB of memory - - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - - # Additional volumes on the output Deployment definition. - volumes: [] - # - name: foo - # secret: - # secretName: mysecret - # optional: false - - # Additional volumeMounts on the output Deployment definition. - volumeMounts: [] - # - name: foo - # mountPath: "/etc/foo" - # readOnly: true - - nodeSelector: {} - tolerations: [] - - -background: - replicaCount: 1 - image: - repository: danswer/danswer-backend - pullPolicy: IfNotPresent - # Overrides the image tag whose default is the chart appVersion. - tag: - podAnnotations: {} - podLabels: - scope: danswer-backend - app: background - deploymentLabels: - app: background - podSecurityContext: {} - # fsGroup: 2000 - - securityContext: {} - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - enableMiniChunk: "true" - resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # requests: - # cpu: 1000m # Requests 1 CPU core - # memory: 1Gi # Requests 1 GiB of memory - # limits: - # cpu: 2000m # Limits to 2 CPU cores - # memory: 2Gi # Limits to 2 GiB of memory - - autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 100 - targetCPUUtilizationPercentage: 80 - # targetMemoryUtilizationPercentage: 80 - - # Additional volumes on the output Deployment definition. - volumes: [] - # - name: foo - # secret: - # secretName: mysecret - # optional: false - - # Additional volumeMounts on the output Deployment definition. - volumeMounts: [] - # - name: foo - # mountPath: "/etc/foo" - # readOnly: true - - nodeSelector: {} - tolerations: [] - -vespa: - replicaCount: 1 - image: - repository: vespa - pullPolicy: IfNotPresent - tag: "8.277.17" - podAnnotations: {} - podLabels: - app: vespa - app.kubernetes.io/instance: danswer - app.kubernetes.io/name: vespa - enabled: true - - podSecurityContext: {} - # fsGroup: 2000 - - securityContext: - privileged: true - runAsUser: 0 - # capabilities: - # drop: - # - ALL - # readOnlyRootFilesystem: true - # runAsNonRoot: true - # runAsUser: 1000 - - resources: - # The Vespa Helm chart specifies default resources, which are quite modest. We override - # them here to increase chances of the chart running successfully. - requests: - cpu: 1500m - memory: 4000Mi - limits: - cpu: 1500m - memory: 4000Mi - - nodeSelector: {} - tolerations: [] - affinity: {} - - -#ingress: -# enabled: false -# className: "" -# annotations: {} -# # kubernetes.io/ingress.class: nginx -# # kubernetes.io/tls-acme: "true" -# hosts: -# - host: chart-example.local -# paths: -# - path: / -# pathType: ImplementationSpecific -# tls: [] -# # - secretName: chart-example-tls -# # hosts: -# # - chart-example.local - -persistence: - vespa: - enabled: true - existingClaim: "" - storageClassName: "" - accessModes: - - ReadWriteOnce - size: 5Gi - -auth: - # for storing smtp, oauth, slack, and other secrets - # keys are lowercased version of env vars (e.g. SMTP_USER -> smtp_user) - existingSecret: "" # danswer-secrets - # optionally override the secret keys to reference in the secret - secretKeys: - postgres_password: "postgres_password" - smtp_pass: "" - oauth_client_id: "" - oauth_client_secret: "" - oauth_cookie_secret: "" - gen_ai_api_key: "" - danswer_bot_slack_app_token: "" - danswer_bot_slack_bot_token: "" - # will be overridden by the existingSecret if set - secretName: "danswer-secrets" - # set values as strings, they will be base64 encoded - secrets: - postgres_password: "postgres" - smtp_pass: "" - oauth_client_id: "" - oauth_client_secret: "" - oauth_cookie_secret: "" - gen_ai_api_key: "" - danswer_bot_slack_app_token: "" - danswer_bot_slack_bot_token: "" - -configMap: - AUTH_TYPE: "disabled" # Change this for production uses unless Danswer is only accessible behind VPN - SESSION_EXPIRE_TIME_SECONDS: "86400" # 1 Day Default - VALID_EMAIL_DOMAINS: "" # Can be something like danswer.ai, as an extra double-check - SMTP_SERVER: "" # For sending verification emails, if unspecified then defaults to 'smtp.gmail.com' - SMTP_PORT: "" # For sending verification emails, if unspecified then defaults to '587' - SMTP_USER: "" # 'your-email@company.com' - # SMTP_PASS: "" # 'your-gmail-password' - EMAIL_FROM: "" # 'your-email@company.com' SMTP_USER missing used instead - # Gen AI Settings - GEN_AI_MODEL_PROVIDER: "" - GEN_AI_MODEL_VERSION: "" - FAST_GEN_AI_MODEL_VERSION: "" - # GEN_AI_API_KEY: "" - GEN_AI_API_ENDPOINT: "" - GEN_AI_API_VERSION: "" - GEN_AI_LLM_PROVIDER_TYPE: "" - GEN_AI_MAX_TOKENS: "" - QA_TIMEOUT: "60" - MAX_CHUNKS_FED_TO_CHAT: "" - DISABLE_LLM_DOC_RELEVANCE: "" - DISABLE_LLM_CHOOSE_SEARCH: "" - DISABLE_LLM_QUERY_REPHRASE: "" - # Query Options - DOC_TIME_DECAY: "" - HYBRID_ALPHA: "" - EDIT_KEYWORD_QUERY: "" - MULTILINGUAL_QUERY_EXPANSION: "" - LANGUAGE_HINT: "" - LANGUAGE_CHAT_NAMING_HINT: "" - QA_PROMPT_OVERRIDE: "" - # Internet Search Tool - BING_API_KEY: "" - # Don't change the NLP models unless you know what you're doing - DOCUMENT_ENCODER_MODEL: "" - NORMALIZE_EMBEDDINGS: "" - ASYM_QUERY_PREFIX: "" - ASYM_PASSAGE_PREFIX: "" - DISABLE_RERANK_FOR_STREAMING: "" - MODEL_SERVER_PORT: "" - MIN_THREADS_ML_MODELS: "" - # Indexing Configs - NUM_INDEXING_WORKERS: "" - DISABLE_INDEX_UPDATE_ON_SWAP: "" - DASK_JOB_CLIENT_ENABLED: "" - CONTINUE_ON_CONNECTOR_FAILURE: "" - EXPERIMENTAL_CHECKPOINTING_ENABLED: "" - CONFLUENCE_CONNECTOR_LABELS_TO_SKIP: "" - JIRA_API_VERSION: "" - GONG_CONNECTOR_START_TIME: "" - NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP: "" - # DanswerBot SlackBot Configs - # DANSWER_BOT_SLACK_APP_TOKEN: "" - # DANSWER_BOT_SLACK_BOT_TOKEN: "" - DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER: "" - DANSWER_BOT_DISPLAY_ERROR_MSGS: "" - DANSWER_BOT_RESPOND_EVERY_CHANNEL: "" - DANSWER_BOT_DISABLE_COT: "" # Currently unused - NOTIFY_SLACKBOT_NO_ANSWER: "" - # Logging - # Optional Telemetry, please keep it on (nothing sensitive is collected)? <3 - # https://docs.danswer.dev/more/telemetry - DISABLE_TELEMETRY: "" - LOG_LEVEL: "" - LOG_ALL_MODEL_INTERACTIONS: "" - LOG_DANSWER_MODEL_INTERACTIONS: "" - LOG_VESPA_TIMING_INFORMATION: "" - # Shared or Non-backend Related - WEB_DOMAIN: "http://localhost:3000" # for web server and api server - DOMAIN: "localhost" # for nginx diff --git a/deployment/kubernetes/api_server-service-deployment.yaml b/deployment/kubernetes/api_server-service-deployment.yaml deleted file mode 100644 index eeac5fecc96..00000000000 --- a/deployment/kubernetes/api_server-service-deployment.yaml +++ /dev/null @@ -1,57 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: api-server-service -spec: - selector: - app: api-server - ports: - - name: api-server-port - protocol: TCP - port: 80 - targetPort: 8080 - type: ClusterIP ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: api-server-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: api-server - template: - metadata: - labels: - app: api-server - spec: - containers: - - name: api-server - image: danswer/danswer-backend:latest - imagePullPolicy: IfNotPresent - command: - - "/bin/sh" - - "-c" - - | - alembic upgrade head && - echo "Starting Danswer Api Server" && - uvicorn danswer.main:app --host 0.0.0.0 --port 8080 - ports: - - containerPort: 8080 - # There are some extra values since this is shared between services - # There are no conflicts though, extra env variables are simply ignored - env: - - name: OAUTH_CLIENT_ID - valueFrom: - secretKeyRef: - name: danswer-secrets - key: google_oauth_client_id - - name: OAUTH_CLIENT_SECRET - valueFrom: - secretKeyRef: - name: danswer-secrets - key: google_oauth_client_secret - envFrom: - - configMapRef: - name: env-configmap diff --git a/deployment/kubernetes/background-deployment.yaml b/deployment/kubernetes/background-deployment.yaml deleted file mode 100644 index 18521b0f5ad..00000000000 --- a/deployment/kubernetes/background-deployment.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: background-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: background - template: - metadata: - labels: - app: background - spec: - containers: - - name: background - image: danswer/danswer-backend:latest - imagePullPolicy: IfNotPresent - command: ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] - # There are some extra values since this is shared between services - # There are no conflicts though, extra env variables are simply ignored - envFrom: - - configMapRef: - name: env-configmap diff --git a/deployment/kubernetes/env-configmap.yaml b/deployment/kubernetes/env-configmap.yaml deleted file mode 100644 index 907fae1c836..00000000000 --- a/deployment/kubernetes/env-configmap.yaml +++ /dev/null @@ -1,84 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: env-configmap -data: - # Auth Setting, also check the secrets file - AUTH_TYPE: "disabled" # Change this for production uses unless Danswer is only accessible behind VPN - ENCRYPTION_KEY_SECRET: "" # This should not be specified directly in the yaml, this is just for reference - SESSION_EXPIRE_TIME_SECONDS: "86400" # 1 Day Default - VALID_EMAIL_DOMAINS: "" # Can be something like danswer.ai, as an extra double-check - SMTP_SERVER: "" # For sending verification emails, if unspecified then defaults to 'smtp.gmail.com' - SMTP_PORT: "" # For sending verification emails, if unspecified then defaults to '587' - SMTP_USER: "" # 'your-email@company.com' - SMTP_PASS: "" # 'your-gmail-password' - EMAIL_FROM: "" # 'your-email@company.com' SMTP_USER missing used instead - # Gen AI Settings - GEN_AI_MODEL_PROVIDER: "" - GEN_AI_MODEL_VERSION: "" - FAST_GEN_AI_MODEL_VERSION: "" - GEN_AI_API_KEY: "" - GEN_AI_API_ENDPOINT: "" - GEN_AI_API_VERSION: "" - GEN_AI_LLM_PROVIDER_TYPE: "" - GEN_AI_MAX_TOKENS: "" - QA_TIMEOUT: "60" - MAX_CHUNKS_FED_TO_CHAT: "" - DISABLE_LLM_DOC_RELEVANCE: "" - DISABLE_LLM_CHOOSE_SEARCH: "" - DISABLE_LLM_QUERY_REPHRASE: "" - # Query Options - DOC_TIME_DECAY: "" - HYBRID_ALPHA: "" - EDIT_KEYWORD_QUERY: "" - MULTILINGUAL_QUERY_EXPANSION: "" - LANGUAGE_HINT: "" - LANGUAGE_CHAT_NAMING_HINT: "" - QA_PROMPT_OVERRIDE: "" - # Other Services - POSTGRES_HOST: "relational-db-service" - VESPA_HOST: "document-index-service" - # Internet Search Tool - BING_API_KEY: "" - # Don't change the NLP models unless you know what you're doing - DOCUMENT_ENCODER_MODEL: "" - NORMALIZE_EMBEDDINGS: "" - ASYM_QUERY_PREFIX: "" - ASYM_PASSAGE_PREFIX: "" - DISABLE_RERANK_FOR_STREAMING: "" - MODEL_SERVER_HOST: "inference-model-server-service" - MODEL_SERVER_PORT: "" - INDEXING_MODEL_SERVER_HOST: "indexing-model-server-service" - MIN_THREADS_ML_MODELS: "" - # Indexing Configs - NUM_INDEXING_WORKERS: "" - ENABLED_CONNECTOR_TYPES: "" - DISABLE_INDEX_UPDATE_ON_SWAP: "" - DASK_JOB_CLIENT_ENABLED: "" - CONTINUE_ON_CONNECTOR_FAILURE: "" - EXPERIMENTAL_CHECKPOINTING_ENABLED: "" - CONFLUENCE_CONNECTOR_LABELS_TO_SKIP: "" - JIRA_API_VERSION: "" - WEB_CONNECTOR_VALIDATE_URLS: "" - GONG_CONNECTOR_START_TIME: "" - NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP: "" - # DanswerBot SlackBot Configs - DANSWER_BOT_SLACK_APP_TOKEN: "" - DANSWER_BOT_SLACK_BOT_TOKEN: "" - DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER: "" - DANSWER_BOT_DISPLAY_ERROR_MSGS: "" - DANSWER_BOT_RESPOND_EVERY_CHANNEL: "" - DANSWER_BOT_DISABLE_COT: "" # Currently unused - NOTIFY_SLACKBOT_NO_ANSWER: "" - # Logging - # Optional Telemetry, please keep it on (nothing sensitive is collected)? <3 - # https://docs.danswer.dev/more/telemetry - DISABLE_TELEMETRY: "" - LOG_LEVEL: "" - LOG_ALL_MODEL_INTERACTIONS: "" - LOG_DANSWER_MODEL_INTERACTIONS: "" - LOG_VESPA_TIMING_INFORMATION: "" - # Shared or Non-backend Related - INTERNAL_URL: "http://api-server-service:80" # for web server - WEB_DOMAIN: "http://localhost:3000" # for web server and api server - DOMAIN: "localhost" # for nginx diff --git a/deployment/kubernetes/indexing_model_server-service-deployment.yaml b/deployment/kubernetes/indexing_model_server-service-deployment.yaml deleted file mode 100644 index d44b52e9289..00000000000 --- a/deployment/kubernetes/indexing_model_server-service-deployment.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: indexing-model-server-service -spec: - selector: - app: indexing-model-server - ports: - - name: indexing-model-server-port - protocol: TCP - port: 9000 - targetPort: 9000 - type: ClusterIP ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: indexing-model-server-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: indexing-model-server - template: - metadata: - labels: - app: indexing-model-server - spec: - containers: - - name: indexing-model-server - image: danswer/danswer-model-server:latest - imagePullPolicy: IfNotPresent - command: [ "uvicorn", "model_server.main:app", "--host", "0.0.0.0", "--port", "9000" ] - ports: - - containerPort: 9000 - envFrom: - - configMapRef: - name: env-configmap - env: - - name: INDEXING_ONLY - value: "True" - volumeMounts: - - name: indexing-model-storage - mountPath: /root/.cache - volumes: - - name: indexing-model-storage - persistentVolumeClaim: - claimName: indexing-model-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: indexing-model-pvc -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 3Gi diff --git a/deployment/kubernetes/inference_model_server-service-deployment.yaml b/deployment/kubernetes/inference_model_server-service-deployment.yaml deleted file mode 100644 index 790dc633db8..00000000000 --- a/deployment/kubernetes/inference_model_server-service-deployment.yaml +++ /dev/null @@ -1,56 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: inference-model-server-service -spec: - selector: - app: inference-model-server - ports: - - name: inference-model-server-port - protocol: TCP - port: 9000 - targetPort: 9000 - type: ClusterIP ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: inference-model-server-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: inference-model-server - template: - metadata: - labels: - app: inference-model-server - spec: - containers: - - name: inference-model-server - image: danswer/danswer-model-server:latest - imagePullPolicy: IfNotPresent - command: [ "uvicorn", "model_server.main:app", "--host", "0.0.0.0", "--port", "9000" ] - ports: - - containerPort: 9000 - envFrom: - - configMapRef: - name: env-configmap - volumeMounts: - - name: inference-model-storage - mountPath: /root/.cache - volumes: - - name: inference-model-storage - persistentVolumeClaim: - claimName: inference-model-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: inference-model-pvc -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 3Gi diff --git a/deployment/kubernetes/nginx-configmap.yaml b/deployment/kubernetes/nginx-configmap.yaml deleted file mode 100644 index 08b945d599c..00000000000 --- a/deployment/kubernetes/nginx-configmap.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: nginx-configmap -data: - nginx.conf: | - upstream api_server { - server api-server-service:80 fail_timeout=0; - } - - upstream web_server { - server web-server-service:80 fail_timeout=0; - } - - server { - listen 80; - server_name $$DOMAIN; - - client_max_body_size 5G; # Maximum upload size - - location ~ ^/api(.*)$ { - rewrite ^/api(/.*)$ $1 break; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - proxy_http_version 1.1; - proxy_buffering off; - proxy_redirect off; - proxy_pass http://api_server; - } - - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header Host $host; - proxy_http_version 1.1; - proxy_redirect off; - proxy_pass http://web_server; - } - } diff --git a/deployment/kubernetes/nginx-service-deployment.yaml b/deployment/kubernetes/nginx-service-deployment.yaml deleted file mode 100644 index 27b14794ee3..00000000000 --- a/deployment/kubernetes/nginx-service-deployment.yaml +++ /dev/null @@ -1,55 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: nginx-service -spec: - selector: - app: nginx - ports: - - name: http - protocol: TCP - port: 80 - targetPort: 80 - - name: danswer - protocol: TCP - port: 3000 - targetPort: 80 - type: LoadBalancer ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: nginx-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: nginx - template: - metadata: - labels: - app: nginx - spec: - containers: - - name: nginx - image: nginx:1.23.4-alpine - ports: - - containerPort: 80 - env: - - name: DOMAIN - valueFrom: - configMapKeyRef: - name: env-configmap - key: DOMAIN - volumeMounts: - - name: nginx-conf - mountPath: /etc/nginx/conf.d - command: - - /bin/sh - - -c - - | - while :; do sleep 6h & wait $$!; nginx -s reload; done & nginx -g "daemon off;" - volumes: - - name: nginx-conf - configMap: - name: nginx-configmap diff --git a/deployment/kubernetes/postgres-service-deployment.yaml b/deployment/kubernetes/postgres-service-deployment.yaml deleted file mode 100644 index 33f2200b801..00000000000 --- a/deployment/kubernetes/postgres-service-deployment.yaml +++ /dev/null @@ -1,58 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: relational-db-service -spec: - selector: - app: relational-db - ports: - - protocol: TCP - port: 5432 - targetPort: 5432 - clusterIP: None ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: relational-db-statefulset -spec: - serviceName: relational-db-service - replicas: 1 - selector: - matchLabels: - app: relational-db - template: - metadata: - labels: - app: relational-db - spec: - containers: - - name: relational-db - image: postgres:15.2-alpine - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: danswer-secrets - key: postgres_user - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: danswer-secrets - key: postgres_password - args: ["-c", "max_connections=150"] - ports: - - containerPort: 5432 - volumeMounts: - - mountPath: /var/lib/postgresql/data - name: db-storage - subPath: postgres - volumeClaimTemplates: - - metadata: - name: db-storage - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - # Adjust the storage request size as needed. - storage: 5Gi diff --git a/deployment/kubernetes/secrets.yaml b/deployment/kubernetes/secrets.yaml deleted file mode 100644 index c135a29f676..00000000000 --- a/deployment/kubernetes/secrets.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# The values in this file should be changed -apiVersion: v1 -kind: Secret -metadata: - name: danswer-secrets -type: Opaque -data: - postgres_user: cG9zdGdyZXM= # "postgres" base64 encoded - postgres_password: cGFzc3dvcmQ= # "password" base64 encoded - google_oauth_client_id: ZXhhbXBsZS1jbGllbnQtaWQ= # "example-client-id" base64 encoded. You will need to provide this, use echo -n "your-client-id" | base64 - google_oauth_client_secret: example_google_oauth_secret # "example-client-secret" base64 encoded. You will need to provide this, use echo -n "your-client-id" | base64 diff --git a/deployment/kubernetes/vespa-service-deployment.yaml b/deployment/kubernetes/vespa-service-deployment.yaml deleted file mode 100644 index 5016258b757..00000000000 --- a/deployment/kubernetes/vespa-service-deployment.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: document-index-service -spec: - selector: - app: vespa - ports: - - name: vespa-tenant-port - protocol: TCP - port: 19071 - targetPort: 19071 - - name: vespa-port - protocol: TCP - port: 8081 - targetPort: 8081 - type: LoadBalancer ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: vespa - labels: - app: vespa -spec: - replicas: 1 - serviceName: vespa - selector: - matchLabels: - app: vespa - template: - metadata: - labels: - app: vespa - spec: - containers: - - name: vespa - image: vespaengine/vespa:8.277.17 - imagePullPolicy: IfNotPresent - securityContext: - privileged: true - runAsUser: 0 - ports: - - containerPort: 19071 - - containerPort: 8081 - readinessProbe: - httpGet: - path: /state/v1/health - port: 19071 - scheme: HTTP - volumeMounts: - - name: vespa-storage - mountPath: /opt/vespa/var/ - volumeClaimTemplates: - - metadata: - name: vespa-storage - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - # Adjust the storage request size as needed. - storage: 5Gi diff --git a/deployment/kubernetes/web_server-service-deployment.yaml b/deployment/kubernetes/web_server-service-deployment.yaml deleted file mode 100644 index b19b8e37986..00000000000 --- a/deployment/kubernetes/web_server-service-deployment.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: web-server-service -spec: - selector: - app: web-server - ports: - - protocol: TCP - port: 80 - targetPort: 3000 - type: ClusterIP ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: web-server-deployment -spec: - replicas: 1 - selector: - matchLabels: - app: web-server - template: - metadata: - labels: - app: web-server - spec: - containers: - - name: web-server - image: danswer/danswer-web-server:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 3000 - # There are some extra values since this is shared between services - # There are no conflicts though, extra env variables are simply ignored - envFrom: - - configMapRef: - name: env-configmap diff --git a/examples/widget/.env.example b/examples/widget/.env.example deleted file mode 100644 index b92284bf274..00000000000 --- a/examples/widget/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_URL=https://example.danswer.ai -NEXT_PUBLIC_API_KEY=some_long_api_key_here \ No newline at end of file diff --git a/examples/widget/.eslintrc.json b/examples/widget/.eslintrc.json deleted file mode 100644 index bffb357a712..00000000000 --- a/examples/widget/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/examples/widget/.gitignore b/examples/widget/.gitignore deleted file mode 100644 index fd3dbb571a1..00000000000 --- a/examples/widget/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/examples/widget/README.md b/examples/widget/README.md deleted file mode 100644 index cb32ecd073a..00000000000 --- a/examples/widget/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Danswer Chat Bot Widget -Note: The widget requires a Danswer API key, which is a paid (cloud/enterprise) feature. - -This is a code example for how you can use Danswer's APIs to build a chat bot widget for a website! The main code to look at can be found in `src/app/widget/Widget.tsx`. - -## Getting Started - -To get the widget working on your webpage, follow these steps: - -### 1. Install Dependencies - -Ensure you have the necessary dependencies installed. From the `examples/widget/README.md` file: -```bash -npm i -``` - - -### 2. Set Environment Variables - -Make sure to set the environment variables `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_API_KEY` in a `.env` file at the root of your project: - -```bash -NEXT_PUBLIC_API_URL= -NEXT_PUBLIC_API_KEY= -``` - -### 3. Run the Development Server - -Start the development server to see the widget in action. - -```bash -npm run dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -### 4. Integrate the Widget - -To integrate the widget into your webpage, you can use the `ChatWidget` component. Here’s an example of how to include it in a page component: - -```jsx -import ChatWidget from 'path/to/ChatWidget'; -function MyPage() { -return ( -

-

My Webpage

- -
-); -} -export default MyPage; -``` - - -### 5. Deploy - -Once you are satisfied with the widget, you can build and start the application for production: - -```bash -npm run build -npm run start -``` - -### Custom Styling and Configuration - -If you need to customize the widget, you can modify the `ChatWidget` component in the `examples/widget/src/app/widget/Widget.tsx` file. - -By following these steps, you should be able to get the chat widget working on your webpage. - -If you want to get fancier, then take a peek at the Chat implementation within Danswer itself [here](https://github.com/danswer-ai/danswer/blob/main/web/src/app/chat/ChatPage.tsx#L82). \ No newline at end of file diff --git a/examples/widget/next.config.mjs b/examples/widget/next.config.mjs deleted file mode 100644 index 4678774e6d6..00000000000 --- a/examples/widget/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/examples/widget/package-lock.json b/examples/widget/package-lock.json deleted file mode 100644 index bd7c54d2081..00000000000 --- a/examples/widget/package-lock.json +++ /dev/null @@ -1,5933 +0,0 @@ -{ - "name": "widget", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "widget", - "version": "0.1.0", - "dependencies": { - "next": "14.2.5", - "react": "^18", - "react-dom": "^18", - "react-markdown": "^8.0.6" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "autoprefixer": "^10.4.19", - "eslint": "^8", - "eslint-config-next": "14.2.5", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.6", - "typescript": "^5" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", - "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", - "integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.5.tgz", - "integrity": "sha512-LY3btOpPh+OTIpviNojDpUdIbHW9j0JBYBjsIp8IxtDFfYFyORvw3yNq6N231FVqQA7n7lwaf7xHbVJlA1ED7g==", - "dev": true, - "dependencies": { - "glob": "10.3.10" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz", - "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz", - "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz", - "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz", - "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz", - "integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz", - "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz", - "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz", - "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz", - "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", - "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", - "dev": true - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", - "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "20.14.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", - "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" - }, - "node_modules/@types/react": { - "version": "18.3.3", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", - "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", - "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dev": true, - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", - "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "7.2.0", - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/typescript-estree": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", - "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", - "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", - "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", - "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.toreversed": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", - "integrity": "sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.9.1.tgz", - "integrity": "sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", - "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", - "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.2.tgz", - "integrity": "sha512-qkqSyistMYdxAcw+CzbZwlBy8AGmS/eEWs+sEV5TnLRGDOL+C5M2EnH6tlZyg0YoAxGJAFKh61En9BR941GnHA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001640", - "electron-to-chromium": "^1.4.820", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.1.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "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" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001642", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", - "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.4.832", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.832.tgz", - "integrity": "sha512-cTen3SB0H2SGU7x467NRe1eVcQgcuS6jckKfWJHia2eo0cHIGOqHoAxevIYZD4eRHcWjkvFzo93bi3vJ9W+1lA==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", - "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "iterator.prototype": "^1.1.2", - "safe-array-concat": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.5.tgz", - "integrity": "sha512-zogs9zlOiZ7ka+wgUnmcM0KBEDjo4Jis7kxN1jvC0N4wynQ2MIx/KBkg4mVF63J5EK4W0QMCn7xO3vNisjaAoA==", - "dev": true, - "dependencies": { - "@next/eslint-plugin-next": "14.2.5", - "@rushstack/eslint-patch": "^1.3.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", - "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "enhanced-resolve": "^5.12.0", - "eslint-module-utils": "^2.7.4", - "fast-glob": "^3.3.1", - "get-tsconfig": "^4.5.0", - "is-core-module": "^2.11.0", - "is-glob": "^4.0.3" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", - "dev": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", - "semver": "^6.3.1", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.9.0.tgz", - "integrity": "sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==", - "dev": true, - "dependencies": { - "aria-query": "~5.1.3", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.9.1", - "axobject-query": "~3.1.1", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.19", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.0" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.34.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.4.tgz", - "integrity": "sha512-Np+jo9bUwJNxCsT12pXtrGhJgT3T44T1sHhn1Ssr42XFn8TES0267wPGo5nNrMHi8qkyimDAX2BUmkf9pSaVzA==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.2", - "array.prototype.toreversed": "^1.1.2", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.19", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.8", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.0", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.11", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.6.tgz", - "integrity": "sha512-ZAqrLlu18NbDdRaHq+AKXzAmqIUPswPWKUchfytdAjiRFnCe5ojG2bstg6mRiZabkKfCoL/e98pbBELIV/YCeA==", - "dev": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", - "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "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" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/inline-style-parser": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", - "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", - "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dev": true, - "dependencies": { - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", - "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dev": true, - "dependencies": { - "which-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/iterator.prototype": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", - "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "reflect.getprototypeof": "^1.0.4", - "set-function-name": "^2.0.1" - } - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, - "node_modules/mdast-util-definitions": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", - "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", - "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "mdast-util-to-string": "^3.1.0", - "micromark": "^3.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-decode-string": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "unist-util-stringify-position": "^3.0.0", - "uvu": "^0.5.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", - "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-definitions": "^5.0.0", - "micromark-util-sanitize-uri": "^1.1.0", - "trim-lines": "^3.0.0", - "unist-util-generated": "^2.0.0", - "unist-util-position": "^4.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", - "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", - "dependencies": { - "@types/mdast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", - "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "micromark-core-commonmark": "^1.0.1", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", - "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-factory-destination": "^1.0.0", - "micromark-factory-label": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-factory-title": "^1.0.0", - "micromark-factory-whitespace": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-html-tag-name": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-destination": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", - "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", - "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", - "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", - "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", - "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", - "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", - "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", - "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", - "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", - "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", - "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", - "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", - "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", - "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", - "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/next": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz", - "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", - "dependencies": { - "@next/env": "14.2.5", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", - "postcss": "8.4.31", - "styled-jsx": "5.1.1" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=18.17.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.5", - "@next/swc-darwin-x64": "14.2.5", - "@next/swc-linux-arm64-gnu": "14.2.5", - "@next/swc-linux-arm64-musl": "14.2.5", - "@next/swc-linux-x64-gnu": "14.2.5", - "@next/swc-linux-x64-musl": "14.2.5", - "@next/swc-win32-arm64-msvc": "14.2.5", - "@next/swc-win32-ia32-msvc": "14.2.5", - "@next/swc-win32-x64-msvc": "14.2.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-releases": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz", - "integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "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" - } - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", - "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-markdown": { - "version": "8.0.6", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.6.tgz", - "integrity": "sha512-KgPWsYgHuftdx510wwIzpwf+5js/iHqBR+fzxefv8Khk3mFbnioF1bmL2idHN3ler0LMQmICKeDrWnZrX9mtbQ==", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/prop-types": "^15.0.0", - "@types/unist": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^2.0.0", - "prop-types": "^15.0.0", - "property-information": "^6.0.0", - "react-is": "^18.0.0", - "remark-parse": "^10.0.0", - "remark-rehype": "^10.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^0.4.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/react-markdown/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", - "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.1", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "which-builtin-type": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/remark-parse": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", - "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", - "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-from-markdown": "^1.0.0", - "unified": "^10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz", - "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-to-hast": "^12.1.0", - "unified": "^10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "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" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-regex": "^1.1.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/string.prototype.includes": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz", - "integrity": "sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", - "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "regexp.prototype.flags": "^1.5.2", - "set-function-name": "^2.0.2", - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-object": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", - "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", - "dependencies": { - "inline-style-parser": "0.1.1" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", - "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "node_modules/unified": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", - "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", - "dependencies": { - "@types/unist": "^2.0.0", - "bail": "^2.0.0", - "extend": "^3.0.0", - "is-buffer": "^2.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-generated": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", - "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", - "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", - "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", - "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", - "dev": true, - "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", - "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", - "is-finalizationregistry": "^1.0.2", - "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yaml": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", - "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/examples/widget/package.json b/examples/widget/package.json deleted file mode 100644 index 4ceb4893084..00000000000 --- a/examples/widget/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "widget", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "next": "14.2.5", - "react": "^18", - "react-dom": "^18", - "react-markdown": "^8.0.6" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^18", - "@types/react-dom": "^18", - "autoprefixer": "^10.4.19", - "eslint": "^8", - "eslint-config-next": "14.2.5", - "postcss": "^8.4.39", - "tailwindcss": "^3.4.6", - "typescript": "^5" - } -} diff --git a/examples/widget/postcss.config.mjs b/examples/widget/postcss.config.mjs deleted file mode 100644 index 1a69fd2a450..00000000000 --- a/examples/widget/postcss.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - tailwindcss: {}, - }, -}; - -export default config; diff --git a/examples/widget/src/app/globals.css b/examples/widget/src/app/globals.css deleted file mode 100644 index b5c61c95671..00000000000 --- a/examples/widget/src/app/globals.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/examples/widget/src/app/layout.tsx b/examples/widget/src/app/layout.tsx deleted file mode 100644 index 2cabefe9681..00000000000 --- a/examples/widget/src/app/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; - -import "./globals.css"; - -const inter = Inter({ subsets: ["latin"] }); - -export const metadata: Metadata = { - title: "Example Danswer Widget", - description: "Example Danswer Widget", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); -} diff --git a/examples/widget/src/app/page.tsx b/examples/widget/src/app/page.tsx deleted file mode 100644 index 945cbee37db..00000000000 --- a/examples/widget/src/app/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { ChatWidget } from "./widget/Widget"; - -export default function Home() { - return ( -
- -
- ); -} diff --git a/examples/widget/src/app/widget/Widget.tsx b/examples/widget/src/app/widget/Widget.tsx deleted file mode 100644 index 44654993c84..00000000000 --- a/examples/widget/src/app/widget/Widget.tsx +++ /dev/null @@ -1,344 +0,0 @@ -"use client"; - -import React, { useState } from "react"; -import ReactMarkdown from "react-markdown"; - -const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"; -const API_KEY = process.env.NEXT_PUBLIC_API_KEY || ""; - -type NonEmptyObject = { [k: string]: any }; - -const processSingleChunk = ( - chunk: string, - currPartialChunk: string | null, -): [T | null, string | null] => { - const completeChunk = (currPartialChunk || "") + chunk; - try { - // every complete chunk should be valid JSON - const chunkJson = JSON.parse(completeChunk); - return [chunkJson, null]; - } catch (err) { - // if it's not valid JSON, then it's probably an incomplete chunk - return [null, completeChunk]; - } -}; - -const processRawChunkString = ( - rawChunkString: string, - previousPartialChunk: string | null, -): [T[], string | null] => { - /* This is required because, in practice, we see that nginx does not send over - each chunk one at a time even with buffering turned off. Instead, - chunks are sometimes in batches or are sometimes incomplete */ - if (!rawChunkString) { - return [[], null]; - } - const chunkSections = rawChunkString - .split("\n") - .filter((chunk) => chunk.length > 0); - let parsedChunkSections: T[] = []; - let currPartialChunk = previousPartialChunk; - chunkSections.forEach((chunk) => { - const [processedChunk, partialChunk] = processSingleChunk( - chunk, - currPartialChunk, - ); - if (processedChunk) { - parsedChunkSections.push(processedChunk); - currPartialChunk = null; - } else { - currPartialChunk = partialChunk; - } - }); - - return [parsedChunkSections, currPartialChunk]; -}; - -async function* handleStream( - streamingResponse: Response, -): AsyncGenerator { - const reader = streamingResponse.body?.getReader(); - const decoder = new TextDecoder("utf-8"); - - let previousPartialChunk: string | null = null; - while (true) { - const rawChunk = await reader?.read(); - if (!rawChunk) { - throw new Error("Unable to process chunk"); - } - const { done, value } = rawChunk; - if (done) { - break; - } - - const [completedChunks, partialChunk] = processRawChunkString( - decoder.decode(value, { stream: true }), - previousPartialChunk, - ); - if (!completedChunks.length && !partialChunk) { - break; - } - previousPartialChunk = partialChunk as string | null; - - yield await Promise.resolve(completedChunks); - } -} - -async function* sendMessage({ - message, - chatSessionId, - parentMessageId, -}: { - message: string; - chatSessionId?: number; - parentMessageId?: number; -}) { - if (!chatSessionId || !parentMessageId) { - // Create a new chat session if one doesn't exist - const createSessionResponse = await fetch( - `${API_URL}/chat/create-chat-session`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - body: JSON.stringify({ - // or specify an assistant you have defined - persona_id: 0, - }), - }, - ); - - if (!createSessionResponse.ok) { - const errorJson = await createSessionResponse.json(); - const errorMsg = errorJson.message || errorJson.detail || ""; - throw Error(`Failed to create chat session - ${errorMsg}`); - } - - const sessionData = await createSessionResponse.json(); - chatSessionId = sessionData.chat_session_id; - } - - const sendMessageResponse = await fetch(`${API_URL}/chat/send-message`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - body: JSON.stringify({ - chat_session_id: chatSessionId, - parent_message_id: parentMessageId, - message: message, - prompt_id: null, - search_doc_ids: null, - file_descriptors: [], - // checkout https://github.com/danswer-ai/danswer/blob/main/backend/danswer/search/models.py#L105 for - // all available options - retrieval_options: { - run_search: "always", - filters: null, - }, - query_override: null, - }), - }); - if (!sendMessageResponse.ok) { - const errorJson = await sendMessageResponse.json(); - const errorMsg = errorJson.message || errorJson.detail || ""; - throw Error(`Failed to send message - ${errorMsg}`); - } - - yield* handleStream(sendMessageResponse); -} - -export const ChatWidget = () => { - const [messages, setMessages] = useState<{ text: string; isUser: boolean }[]>( - [], - ); - const [inputText, setInputText] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (inputText.trim()) { - const initialPrevMessages = messages; - setMessages([...initialPrevMessages, { text: inputText, isUser: true }]); - setInputText(""); - setIsLoading(true); - - try { - const messageGenerator = sendMessage({ - message: inputText, - chatSessionId: undefined, - parentMessageId: undefined, - }); - let fullResponse = ""; - - for await (const chunks of messageGenerator) { - for (const chunk of chunks) { - if ("answer_piece" in chunk) { - fullResponse += chunk.answer_piece; - setMessages([ - ...initialPrevMessages, - { text: inputText, isUser: true }, - { text: fullResponse, isUser: false }, - ]); - } - } - } - } catch (error) { - console.error("Error sending message:", error); - setMessages((prevMessages) => [ - ...prevMessages, - { text: "An error occurred. Please try again.", isUser: false }, - ]); - } finally { - setIsLoading(false); - } - } - }; - - return ( -
-
- Chat Support -
-
- {messages.map((message, index) => ( -
-
- {message.text} -
-
- ))} - {isLoading && ( -
-
-
-
-
-
-
- )} -
-
-
- setInputText(e.target.value)} - placeholder="Type a message..." - className=" - w-full - p-2 - pr-10 - border - border-gray-300 - rounded-full - focus:outline-none - focus:ring-2 - focus:ring-blue-500 - focus:border-transparent - " - disabled={isLoading} - /> - -
-
-
- ); -}; diff --git a/examples/widget/tailwind.config.ts b/examples/widget/tailwind.config.ts deleted file mode 100644 index e9a0944e7b3..00000000000 --- a/examples/widget/tailwind.config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Config } from "tailwindcss"; - -const config: Config = { - content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], - theme: { - extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", - }, - }, - }, - plugins: [], -}; -export default config; diff --git a/examples/widget/tsconfig.json b/examples/widget/tsconfig.json deleted file mode 100644 index 7b285893049..00000000000 --- a/examples/widget/tsconfig.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} diff --git a/web/.dockerignore b/web/.dockerignore deleted file mode 100644 index b90a368f6ad..00000000000 --- a/web/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -.next diff --git a/web/.eslintrc.json b/web/.eslintrc.json deleted file mode 100644 index bffb357a712..00000000000 --- a/web/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index c87c9b392c0..00000000000 --- a/web/.gitignore +++ /dev/null @@ -1,36 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/web/.prettierignore b/web/.prettierignore deleted file mode 100644 index ba17c27d865..00000000000 --- a/web/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -**/.git -**/.svn -**/.hg -**/node_modules -**/.next -**/.vscode \ No newline at end of file diff --git a/web/.prettierrc.json b/web/.prettierrc.json deleted file mode 100644 index 757fd64caa9..00000000000 --- a/web/.prettierrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "trailingComma": "es5" -} diff --git a/web/Dockerfile b/web/Dockerfile deleted file mode 100644 index 4ffced0da49..00000000000 --- a/web/Dockerfile +++ /dev/null @@ -1,123 +0,0 @@ -FROM node:20-alpine AS base - -LABEL com.danswer.maintainer="founders@danswer.ai" -LABEL com.danswer.description="This image is the web/frontend container of Danswer which \ -contains code for both the Community and Enterprise editions of Danswer. If you do not \ -have a contract or agreement with DanswerAI, you are not permitted to use the Enterprise \ -Edition features outside of personal development or testing purposes. Please reach out to \ -founders@danswer.ai for more information. Please visit https://github.com/danswer-ai/danswer" - -# Default DANSWER_VERSION, typically overriden during builds by GitHub Actions. -ARG DANSWER_VERSION=0.3-dev -ENV DANSWER_VERSION=${DANSWER_VERSION} -RUN echo "DANSWER_VERSION: ${DANSWER_VERSION}" - -# Step 1. Install dependencies + rebuild the source code only when needed -FROM base AS builder -# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat -WORKDIR /app - -# pull in source code / package.json / package-lock.json -COPY . . - -# Install dependencies -RUN npm ci - -# needed to get the `standalone` dir we expect later -ENV NEXT_PRIVATE_STANDALONE true - -# Disable automatic telemetry collection -ENV NEXT_TELEMETRY_DISABLED 1 - -# Environment variables must be present at build time -# https://github.com/vercel/next.js/discussions/14030 -# NOTE: if you add something here, make sure to add it to the runner as well -ARG NEXT_PUBLIC_DISABLE_STREAMING -ENV NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING} - -ARG NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA -ENV NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA} - -# allow user to specify custom feedback options -ARG NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS -ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS} - -ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS -ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS} - -ARG NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN -ENV NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN} - -ARG NEXT_PUBLIC_THEME -ENV NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME} - -ARG NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED -ENV NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED=${NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED} - -ARG NEXT_PUBLIC_DISABLE_LOGOUT -ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT} - -RUN npx next build - -# Step 2. Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -# Remove global node modules, since they are not needed by the actual app -# (all dependencies are copied over into the `/app` dir itself). These -# global modules may be outdated and trigger security scans. -RUN rm -rf /usr/local/lib/node_modules - -# Not needed, set by compose -# ENV NODE_ENV production - -# Disable automatic telemetry collection -ENV NEXT_TELEMETRY_DISABLED 1 - -# Don't run production as root -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs -USER nextjs - -# Add back in if we add anything to `public` -COPY --from=builder /app/public ./public - -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -# Environment variables must be redefined at run time -# NOTE: if you add something here, make sure to add it to the builder as well -ARG NEXT_PUBLIC_DISABLE_STREAMING -ENV NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING} - -ARG NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA -ENV NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA} - -# allow user to specify custom feedback options -ARG NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS -ENV NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS} - -ARG NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS -ENV NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS} - -ARG NEXT_PUBLIC_THEME -ENV NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME} - -ARG NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED -ENV NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED=${NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED} - -ARG NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN -ENV NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN} - -ARG NEXT_PUBLIC_DISABLE_LOGOUT -ENV NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT} - -# Note: Don't expose ports here, Compose will handle that for us if necessary. -# If you want to run this without compose, specify the ports to -# expose via cli - -CMD ["node", "server.js"] - diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 05e94698626..00000000000 --- a/web/README.md +++ /dev/null @@ -1,23 +0,0 @@ - - -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -Install node / npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm -Install all dependencies: `npm i` - -Then, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -_Note:_ if you are having problems accessing the ^, try setting the `WEB_DOMAIN` env variable to -`http://127.0.0.1:3000` and accessing it there. diff --git a/web/next.config.js b/web/next.config.js deleted file mode 100644 index 1586af8d178..00000000000 --- a/web/next.config.js +++ /dev/null @@ -1,57 +0,0 @@ -// Get Danswer Web Version -const { version: package_version } = require("./package.json"); // version from package.json -const env_version = process.env.DANSWER_VERSION; // version from env variable -// Use env version if set & valid, otherwise default to package version -const version = env_version || package_version; - -/** @type {import('next').NextConfig} */ -const nextConfig = { - output: "standalone", - swcMinify: true, - rewrites: async () => { - // In production, something else (nginx in the one box setup) should take - // care of this rewrite. TODO (chris): better support setups where - // web_server and api_server are on different machines. - if (process.env.NODE_ENV === "production") return []; - - return [ - { - source: "/api/:path*", - destination: "http://127.0.0.1:8080/:path*", // Proxy to Backend - }, - ]; - }, - redirects: async () => { - // In production, something else (nginx in the one box setup) should take - // care of this redirect. TODO (chris): better support setups where - // web_server and api_server are on different machines. - const defaultRedirects = []; - - if (process.env.NODE_ENV === "production") return defaultRedirects; - - return defaultRedirects.concat([ - { - source: "/api/chat/send-message:params*", - destination: "http://127.0.0.1:8080/chat/send-message:params*", // Proxy to Backend - permanent: true, - }, - { - source: "/api/query/stream-answer-with-quote:params*", - destination: - "http://127.0.0.1:8080/query/stream-answer-with-quote:params*", // Proxy to Backend - permanent: true, - }, - { - source: "/api/query/stream-query-validation:params*", - destination: - "http://127.0.0.1:8080/query/stream-query-validation:params*", // Proxy to Backend - permanent: true, - }, - ]); - }, - publicRuntimeConfig: { - version, - }, -}; - -module.exports = nextConfig; diff --git a/web/package-lock.json b/web/package-lock.json deleted file mode 100644 index 48ac21d6477..00000000000 --- a/web/package-lock.json +++ /dev/null @@ -1,11677 +0,0 @@ -{ - "name": "qa", - "version": "0.2-dev", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "qa", - "version": "0.2-dev", - "dependencies": { - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^8.0.0", - "@phosphor-icons/react": "^2.0.8", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-popover": "^1.0.7", - "@radix-ui/react-tooltip": "^1.0.7", - "@tremor/react": "^3.9.2", - "@types/js-cookie": "^3.0.3", - "@types/lodash": "^4.17.0", - "@types/node": "18.15.11", - "@types/prismjs": "^1.26.4", - "@types/react": "18.0.32", - "@types/react-dom": "18.0.11", - "@types/uuid": "^9.0.8", - "autoprefixer": "^10.4.14", - "formik": "^2.2.9", - "js-cookie": "^3.0.5", - "lodash": "^4.17.21", - "mdast-util-find-and-replace": "^3.0.1", - "next": "^14.2.3", - "npm": "^10.8.0", - "postcss": "^8.4.31", - "prismjs": "^1.29.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-dropzone": "^14.2.3", - "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", - "sharp": "^0.32.6", - "swr": "^2.1.5", - "tailwindcss": "^3.3.1", - "typescript": "5.0.3", - "uuid": "^9.0.1", - "yup": "^1.1.1" - }, - "devDependencies": { - "@tailwindcss/typography": "^0.5.10", - "eslint": "^8.48.0", - "eslint-config-next": "^14.1.0", - "prettier": "2.8.8" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", - "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", - "peer": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.24.5", - "@babel/helpers": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.5", - "@babel/types": "^7.24.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.5.tgz", - "integrity": "sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA==", - "dependencies": { - "@babel/types": "^7.24.5", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "peer": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "peer": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", - "dependencies": { - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz", - "integrity": "sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A==", - "peer": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.24.3", - "@babel/helper-simple-access": "^7.24.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/helper-validator-identifier": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz", - "integrity": "sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz", - "integrity": "sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ==", - "peer": true, - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz", - "integrity": "sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q==", - "dependencies": { - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz", - "integrity": "sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.5.tgz", - "integrity": "sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q==", - "peer": true, - "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.5", - "@babel/types": "^7.24.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.5.tgz", - "integrity": "sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.5", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", - "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", - "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", - "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.5.tgz", - "integrity": "sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA==", - "dependencies": { - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/types": "^7.24.5", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.5.tgz", - "integrity": "sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ==", - "dependencies": { - "@babel/helper-string-parser": "^7.24.1", - "@babel/helper-validator-identifier": "^7.24.5", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@dnd-kit/accessibility": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", - "integrity": "sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/core": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.1.0.tgz", - "integrity": "sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==", - "dependencies": { - "@dnd-kit/accessibility": "^3.1.0", - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/modifiers": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", - "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.1.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/sortable": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", - "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==", - "dependencies": { - "@dnd-kit/utilities": "^3.2.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "@dnd-kit/core": "^6.1.0", - "react": ">=16.8.0" - } - }, - "node_modules/@dnd-kit/utilities": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", - "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", - "dependencies": { - "tslib": "^2.0.0" - }, - "peerDependencies": { - "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", - "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", - "dependencies": { - "@emotion/memoize": "^0.8.1" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.8.1", - "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", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==" - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "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", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.2.tgz", - "integrity": "sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg==", - "dependencies": { - "@floating-ui/utils": "^0.2.0" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz", - "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==", - "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" - } - }, - "node_modules/@floating-ui/react": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz", - "integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==", - "dependencies": { - "@floating-ui/react-dom": "^1.3.0", - "aria-hidden": "^1.1.3", - "tabbable": "^6.0.1" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.9.tgz", - "integrity": "sha512-q0umO0+LQK4+p6aGyvzASqKbKOJcAHJ7ycE9CuUvfx3s9zTHWmGJTPOIlM/hmSBfUfg/XfY5YhLBLR/LHwShQQ==", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/react/node_modules/@floating-ui/react-dom": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz", - "integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==", - "dependencies": { - "@floating-ui/dom": "^1.2.1" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz", - "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==" - }, - "node_modules/@headlessui/react": { - "version": "1.7.19", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", - "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", - "dependencies": { - "@tanstack/react-virtual": "^3.0.0-beta.60", - "client-only": "^0.0.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^16 || ^17 || ^18", - "react-dom": "^16 || ^17 || ^18" - } - }, - "node_modules/@headlessui/tailwindcss": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.0.tgz", - "integrity": "sha512-fpL830Fln1SykOCboExsWr3JIVeQKieLJ3XytLe/tt1A0XzqUthOftDmjcCYLW62w7mQI7wXcoPXr3tZ9QfGxw==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "tailwindcss": "^3.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", - "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz", - "integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==", - "dev": true, - "dependencies": { - "glob": "10.3.10" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", - "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", - "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", - "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", - "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", - "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", - "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", - "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", - "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", - "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@phosphor-icons/react": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.5.tgz", - "integrity": "sha512-B7vRm/w+P/+eavWZP5CB5Ul0ffK4Y7fpd/auWKuGvm+8pVgAJzbOK8O0s+DqzR+TwWkh5pHtJTuoAtaSvgCPzg==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">= 16.8", - "react-dom": ">= 16.8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@radix-ui/primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", - "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", - "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", - "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.5", - "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.4", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-portal": "1.0.4", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.1", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", - "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-escape-keydown": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", - "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", - "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", - "integrity": "sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.5", - "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.4", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-popper": "1.1.3", - "@radix-ui/react-portal": "1.0.4", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.1", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", - "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1", - "@radix-ui/react-use-rect": "1.0.1", - "@radix-ui/react-use-size": "1.0.1", - "@radix-ui/rect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", - "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", - "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.5", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-popper": "1.1.3", - "@radix-ui/react-portal": "1.0.4", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.1", - "@radix-ui/react-visually-hidden": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", - "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", - "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", - "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/rect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", - "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", - "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", - "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", - "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", - "dev": true - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, - "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", - "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz", - "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==", - "dev": true, - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.5.0.tgz", - "integrity": "sha512-rtvo7KwuIvqK9zb0VZ5IL7fiJAEnG+0EiFZz8FUOs+2mhGqdGmjKIaT1XU7Zq0eFqL0jonLlhbayJI/J2SA/Bw==", - "dependencies": { - "@tanstack/virtual-core": "3.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.5.0.tgz", - "integrity": "sha512-KnPRCkQTyqhanNC0K63GBG3wA8I+D1fQuVnAvcBF8f13akOKeQp1gSbu6f77zCxhEk727iV5oQnbHLYzHrECLg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tremor/react": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.16.3.tgz", - "integrity": "sha512-XiufPz4RRdrHrhwL7Cfcd9XoUEPyN/Q4jwj3kw1OQmFD1sYMCS2pAzzSP62k7zq02Z0QIPBuVK5p7/KQ+h4esQ==", - "dependencies": { - "@floating-ui/react": "^0.19.2", - "@headlessui/react": "^1.7.19", - "@headlessui/tailwindcss": "^0.2.0", - "date-fns": "^3.6.0", - "react-day-picker": "^8.10.1", - "react-transition-state": "^2.1.1", - "recharts": "^2.12.7", - "tailwind-merge": "^1.14.0" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" - }, - "node_modules/@types/estree-jsx": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", - "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", - "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, - "node_modules/@types/js-cookie": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", - "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true - }, - "node_modules/@types/lodash": { - "version": "4.17.4", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.4.tgz", - "integrity": "sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==" - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "18.15.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", - "integrity": "sha512-rlAnzkW2sZOjbqZ743IHUhFcvzaGbqijwOu8QZnZCjfQzBqFE3s4lOTJEsxikImav9uzz/42I+O7YUs1mWgMlg==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" - }, - "node_modules/@types/react": { - "version": "18.0.32", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.32.tgz", - "integrity": "sha512-gYGXdtPQ9Cj0w2Fwqg5/ak6BcK3Z15YgjSqtyDizWUfx7mQ8drs0NBUzRRsAdoFVTO8kJ8L2TL8Skm7OFPnLUw==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.0.11", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.11.tgz", - "integrity": "sha512-O38bPbI2CWtgw/OoQoY+BRelw7uysmXbWvw3nLWO21H1HSh+GOlqPuXshJfjmpNlKiiSDG9cc1JZAaMmVdcTlw==", - "dependencies": { - "@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", - "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==" - }, - "node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", - "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "7.2.0", - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/typescript-estree": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", - "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", - "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", - "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "@typescript-eslint/visitor-keys": "7.2.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", - "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "7.2.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.toreversed": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", - "integrity": "sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz", - "integrity": "sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.1.0", - "es-shim-unscopables": "^1.0.2" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true - }, - "node_modules/attr-accept": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", - "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.19", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", - "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.0", - "caniuse-lite": "^1.0.30001599", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/b4a": { - "version": "1.6.6", - "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", - "integrity": "sha512-Xgp9g+A/cG47sUyRwwYxGM4bR/jDRg5N6it/8+HxCnbT5XNKSKDT9xm4oag/osgqjC2It/vH0yXsomOG6k558g==", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-module-imports": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.22.5", - "lodash": "^4.17.21", - "picomatch": "^2.3.1" - }, - "peerDependencies": { - "styled-components": ">= 2" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bare-events": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", - "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", - "optional": true - }, - "node_modules/bare-fs": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.0.tgz", - "integrity": "sha512-TNFqa1B4N99pds2a5NYHR15o0ZpdNKbAeKTE/+G6ED/UeOavv8RY3dr/Fu99HW3zU3pXpo2kDNO8Sjsm2esfOw==", - "optional": true, - "dependencies": { - "bare-events": "^2.0.0", - "bare-path": "^2.0.0", - "bare-stream": "^1.0.0" - } - }, - "node_modules/bare-os": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.3.0.tgz", - "integrity": "sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==", - "optional": true - }, - "node_modules/bare-path": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.2.tgz", - "integrity": "sha512-o7KSt4prEphWUHa3QUwCxUI00R86VdjiuxmJK0iNVDHYPGo+HsDaVCnqCmPbf/MiW1ok8F4p3m8RTHlWk8K2ig==", - "optional": true, - "dependencies": { - "bare-os": "^2.1.0" - } - }, - "node_modules/bare-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-1.0.0.tgz", - "integrity": "sha512-KhNUoDL40iP4gFaLSsoGE479t0jHijfYdIcxRn/XtezA2BaUD0NRf/JGRpsMq6dMNM+SrCrB0YSSo/5wBY4rOQ==", - "optional": true, - "dependencies": { - "streamx": "^2.16.1" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001620", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz", - "integrity": "sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-reference-invalid": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", - "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "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", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "engines": { - "node": ">=4" - } - }, - "node_modules/css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true - }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" - }, - "node_modules/electron-to-chromium": { - "version": "1.4.773", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.773.tgz", - "integrity": "sha512-87eHF+h3PlCRwbxVEAw9KtK3v7lWfc/sUDr0W76955AdYTG4bV/k0zrl585Qnj/skRMH2qOSiE+kqMeOQ+LOpw==" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.1.tgz", - "integrity": "sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "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", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", - "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "iterator.prototype": "^1.1.2", - "safe-array-concat": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.3.tgz", - "integrity": "sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==", - "dev": true, - "dependencies": { - "@next/eslint-plugin-next": "14.2.3", - "@rushstack/eslint-patch": "^1.3.3", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", - "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "enhanced-resolve": "^5.12.0", - "eslint-module-utils": "^2.7.4", - "fast-glob": "^3.3.1", - "get-tsconfig": "^4.5.0", - "is-core-module": "^2.11.0", - "is-glob": "^4.0.3" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", - "dev": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", - "semver": "^6.3.1", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", - "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.23.2", - "aria-query": "^5.3.0", - "array-includes": "^3.1.7", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "=4.7.0", - "axobject-query": "^3.2.1", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.15", - "hasown": "^2.0.0", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.34.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz", - "integrity": "sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlast": "^1.2.4", - "array.prototype.flatmap": "^1.3.2", - "array.prototype.toreversed": "^1.1.2", - "array.prototype.tosorted": "^1.1.3", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.17", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7", - "object.hasown": "^1.1.3", - "object.values": "^1.1.7", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.10" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-util-is-identifier-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", - "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-equals": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", - "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/file-selector": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", - "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", - "dependencies": { - "tslib": "^2.4.0" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "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", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dev": true, - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/formik": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", - "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.1", - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-tsconfig": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", - "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", - "dev": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-from-html": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.1.tgz", - "integrity": "sha512-RXQBLMl9kjKVNkJTIO6bZyb2n+cUH8LFaSSzo82jiLT6Tfc+Pt7VQCS+/h3YwG4jaNE2TA2sdJisGWR+aJrp0g==", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.1.0", - "hast-util-from-parse5": "^8.0.0", - "parse5": "^7.0.0", - "vfile": "^6.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz", - "integrity": "sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "devlop": "^1.0.0", - "hastscript": "^8.0.0", - "property-information": "^6.0.0", - "vfile": "^6.0.0", - "vfile-location": "^5.0.0", - "web-namespaces": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/hast-util-parse-selector": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", - "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-from-parse5/node_modules/hastscript": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-8.0.0.tgz", - "integrity": "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==", - "dependencies": { - "@types/hast": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^4.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz", - "integrity": "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==", - "dependencies": { - "@types/hast": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-parse-selector/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/hast-util-parse-selector/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/hast-util-to-jsx-runtime": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", - "integrity": "sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==", - "dependencies": { - "@types/estree": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "devlop": "^1.0.0", - "estree-util-is-identifier-name": "^3.0.0", - "hast-util-whitespace": "^3.0.0", - "mdast-util-mdx-expression": "^2.0.0", - "mdast-util-mdx-jsx": "^3.0.0", - "mdast-util-mdxjs-esm": "^2.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^1.0.0", - "unist-util-position": "^5.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz", - "integrity": "sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-7.2.0.tgz", - "integrity": "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==", - "dependencies": { - "@types/hast": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-parse-selector": "^3.0.0", - "property-information": "^6.0.0", - "space-separated-tokens": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hastscript/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/hastscript/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/html-url-attributes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.0.tgz", - "integrity": "sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/inline-style-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", - "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "engines": { - "node": ">=12" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-alphabetical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", - "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-alphanumerical": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", - "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", - "dependencies": { - "is-alphabetical": "^2.0.0", - "is-decimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, - "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dev": true, - "dependencies": { - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-decimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", - "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", - "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-hexadecimal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", - "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dev": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dev": true, - "dependencies": { - "which-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/iterator.prototype": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", - "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "reflect.getprototypeof": "^1.0.4", - "set-function-name": "^2.0.1" - } - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "engines": { - "node": ">=14" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "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", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "peer": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/markdown-table": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", - "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", - "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", - "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", - "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-expression": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.0.tgz", - "integrity": "sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdx-jsx": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.2.tgz", - "integrity": "sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "parse-entities": "^4.0.0", - "stringify-entities": "^4.0.0", - "unist-util-remove-position": "^5.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-mdxjs-esm": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", - "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", - "dependencies": { - "@types/estree-jsx": "^1.0.0", - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-hast": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz", - "integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "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", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz", - "integrity": "sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.0.0.tgz", - "integrity": "sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.0.1.tgz", - "integrity": "sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz", - "integrity": "sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", - "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/next": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", - "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", - "dependencies": { - "@next/env": "14.2.3", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", - "postcss": "8.4.31", - "styled-jsx": "5.1.1" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=18.17.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.3", - "@next/swc-darwin-x64": "14.2.3", - "@next/swc-linux-arm64-gnu": "14.2.3", - "@next/swc-linux-arm64-musl": "14.2.3", - "@next/swc-linux-x64-gnu": "14.2.3", - "@next/swc-linux-x64-musl": "14.2.3", - "@next/swc-win32-arm64-msvc": "14.2.3", - "@next/swc-win32-ia32-msvc": "14.2.3", - "@next/swc-win32-x64-msvc": "14.2.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/node-abi": { - "version": "3.62.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.62.0.tgz", - "integrity": "sha512-CPMcGa+y33xuL1E0TcNIu4YyaZCxnnvkVaEXrsosR3FxN+fV8xvb7Mzpb7IgKler10qeMkE6+Dp8qJhpzdq35g==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.8.0.tgz", - "integrity": "sha512-wh93uRczgp7HDnPMiLXcCkv2hagdJS0zJ9KT/31d0FoXP02+qgN2AOwpaW85fxRWkinl2rELfPw+CjBXW48/jQ==", - "bundleDependencies": [ - "@isaacs/string-locale-compare", - "@npmcli/arborist", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmhook", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "normalize-package-data", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which", - "write-file-atomic" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^7.5.2", - "@npmcli/config": "^8.3.2", - "@npmcli/fs": "^3.1.1", - "@npmcli/map-workspaces": "^3.0.6", - "@npmcli/package-json": "^5.1.0", - "@npmcli/promise-spawn": "^7.0.2", - "@npmcli/redact": "^2.0.0", - "@npmcli/run-script": "^8.1.0", - "@sigstore/tuf": "^2.3.3", - "abbrev": "^2.0.0", - "archy": "~1.0.0", - "cacache": "^18.0.3", - "chalk": "^5.3.0", - "ci-info": "^4.0.0", - "cli-columns": "^4.0.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^10.3.15", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^7.0.2", - "ini": "^4.1.2", - "init-package-json": "^6.0.3", - "is-cidr": "^5.0.5", - "json-parse-even-better-errors": "^3.0.2", - "libnpmaccess": "^8.0.6", - "libnpmdiff": "^6.1.2", - "libnpmexec": "^8.1.1", - "libnpmfund": "^5.0.10", - "libnpmhook": "^10.0.5", - "libnpmorg": "^6.0.6", - "libnpmpack": "^7.0.2", - "libnpmpublish": "^9.0.8", - "libnpmsearch": "^7.0.5", - "libnpmteam": "^6.0.5", - "libnpmversion": "^6.0.2", - "make-fetch-happen": "^13.0.1", - "minimatch": "^9.0.4", - "minipass": "^7.1.1", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^10.1.0", - "nopt": "^7.2.1", - "normalize-package-data": "^6.0.1", - "npm-audit-report": "^5.0.0", - "npm-install-checks": "^6.3.0", - "npm-package-arg": "^11.0.2", - "npm-pick-manifest": "^9.0.1", - "npm-profile": "^10.0.0", - "npm-registry-fetch": "^17.0.1", - "npm-user-validate": "^2.0.1", - "p-map": "^4.0.0", - "pacote": "^18.0.6", - "parse-conflict-json": "^3.0.1", - "proc-log": "^4.2.0", - "qrcode-terminal": "^0.12.0", - "read": "^3.0.1", - "semver": "^7.6.2", - "spdx-expression-parse": "^4.0.0", - "ssri": "^10.0.6", - "supports-color": "^9.4.0", - "tar": "^6.2.1", - "text-table": "~0.2.0", - "tiny-relative-date": "^1.3.0", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^5.0.1", - "which": "^4.0.0", - "write-file-atomic": "^5.0.1" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "2.2.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "7.5.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^3.1.1", - "@npmcli/installed-package-contents": "^2.1.0", - "@npmcli/map-workspaces": "^3.0.2", - "@npmcli/metavuln-calculator": "^7.1.1", - "@npmcli/name-from-folder": "^2.0.0", - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.1.0", - "@npmcli/query": "^3.1.0", - "@npmcli/redact": "^2.0.0", - "@npmcli/run-script": "^8.1.0", - "bin-links": "^4.0.4", - "cacache": "^18.0.3", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^7.0.2", - "json-parse-even-better-errors": "^3.0.2", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^10.2.2", - "minimatch": "^9.0.4", - "nopt": "^7.2.1", - "npm-install-checks": "^6.2.0", - "npm-package-arg": "^11.0.2", - "npm-pick-manifest": "^9.0.1", - "npm-registry-fetch": "^17.0.1", - "pacote": "^18.0.6", - "parse-conflict-json": "^3.0.0", - "proc-log": "^4.2.0", - "proggy": "^2.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.7", - "ssri": "^10.0.6", - "treeverse": "^3.0.0", - "walk-up-path": "^3.0.1" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "8.3.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^3.0.2", - "ci-info": "^4.0.0", - "ini": "^4.1.2", - "nopt": "^7.2.1", - "proc-log": "^4.2.0", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.5", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "5.0.7", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", - "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^4.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "3.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^2.0.0", - "glob": "^10.2.2", - "minimatch": "^9.0.0", - "read-package-json-fast": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "7.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^18.0.0", - "json-parse-even-better-errors": "^3.0.0", - "pacote": "^18.0.0", - "proc-log": "^4.1.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "5.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "glob": "^10.2.2", - "hosted-git-info": "^7.0.0", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "proc-log": "^4.0.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "7.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "3.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "8.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/package-json": "^5.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "node-gyp": "^10.0.0", - "proc-log": "^4.0.0", - "which": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "2.3.1", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/core": { - "version": "1.1.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.3.2", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "2.3.1", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.0", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.1", - "make-fetch-happen": "^13.0.1", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "2.3.3", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.3.0", - "tuf-js": "^2.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@sigstore/verify": { - "version": "1.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.1", - "@sigstore/core": "^1.1.0", - "@sigstore/protobuf-specs": "^0.3.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/aggregate-error": { - "version": "3.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ansi-styles": { - "version": "6.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "4.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "read-cmd-shim": "^4.0.0", - "write-file-atomic": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "2.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "18.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.0.0", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "4.0.5", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "^5.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/clean-stack": { - "version": "2.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "6.0.3", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/npm/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cross-spawn": { - "version": "7.0.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.3.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/diff": { - "version": "5.2.0", - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/eastasianwidth": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.1", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/foreground-child": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/function-bind": { - "version": "1.1.2", - "inBundle": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "10.3.15", - "inBundle": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.11.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hasown": { - "version": "2.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "7.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^10.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.1.1", - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.4", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "6.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/indent-string": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/ini": { - "version": "4.1.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "6.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^5.0.0", - "npm-package-arg": "^11.0.0", - "promzard": "^1.0.0", - "read": "^3.0.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "9.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "5.0.5", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "^4.0.4" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm/node_modules/is-core-module": { - "version": "2.13.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/is-lambda": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/isexe": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/jackspeak": { - "version": "2.3.6", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/npm/node_modules/jsbn": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "3.0.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "8.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^11.0.2", - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "6.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^7.5.2", - "@npmcli/installed-package-contents": "^2.1.0", - "binary-extensions": "^2.3.0", - "diff": "^5.1.0", - "minimatch": "^9.0.4", - "npm-package-arg": "^11.0.2", - "pacote": "^18.0.6", - "tar": "^6.2.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "8.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^7.5.2", - "@npmcli/run-script": "^8.1.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^11.0.2", - "pacote": "^18.0.6", - "proc-log": "^4.2.0", - "read": "^3.0.1", - "read-package-json-fast": "^3.0.2", - "semver": "^7.3.7", - "walk-up-path": "^3.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "5.0.10", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^7.5.2" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmhook": { - "version": "10.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "6.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "7.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^7.5.2", - "@npmcli/run-script": "^8.1.0", - "npm-package-arg": "^11.0.2", - "pacote": "^18.0.6" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "9.0.8", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ci-info": "^4.0.0", - "normalize-package-data": "^6.0.1", - "npm-package-arg": "^11.0.2", - "npm-registry-fetch": "^17.0.1", - "proc-log": "^4.2.0", - "semver": "^7.3.7", - "sigstore": "^2.2.0", - "ssri": "^10.0.6" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "7.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "6.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^17.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "6.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.7", - "@npmcli/run-script": "^8.1.0", - "json-parse-even-better-errors": "^3.0.2", - "proc-log": "^4.2.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "10.2.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "13.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", - "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "proc-log": "^4.2.0", - "promise-retry": "^2.0.1", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "9.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "3.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-json-stream": { - "version": "1.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "node_modules/npm/node_modules/minipass-json-stream/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/mkdirp": { - "version": "1.0.4", - "inBundle": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "1.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/negotiator": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "10.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^3.0.0", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^4.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/node-gyp/node_modules/proc-log": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "7.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/normalize-package-data": { - "version": "6.0.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^7.0.0", - "is-core-module": "^2.8.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "6.3.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "11.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^4.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^6.0.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "10.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^17.0.1", - "proc-log": "^4.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "17.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^2.0.0", - "make-fetch-happen": "^13.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^11.0.0", - "proc-log": "^4.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "2.0.1", - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/pacote": { - "version": "18.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^5.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/package-json": "^5.1.0", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^8.0.0", - "cacache": "^18.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^11.0.0", - "npm-packlist": "^8.0.0", - "npm-pick-manifest": "^9.0.0", - "npm-registry-fetch": "^17.0.0", - "proc-log": "^4.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^2.2.0", - "ssri": "^10.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/path-key": { - "version": "3.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "1.11.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.0.16", - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "4.2.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/proggy": { - "version": "2.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-inflight": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "1.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^3.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^1.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/read-package-json-fast": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.6.2", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/shebang-command": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/shebang-regex": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "2.3.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^2.3.1", - "@sigstore/core": "^1.0.0", - "@sigstore/protobuf-specs": "^0.3.1", - "@sigstore/sign": "^2.3.0", - "@sigstore/tuf": "^2.3.1", - "@sigstore/verify": "^1.2.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.1", - "debug": "^4.3.4", - "socks": "^2.7.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.17", - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/sprintf-js": { - "version": "1.1.3", - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/npm/node_modules/ssri": { - "version": "10.0.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "9.4.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "6.2.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "2.0.1", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.1" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/which": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/which/node_modules/isexe": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/wrap-ansi": { - "version": "8.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.hasown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.4.tgz", - "integrity": "sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "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==", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-entities": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", - "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", - "dependencies": { - "@types/unist": "^2.0.0", - "character-entities": "^2.0.0", - "character-entities-legacy": "^3.0.0", - "character-reference-invalid": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "is-alphanumerical": "^2.0.0", - "is-decimal": "^2.0.0", - "is-hexadecimal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/parse-entities/node_modules/@types/unist": { - "version": "2.0.10", - "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", - "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" - }, - "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", - "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" - }, - "node_modules/prebuild-install": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", - "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/property-expr": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", - "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" - }, - "node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-day-picker": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", - "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/gpbl" - }, - "peerDependencies": { - "date-fns": "^2.28.0 || ^3.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-dropzone": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", - "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", - "dependencies": { - "attr-accept": "^2.2.2", - "file-selector": "^0.6.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "react": ">= 16.8 || 18.0.0" - } - }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" - }, - "node_modules/react-icons": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", - "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", - "peerDependencies": { - "react": "*" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-loader-spinner": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-5.4.5.tgz", - "integrity": "sha512-32f+sb/v2tnNfyvnCCOS4fpyVHsGXjSyNo6QLniHcaj1XjKLxx14L2z0h6szRugOL8IEJ+53GPwNAdbkDqmy4g==", - "dependencies": { - "react-is": "^18.2.0", - "styled-components": "^5.3.5", - "styled-tools": "^1.7.2" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-loader-spinner/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" - }, - "node_modules/react-markdown": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", - "integrity": "sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.0.0", - "hast-util-to-jsx-runtime": "^2.0.0", - "html-url-attributes": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "remark-parse": "^11.0.0", - "remark-rehype": "^11.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=18", - "react": ">=18" - } - }, - "node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "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", - "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "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-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/react-transition-state": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.1.1.tgz", - "integrity": "sha512-kQx5g1FVu9knoz1T1WkapjUgFz08qQ/g1OmuWGi3/AoEFfS0kStxrPlZx81urjCXdz2d+1DqLpU6TyLW/Ro04Q==", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/recharts": { - "version": "2.12.7", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.12.7.tgz", - "integrity": "sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^16.10.2", - "react-smooth": "^4.0.0", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", - "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.1", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "which-builtin-type": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/refractor": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.8.1.tgz", - "integrity": "sha512-/fk5sI0iTgFYlmVGYVew90AoYnNMP6pooClx/XKqyeeCQXrL0Kvgn8V0VEht5ccdljbzzF1i3Q213gcntkRExg==", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/prismjs": "^1.0.0", - "hastscript": "^7.0.0", - "parse-entities": "^4.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/refractor/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/refractor/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rehype-parse": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.0.tgz", - "integrity": "sha512-WG7nfvmWWkCR++KEkZevZb/uw41E8TsH4DsY9UxsTbIXCVGbAs4S+r8FrQ+OtH5EEQAs+5UxKC42VinkmpA1Yw==", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-from-html": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-prism-plus": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/rehype-prism-plus/-/rehype-prism-plus-2.0.0.tgz", - "integrity": "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ==", - "dependencies": { - "hast-util-to-string": "^3.0.0", - "parse-numeric-range": "^1.3.0", - "refractor": "^4.8.0", - "rehype-parse": "^9.0.0", - "unist-util-filter": "^5.0.0", - "unist-util-visit": "^5.0.0" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", - "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-rehype": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.0.tgz", - "integrity": "sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "mdast-util-to-hast": "^13.0.0", - "unified": "^11.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-regex": "^1.1.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" - }, - "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "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", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/streamx": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", - "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", - "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "regexp.prototype.flags": "^1.5.2", - "set-function-name": "^2.0.2", - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-to-object": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", - "integrity": "sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==", - "dependencies": { - "inline-style-parser": "0.2.3" - } - }, - "node_modules/styled-components": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.11.tgz", - "integrity": "sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==", - "dependencies": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/traverse": "^7.4.5", - "@emotion/is-prop-valid": "^1.1.0", - "@emotion/stylis": "^0.8.4", - "@emotion/unitless": "^0.7.4", - "babel-plugin-styled-components": ">= 1.12.0", - "css-to-react-native": "^3.0.0", - "hoist-non-react-statics": "^3.0.0", - "shallowequal": "^1.1.0", - "supports-color": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-is": ">= 16.8.0" - } - }, - "node_modules/styled-components/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/styled-components/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/styled-tools": { - "version": "1.7.2", - "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", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/swr": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", - "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", - "dependencies": { - "client-only": "^0.0.1", - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" - }, - "node_modules/tailwind-merge": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", - "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", - "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", - "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-fs": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", - "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^2.1.1", - "bare-path": "^2.1.0" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-case": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", - "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.3.tgz", - "integrity": "sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=12.20" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unified": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", - "integrity": "sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-filter": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/unist-util-filter/-/unist-util-filter-5.0.1.tgz", - "integrity": "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-remove-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", - "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "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", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sync-external-store": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", - "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-location": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.2.tgz", - "integrity": "sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg==", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dev": true, - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", - "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", - "dev": true, - "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", - "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", - "is-finalizationregistry": "^1.0.2", - "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "peer": true - }, - "node_modules/yaml": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", - "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yup": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", - "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", - "dependencies": { - "property-expr": "^2.0.5", - "tiny-case": "^1.0.3", - "toposort": "^2.0.2", - "type-fest": "^2.19.0" - } - }, - "node_modules/yup/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 190ec9b1aa6..00000000000 --- a/web/package.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "qa", - "version": "0.2-dev", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, - "dependencies": { - "@dnd-kit/core": "^6.1.0", - "@dnd-kit/modifiers": "^7.0.0", - "@dnd-kit/sortable": "^8.0.0", - "@phosphor-icons/react": "^2.0.8", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-popover": "^1.0.7", - "@radix-ui/react-tooltip": "^1.0.7", - "@tremor/react": "^3.9.2", - "@types/js-cookie": "^3.0.3", - "@types/lodash": "^4.17.0", - "@types/node": "18.15.11", - "@types/prismjs": "^1.26.4", - "@types/react": "18.0.32", - "@types/react-dom": "18.0.11", - "@types/uuid": "^9.0.8", - "autoprefixer": "^10.4.14", - "formik": "^2.2.9", - "js-cookie": "^3.0.5", - "lodash": "^4.17.21", - "mdast-util-find-and-replace": "^3.0.1", - "next": "^14.2.3", - "npm": "^10.8.0", - "postcss": "^8.4.31", - "prismjs": "^1.29.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-dropzone": "^14.2.3", - "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", - "sharp": "^0.32.6", - "swr": "^2.1.5", - "tailwindcss": "^3.3.1", - "typescript": "5.0.3", - "uuid": "^9.0.1", - "yup": "^1.1.1" - }, - "devDependencies": { - "@tailwindcss/typography": "^0.5.10", - "eslint": "^8.48.0", - "eslint-config-next": "^14.1.0", - "prettier": "2.8.8" - } -} diff --git a/web/postcss.config.js b/web/postcss.config.js deleted file mode 100644 index 12a703d900d..00000000000 --- a/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/web/public/Amazon.webp b/web/public/Amazon.webp deleted file mode 100644 index cd8574fa59e4058e64f441034b00b31e52ab863b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9580 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 zaW88N061 - - Anthropic - - - - - diff --git a/web/public/Axero.jpeg b/web/public/Axero.jpeg deleted file mode 100644 index f6df99217274165fefcd215709b76c2663661df8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7977 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 p_1Vz_K*OQ?$wj-EIAPqI8^JlJ}0f9j0AKaJM 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

    7n&zq5_^7_mjERcC+3vQ1}SM3aQ>*pZzE;I!qw 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 ZG0OMS4XT5B&=ddPd$KArId>mD|9>Y \ No newline at end of file diff --git a/web/public/Cohere.svg b/web/public/Cohere.svg deleted file mode 100644 index 42d4eb1845a..00000000000 --- a/web/public/Cohere.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/public/Confluence.svg b/web/public/Confluence.svg deleted file mode 100644 index 3a52be355e3..00000000000 --- a/web/public/Confluence.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/web/public/Discourse.png b/web/public/Discourse.png deleted file mode 100644 index 48e4046b8029b34a5e1d0d24821c9b81e52dc350..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42742 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 OV0K52A8t70m-=6wdq5!o diff --git a/web/public/Dropbox.png b/web/public/Dropbox.png deleted file mode 100644 index cd83e09eb6622919b416e635d611c43893a07a4f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43377 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 M`iJ^e`Nkaj7g{K;T>t<8 diff --git a/web/public/GoogleDrive.png b/web/public/GoogleDrive.png deleted file mode 100644 index f562c2390521e64f31cd3d28da7f3483d44e9184..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 264831 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%0DS;h2R692br}%&U=WP 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�`|v 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 diff --git a/web/public/GoogleSites.png b/web/public/GoogleSites.png deleted file mode 100644 index a01ab45deef92a45e2efedc9740a350e56c49036..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5539 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 PD`IQ2%KFw~+P>cal(qWD diff --git a/web/public/Guru.svg b/web/public/Guru.svg deleted file mode 100644 index b72aeb5b6c9..00000000000 --- a/web/public/Guru.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/public/HubSpot.png b/web/public/HubSpot.png deleted file mode 100644 index e4112aecd0dfcf52688c44348c984fecf3aa2eb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14062 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 H*uMOKXV4>H diff --git a/web/public/Jira.svg b/web/public/Jira.svg deleted file mode 100644 index 09956020aef..00000000000 --- a/web/public/Jira.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/web/public/Linear.png b/web/public/Linear.png deleted file mode 100644 index 98e05b02d9fb165c70eade60770298aad5f61c7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 466996 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
  1. $>V+Pr0nNHq z%CbpMf#7ttx2YqkI)-e9Rg~0&G8CAh3`jQD_gJe`EUYzOGvYLUL7;$_i_m<}kT%or zHo0BEtcBZM1rF-%K4#kFgXXU;m#>&0yhy|GLl5+}Bw`-4;wg$L`<3shSXRV@rGXPd z_xA^VNOZY13LD=HKi`zig0E3{8prYFZ)5_YZc2a1S30jz|MTZRmqg2vhp8+W}Q){ z95{@}Y`11sm9jn~pTnLzeN3}pu!nt6#Oo8~lEtOYnWu*idACF|G1!jR)&0(`hDJ1F%HYp-Sg)H+PD)OP9I7F7%#+pxW?qH_ z!xqeO$nCYfbbS_!L?%JkimZHR+?(?|F-&rNt1!Y#2pXyhmll=EsD}@++DP6@i$&_1 z268{29HE?lRHm|JXgNL7WC}}98f5H5`LZ8R+b4#mp z(*h~VP8n*v#1&p_v3x_I`TjG}3rd3X%WEr`j}JKgW4jOM7Z+hYVh(KkPH3U_sA1k0 z8gvFtu)Xim`9eMBQMAdE!@Cx`cQGUT(eWhYAHZeswNUvP|IT7_Nk93RH)`)U6gdl9 z`h%SJsZZB^NWTbjlPEP#20S4j2mgy+W`N(F9N%+` zM(kn7&m6{rRSKp_44NyqBZ~17AOgF+!m3$#s(%(_I4I^mXg`pR3h5H}+4xWEj_m7G za8j(9TRB2EMQ=*IMYrG5cVKP2yYjv{>cOVTRb74Tu^%Q5`DBjE1xtVHKEg0zI11gH zQ>T$IWO0;N#Yj(GDO>bIb@Mbpn2Vut9nZzK7i3B)@j0 z;jJ;q;&OtDX7xF$SvjkE(0__TjBnO}o>+C#+s$v~Kb0E(e4f=meW@RC-xGeRfHco4 z?6JK2`FvxqZS^!(jF@}&k%Nm7@Im*l{PA&tY6PDHSpdl=FRh)~b@qV*Xc&!nr&kwG zRN`E}auxC9cX+&QwlOKhe>WYDP%jDi*JSVfm~HtZ$vda=GjhKwR4u~OHRDV@iO$z> zq+;SrmaLKvc?Peb7%$guG?(xZcdf?IPXJA`MEc}{JF*%ZnQ3)I3U9<}&x?qyiCY?g zZG0}iNff}(DF7p5F%Q`%90()*>nsD_ky)<0k)p8E$6LoUFq77KZ)wqhJP^4z?*wCJ$J+V#*$?QxyPX?2V|eV>I;R2>g;`ijG3{)c zamMooo=J5Y|1Cc)a5JYQ`#VuGUHwv4eEo!kQLwi5A-q??oz1pRus?7*Y(!gghBZrU za6L%4+ULeEdi)G;>acbnTAh|5Dq`Pi3C<4%+-UFvNg52jOKxrsNzYNhs!TLkA>TuS zWx7AJ-jCp?0!n4Me{&XRt>trGP7hU2rUQ^zX4H_k3}^3MzWjbP4p+ep3tmsjEy>=x z+W)xhVh6;!(CE&yDptYtH5h%7JM7Kp4rCerC$jWWScY;pdKB%oF9mYW5kR^-8e4RR zNq3%tM0^<)mEAFG#GF0jCsqqzy~vjx;>T(U;u8XhS#Hi0&E~SUjwnXzAYZ@#=q=VSLjy9xQ$G>|MWtP)0~gkt4*# zqN}^wh#?64=SvZ>WgjnuL3&P6%$9d)X&_UKY*>jOu0YeD*X>ls zTa4OO!`{>I*8N$Bz{S$H-<k)S%`t_-4Yb_vaX?^9Xgy)h6`S&#i}}?!dP5+yV7c zS2F;>aN++bvP1w8mm71yCgdN!T1NQutaWgH^$(JK9Yad2=5PxP;seHoyrDWnCc=PW zaP{raj#a6Z^es!7;WDOPWrq)aRF#vB5h3nV%|cw_)!i>0sLJ1!7w%MT(3ABb)IQN%J?mdeeWQI6vpGnRkb(dr4e(z~z~d{1%KcbHa25VmfR z5qdSo;$W>$I}rM{*KU?+h|cG zBv{)6;$AeXYBg4DrKqnYcngD%zE*Fhbdsor8-?6y{tg&9M6c%jvlFb~+sr#hssqrE zOx(S5Sit@{iz0pfWZGWKcG|oW`UlIBeKFl5YcXk}pq4v!Tp5g8Z~Gol6STNS{fpt% zHQPIQo#wlOciL0(%G9iR03cB}urpGa@cwTBK8S?U#_f0y-+zH5#EJRm+yF4$yi?w4La2B$Ie9H=<#oxNZGmaMcjkXYr8Onw(d8Jz5P z9&?Q)FzOz)i0>jlLUpIMfoVowNN1>@G9TDZ%bmhA&_j*0k?hY}^nwALTZTWvLV(_u z5}5dA^<%KS2g4g=TLJu*oAP^2}!CMP?`v&RVCLF2f;D+ko_ry;5G@0^9?+z+MT zKxop&VQ?Lt!w1K=?WKTzm~W#N^lBlg9jGMNEg<`15En(6P{W2GH!-eH>7c|45Y(!UikfxMaR(lA?9R_5uW|I?4X(CxCHYVw%fXP z-Zl?ChGv0vk_cFUr<>h)~gnu5&PVN=v2Jv#`a`)36UT{)LIlMPd9V7xP!c z_lhbROF$P5SaX+i>_%BZ|dN(8z#XhQ=|2Vw>QWi{-!VG{kBQZM|-NL8<#5~Bi zJx~t-w)!qf>?i~yv-bxNJu;zxQ$^kBPt=hvib7Kv=UfVXuVAtM1^<_zCh_8gA27JQ zkEI!(SNFtYIJw(w_-aU&>E~c$OKwt2+^y-Mf-+w>Npii&K`w*$>trv+zVs z7L=2>&r?&nNwLJTvp^k?i5_ABZ}Up_6S)Pe4QX%j@8 zW$@Z2z0SX4C}MQHOsUOor+z=ijWe^ZEwpQzOe=;ID@BqcoW7c20MAZ0=59jk8f=_i z9ep01VojfXmwX`rD9~M5Abh&}U<_=gaEu@R7<9iCo8QmljI0P~#4jOr%$+hN(eqjO zA$3Ven5_Tpz69fB232+P`%SlsY_H#GQ9@Sqwr)L9tKt`J`zfd4x0KwZ-M2hxji4yH0xfL}Q7&wL9Z-!0N^92|^ol!oSh~evDtP4M*iJ8i@}IJTH1Z_dBXJ zXD?^My7^}p88`H`5xG~oB|CeK8ocue{0116x04nNs~xf+3x3+Z>fK%|^%4vPAfv|O zF+?an>Git|a$`P>=bt5K19-HcWs`GIHjaY%2haB9o@mRNjsek~|!e}e_ z4{*kDAV>VS^VFJYKV-?UrS+;31T1(dD!fkJVdKVI1unIkkM7*O(- zghlLslMmq84@vDgjtHu`ChQWP%bcWqoCpe$$*nCOEJ8E*V=|VsIII+W5~Lq&H{sM_ z&*?<3=QRhF<^^n`V5K1MwD+B;0OgDvAYg$0HXepCca^8?z|^?9mWmQ6w;QVH3Z76O z*)p>j^6p8ZW~wG*g1zoQ1Zr#=tKd(PCVz43r>g&pCghactn(A!Q9Yw*QYyLU-=qIT zNWuA3E@1K#U){SAr&Tl4p{nmmQTUb8{r-|W=Q=`q3V=UzsFVwfANt>keAXEX5$pQM zYK*V0J;|!$vwd(7>U%xiI_TfYdqnk-vm3J#pJQ`)F49}tMo)NUO~UOq-E{tj9IiUn z!KTfilhbnNG)ZvP@oPQUKT0MCWe%54OIxq?Yd3x9Omz98>ORBPeHRN}X_6eVx;vjF zNjMS4bzPq0+u;3?*Y>kaz1hrd7=J(B&@O0G@}TJWaxL8&{eW1PblX2(6hMElwWq!4 zPUcQy_SEXdD!ml^CEGo1*vpM-v3w-SyN-S%NU3+^a1(e7ov&V1i8L?L;ju3bJfxsE zdILP$P>o2rv8r-xXX%Ew(&WlCkhamF{Ui-w*TUJul-J6_km4ca&>pclRFnHT*m+2w zME2#{>V~h8KHsxx-2c3bAM@L!BuvuSL?xo<{!4Cbi)rHnf#|QKoY+N@`*JVt#$VQ8 z8LDX1j6*K_-N4X0Y)@c5s(fLv8Lml_X=xX9=yE8VtrluygAx}*+laJkbu6v}MJ`;& zlXO8u9~W)+x_J~Q>*BZ7lw=BiW?+2yPvOr?;@r0aciYE42eUeKdr(4UG9-IvS=>QhxwFywrahfLy0+ z`vhIjvh6BLE4+A`=hQgbqIts1sjbrwMHro#)*Do5kX?LLTG-J10QfN3@e-$(p~;9J z!ufhvYn(C-O11Qzh#X#L7f>Il+)`}&XjqYa-DO2^T(0RkKl;>I>E_X+8xnTU6U{v8 zHGtP6;*8RT5^hA?<5V7)eRB0d44+?i=mBMD9gj@ZgS9WCKaEoG8*R>?A)a4SB@DH! z9fR%y8$ta4%fup|gF8|H0fZXR4W(~?FnxT!-JC{b(sAHpRZ=<)(|ruoxy)T1lM*pg zzii%JylSn>_{8HAe3qS;O83{_ib60G)fi~`x|5Qro0~XrHS|i?1Kj7YEgOR@nFwAk z`y!5N<-0)dZj~EtobHGu=ei|Nn3H{ z{@$BqB;Ob+G_4>m`W(=N6>!jVo?8{f*!|9s$-4T5HydpB7>dubj$NbPfJvDMVTdSj zdKVGfjV;U=%%%B_a*k6=N$5<>$<=gQ?|!C=VDbni#IVg(W#Tkl*FRD#PEpZA#h#6s z5pJ$UALC!oXEqfsJ&rl`6vm4FReO~M>=S>$S}iPfp6tfoKwsWB7s>+*r-%FEz0Q$p z#wtvZ@+1>ct?SZ00nEpjzs&?%mIYF;?U_lj+OzQ#g|Ob_*$x!i-uXM#PZQRj+gs+I zyuTAegVOo6%&wjX6a|hnw}k?jOGGvTNo2!OP~7saDoJ+hL)cx|ME`BYiQ#0L{j!fU zXp+^N3aluSi8b)FLm580Eq94@_j6mGSu7(}@dL`BjLOR>u4dSRRt`MO!f6BvYY()q0k2Pr9Lq~2ioaz{af)+ZWks64dN~;>l)LR~OQLJ+ONIb%;y4&xD zJ2_2~ciGwGvbcffVjql$zWDitOMZ-huaUfvRX$?7-;ZFEl)~+N11ZoQ&pW7TM5*Ku#5+r!t z8bnTzB*{00n6k<}f}|+Sm0?B!i;f#6df`{_VqS2b;LLayH8$l*QC zY;L9gS(H_4l?CCn5O77xI#R~Er=;zN!@e?j*2jYNi;Scc&2i7H`PcAJ))N$;%)CAP zu+@t~2P1HcWuK(5d`p|e!4j_O!>L0x5;u}!;%8Mh4OKw1o368|bT(^C9{EP0iiYEk z;i{cotVwr-cj8kS3D3%$Xh7`j%<2O!=5Xtq2E7lJu2hUSH6 zBfGuHqt{?vuyW}c{qc7CW*d10u|Z0RqgI%!ORWPQJ_v2r-XuX${&CtrKd!&~A?)h} zenC*or~8=!ASG@}j*aTRDCv6hhh#Tbi>WC1Hn=X*Ldzdz^7G|GMv4(Bi&+KkRtB}r zSA79RzJhZPXSgI!!MqK5`{!L>L@Q#u@9ud5vGYH>EYdFs;Y8hp+;@5ax%n2-f#JS7({-BpWr$ZQ?ia62aXS!J z;lwD0(i>e3J$azri`0RB8y(4z>MlQSJUsgUuD2E+cgf-*audy^@g#VO2YQL~9)@Hg*c)>adZmrMV;~EYJr%Nu zD)*V-WkZX*7$Hf!xfOY-#7YARTTbMt@1?z|veZF=zOMaz&W^ML$2+dXbhBDvBNxt4t(B}IS)fm0`4a_24LKpvx@0i2*OCjQAH7it|2wqQiKO_$F8YZIVIBLr{;H7Kz2( z9Dz*r%`jDdGPS-9;qJDK4xF_|%b8r34NM)}%rk>YIGDErsUq*eI+~hV4h>mw?0c}* z+|(b+_Ynfd=5^`pg^~U7&Xlsb63u=z-d9#qv0-v^ROp^OH65?&wyRldPN$2#gkR$A zA%66~Ty&ER#gR?0_2C7AU2Ojsv#615Ndti zcym12-_!HHE_4PP_`X-(Xe^spnFkNQ8T+3#XD0!=FJ8^<`mdZK)Y>E7k7?@}Zg*%~K|rrlq}KSBqlJV%3}rCyYs5M1jUw5As{` z6Jj&12u%Eyc~I}z;|W+oybUt@`+o1`cVu;+~ivvp5ID630k(+s)!k(?M~k3N@Aix1r51`xr+_5y|?6`fTCR`2!GL5X{a>o8(D2KEU z6v(X0b9vEYM&f{*N_?gdmqm9$!pQ!%%*=sl_-xuOVp@S7$x(O@a- zf`H6QNo@#k_8pEo`0?0>BtAP(Q@lEVyfvySJ67q7TxLU=czj;!O5d8aq9$;)Tu+dr zRCk5s7W`%LU>?q0`DpR8oEK*lf`CZ9u88#@!O8&r<8MbkdSqa z=*8{C#3Na7hdz`FYq;|l^Nxq(AZAw|Awk|H)A@4++J9aU7nErruZ#^{*O}1 zp1hsk|CE~2MIGZ@$y=v|af9N`=wjDb?gi!gpCx0^D}d!Tb4MLQc1&-Vd_oqYE^-(J z*QzwGec`_k+T&5sTwm&&p7C~nTzqk`t%dB3e3(D)e~%v|e&ne#g!c$K5RLEzaEvB! z$BHafX&*V)H*0~5q>_G%$7(?)kI|B~WG2$ZxOdEgkI~{L7z}CApO5@$sSkIto&9c2 z^atqBK8WHp)=6OMR$HAI_8$@o0vu#kk;jJx6ji)qrrI+60eHRe#I}; zDi=JyVe$gmt(v3eR;4h=;`JOG9FL>f!lHN~Y@;nal&4NcrlUZE?3Kc7DL!R1l)qLZ zIxO;bhuZnUgS6%to2Jn3M^MzOWFq1%+A%XFfOdX873bT_M+X-{nhon|%5d_1t z1oxy;6KRJo-}}AQtt7JLcevTk56v!ab!dp8;b+k00J${IGs2aIUU*sW&o-#2v=~H8 zm?2O9WfoNI)Sr)^SJ4%Ks88HI$4iyV>aL5&0l%?5z%UCLl83K0b1s-`=<+d1<*!X5 z>Tk1FxysE7*719~XA+;*;bF}Dp6T0df7IRIvYnA9Ubq?JTHbiL**Q|S&}is>^T0NR zD!q~@>)jofPnsB|`*7(>`Als{b7U+{B*n$F&qqvF*oTTADx46C-&E-V*vvwb%vb(5 zc0FaI1L%TIq;Gn+k_-`OO)hl<7dIbtUSA9A@B>N1DFYc^YnIERVzt@$54~e4`WCtJ zbt3m=K=jbIR{`AYR%+gAQ;N9F_r zVkNE>=MJa(OB#$YCmPo6($tki?>5c)>;}>Gv5rGE_53!I-Mo3L%-tOu2>uFYsRS1s zAbgiYzEc%1tr_f~71=bgI4Pl6!55E({RSUSeA8a{DYTwO{^(3<@w@OKR45Nec#OH}I@ZmnB9+BtLVK&xqnLZJ7F60`?Mu8z9LO6D;+a>LQ8 zf6r3|vs`38cjaV13GKPsER0mQpp9Yug`MYg`U{LkO|Sf`3f<<;1my ze7cpw27aQ8O&n@;)RzU@WW$0{yg|61`_vRjBt!d}+sgd#^=vrV8rJjwej>|B(Jwv=2O*dM zNeS!!qwT%|%|e)#7L+FUas*Un0?deBD}JkDC$$pmL$eb8d?nO;Kbx9n7N>_*K6p|k z`tGYJW?jE+(Nf^TY>Nnr#FBRrs8?z?3mbDDfN1(sa%P$m%*vTk>!)Yqn9RX_OJ%0*h>xsc{wxfl|jkY>@}KbZzW7seyMw4q*Mqo`jmDT+I%OvY z_Fej)=#2&J{Z$+-+Sk>qzdYlb!0$WoXt}A5{C8L4ig}=+AT8xChSiD@xblv*dYNW; zf4#FP(a)b=gq#@*c|6FJH{L~N_Qxs5Kkq+mKIw(~>Cs28$O2SjT%G3?*hclYrDQpi zc2q5=B`rr6l4)%uOX_;Mbm^6})t-97A)Se+PYL*p3{^@eDqbVYu<4v{3l!h94zpw? z*K$zKXezE5nnQm2*BGC3CO^DE=P_vNcT`}JL|r)Nu=rJnG?`ueW5F?a2f{L~prznCz}AwfGYQb-xt*k%(#d@}OI~j@S*FWYSNubH_3wn%RE+ z#m~2<1vl39gypi6eNW9XhkKQF#rdF1J2?_De8=shy)coD&e<_wVkc{aQQ|44s*CsZhPub!NQqs6F$-gL@taQ)i zOnDn?VG(y`04GnmGLq0`V%suo5^YJbF{&{>q7peEuv#+>b?RZpWZJ>1`10jeZb#0eDt5@%i`7}a#DGY=mk zmL)(jc=c^lVocxGM(Fp%Jb#ZuYpZY<{^;$=YV2v~0VAt<5uOQF=qHI|DxojM>9r6? znun*oz27OoQq3C5WCr8$9-MT6Zhi{f^?`&|te++Jovhx{T zI|_(2399&^ldM2@e!LR`)#|Z6@nLn_KU3S@3Wvmk>kOLM;|_U#?`Fya)~RAC$4$tl zlsoE1iAK5m#Tas}nJEeA66gT72rjE>n;(L(nT(0e?yL*XkkH?P$Jaw7F%#jLm27-(sPY|rZ3CEy zWv>A_ey*Bibqx;43T^3_@%m*(^*B_jxlNBV8-E^VG>knywk@-d)v{ZK0xz|M7_7V} zuul!4`}5_isQ`wPI~Bi|8V*ns!KLCZ)R3ARuV9)%))KBD>6MPOQZolv-~(vl=-$68D-o+RZTXpiknb42I`|5-EhM6U9)3F`D)!&NWrybTnN<%Db9QQ-tWOp*0|P!K`fE zed3eQ`8!ZYep~UQ$xahM{zrgfTF~!V_gKDJip&1WjYZRd;=6Ms7td%N*$&c0%^fTB zJfreK=pYcfTtSm7wmK<~a;aRMrVLkUANyDiTYUI_tu$x#?KV-i8c^-Qtrd~UjZ*LB z0~oBMTK#DE2lrr@A?f2lRmQ1F*3BWTj&U~H(Jx1gHjAXpPTp7!C^?w}aU_~-un`OZ{AKnYS)irSq$js#+y7+Jr{EJWsZMs5e z0H|kzM$D6?rM*t7zN4zq!-a*~GE0;eV9C1wmK)9-jde@z0IruQlWL1FwbO84w$`ts z{!+<@{dTYqRIlU;`yxkDb3YWifo_l;R2SCgQ-fE9zLf(~s((2NM z6py9GGVBB~^V;PxYIgq{r-H-}q&uSOW>Av*7zg1(!>01prPIkhr;rb;q2J5IMNC?!w>O`q zZrdklPR>@%vr;8DxhNel8Z3>V0S+6tT81*PCq+nEEGMgf-yVJKbgQ$)iZ)2HgK^4b$>vu!A$#Zifn}N#h4@<;eADL*D7??(!#NC!*eAXCG*3HO zH6h+Pj^&6KXrg3H%VrI|FrHh1Z~C-naiKFAW~7vNVg%^FeIYh@czJ|3Wng1B=hu~8 zmqT!tj=<-bb1QPoS}_?H66W)^!U9XT11^Y{?9#=QgVk1+hk1Y z`Oy?HFCsR2uYiaL@jN;kDv##xJ7*0o7+e0DH_ymSspHm&ASjXj@?=OXrocH zsH&#dJ@R4ycHO9jlT7uheSoC9!LeL<49<`ev%5|>+skIFp=pG4B6rrmX3JhB zW(9Fe!Jbr~bP?qUMriBUz3%)FiZhc_kQZWoq9WYgTa5F0G8iF>)DQDvMgLk!q#HeXZT{+7nT5I+4GZr9adR<2B*+!Zrc7MixCBi}Fk99Y`*-vYg z_l~G~G3PfLO7)@*qTW%rrxiu^vYkHw>0`<<#Gtve!#jD0$2HqK62XQps*BeK4hPWM z8w>kCu@63DZzWt%w%7Ko+L5E60N?@=MECWt^yA!CqYXmTDH97SOw`S7m^Iwx_mWAp zjGQ!@dfB(8VOD6&6>tas6^f!U5{Zdj(yPC@b7QoFWLaNryJm;J5=?)zSR%h=tZ^z{ zj20)Xppd7Fh(usevH0Q0{#+&WTW7VkA#W99D^N;HYp@!-U@PxbKk@UW?K=9Y&yz1B zK^5dT^6l9w1f=FxA%$3DVrluAYB=!J=qdjMfkA+|z)k zG{_lO`O7D%oh}Cjle4v!{(5X+?|!XU2KlVewL$IWl{H0S%BnASmLk*DAB`z|6`;`+ zSELx3XNb1oPB3J1{`IuvCQYE}t+PJoynPqxHNGvR0u#Ub#^)KWAI;yvtdLFJKrn|s z=wgS>i+)n#>irMS+h@I7L3Ws_u2|klMq^1S`?fN*Y zx-#T@OHcBJCZ{8ykqrY*7LSd4!^{@6{84MaFnZq(K*}nKQu5d&kZylhdFK~b0*Q&0 zj#)qRmbmElVVtj)PxC1-YmQ224`UIXSoT6+-%#MD&Ji~{-t3q4Hh!e`Dw5BM?82$Z zwc93FVL(BPQDhgxIJGFkKjVR_$Moj!8HQz+(+Ud~;D{qQ#Ox68s; zLfo1JybwWt8pFnbUO{nz<=B=X?9rb;W0>m>>YknH{&A62 z!U_hG2{Yn0xW2j^EBZRbmqNFMeA|}?Q)DXusCeYG&YI>iP12!IEW^ctf2D7eOztRz zUeBIu=oqJWaMkt}GryKvrc^)x1*&y^{xx9IcYoby=ioW({!B^wV3D{;)&G#Cu)FC5 zw|1T9j6Q1-jx4D?{CWWrLuA7OpZq~rYJ>jgkfKeH#=Z1G145G=2H`$>5-z_n)n?yU z_Ql8!0gvzjvpYN&7qK1AWRi?JJ@lc{>Hp9ZcnPl-+O`{ z-4r?6Bx)(3LKzk@v8kfG@yiQ=I04{y&p-kgeBJk=Cq-L#qV46Y?LZV_7~7Z}S73hC?re4n zkvT?7xja-=-~xwSQM2CZCtX>(0$XSXU-QZSRffYO;cbR@Kn-^Qo76>`A6g_x}SlLCd}e3L#qFYy_PBiGC$S(_5xv$JurlL9u|U~)&3hvoBCXFz z_4KC6R=4xhy|uUrbU)MgBI3q_(;Nq%+)vAB|LkQ)FE>_!#X z3EYS4NN2B9m1x9cH|tymap$WR1*-j1J%}rt4gzF5Q9qb%E9@BgI2wztFU#l9t!P~< zf=)O3xJBr4`BZAmQG}}gfGty_{rg|VOE3LUA4iLr!H^C#+sDyy z#Oc{i!GG*`XYuR5{-@)eci&vB_okKpUk^b0-}Y^WZ2i{H1n}1y{drcfH7{E`P(TL_ zT|#Dh3$w`p`B-kKG2a1Ie{{&4917^UII?LrSumX3(b)TsnQ04vFB0bq7kTErH-d|O zJ~*6Ja(yzHyg8`AA6`Qn&k1bX=PtzPEnU^0$Ly{aycT|5-dsdb+wDJQ%V;;t&p&ro zABV>nXK#J<-oN$ljIIAJ0f0*YATAjI4?glAcg{Da7M4+_-OmNr zj@Ex1@7(8A$Zr~xA})3|DBo4{-g}Q@6TIZi;0#-!%X$|Je!J^~$?<}Z6z*bOV zum)xf+$*-tU{6{7zJF58+nUJ@WwG{D&yK2$MA-p=0CPdv3LLvw3D<6v*k{DaQOr#N z4}~Z>MLd{6fE#ht-bS#%k`HZ1^{DFzQAyJrWr0YEFQT(TcOg?6{bS>cGC_x9hiqZ$ zbASLpo|k#zi68W1C3uy}ej_uLE?_Dx3yP%T`vlO%mJ$W4p23+j`kd@$tbu)mJ)Z_j zgGW^jKPm9Sexxm-K&Wx^ zq=~ziy*rY?K8(P1jSoKforpM%r=R&Hyw8fn09~I(r9aeUnLlHOwV0;M!M3f>u4iL8 zUS~uN-6LzG4W-u>Yb@YfN-UyWo?DdyBv=lQ-<;y)B;wxP^*z*=+s4(+QVtMjQN-k` z0%SDa>X_XMe5LXi1eX*M_Q0HTf*vF{S3QqPw~)2u3w>NvU%zUzpgfF$;(PDC6GumP z;^mhXwolVsVYOr1@Ah5r?{pVOr`d!5latt;oyFh&JAWqLdgqOqVNznV2LENF0DsBs zAIyM&>i45eo%&xU^+`Y0N$(>71^v4Y=;}EyscU3&AsF}--1EAgUaEINKBMp%<#wF< zZ$OmAYU5zNPJ^UGp%I8B988382nq~JQlfeQZOlF2zs}AWm^Q~80{|6c`#@wGeS~IA znBwD>&r`|N!1ryGaW?=W&DY=Fi)CeU2sGvxlOr(W=S`lC%U=Zmy#LXA|Mu^{WB^Z$XQ}ciY87rq;4r((=H=qrQiTEKvS_caA z%x3`3W&UY6GkQ zC9q{1(iC+ZrB-bdzS|`N&zu62)=1|A?K4tM7nL`Lc~mb&VR^V2?7=?L1Mzjx4Q8Os#hXEqA_>1k4sB&Prl zy*-t={CSh(N?PebomA_OaiB(B_EDgwFL6l$ohZ}o z&Jp{)`ahnywTaV{630gsU}BqT_8kOxJ6bzh9WK1AV<`Hj{$wC#Y%T)T^VbT{QS+~N z-12*Z5zqzlWZg=jXFmS^8HD#T8=vp_yZ7FGKaTF-j_-Wu6^)Hu{B*yIv$Gbv9{kU= z|D&T8C&wqTJ3WiP`}h7_{Pe9?I|vyI#lId#soO=tAGUu>u%C4f0O#MOvk?-!oi>q6 zSxa5cl^NxllUd8L?6U@?dDDO>l^3wm!k>_h{7u6cY0_qE=x#{puus?Op z`*FGEOD5+KUi&`?Zb`f6`bZ+!e3{$imb5K%=;%KLQf$n{n1}x^WC{$+KBE+TrVKn2 z6SQ@;6M|?p{8Rx3;kmLh4gEB8}=Vu0sxl)z$F9VLIB`D z^u)F6-~AOA|iyme{Qi+S3zfcfpF-YfpopZIp??zJ}OaOdY{c!M65F25On35=8 zjGCn2cs00#5T*}Hb9PP;X~w#|A% z-|+*Tr`1>wsemR5<#l;F51upG4qQtwY6WdUA9%N#;Um)gUc<633|Qmd-M40H_x0;9 z7+a$BdgptOAYu|HRHfn&it7&aaM*is5xxf z9_ZMO^nHK~lZ4-7SEo#R4<0r_8^e{O(438urZF(ZmFp#1i#bq`Wt!~e2pUYAehgg` z;94&SAn2$`A!&h_z~QEF%P)dQ`T`Btg4R;T-a(*=Wg2cVEh9~UAI+-$H*fv&@~k!O zO}Y~5&v|_UH`CMw-9qAky?>!A(ADIVlTa}_qC|@Eu?W#^w+C1s4I-1ZR0!Bjn3(E{ zFBw=^x;CEE0{RIA*td-PcXw6;8gZ~W2YIfZTgO=d<`nNHIei-k7|4IU#A8(8Q5&b7c9Gv5;2zpWS+*8b2&gGxHzx~U4entpwH1V*aX^aH~XknQNhak zVX47>2mJ+`P6!xGj7+$M>tcbH3*`^;{V6`8Yaz zCo+!h+UXg_d%<)a007_~#Ij~t$Lrny)VqsgKE?zwZ1kP_I9K*j5P)Eb%)%gg_x*EC zfUkZA0(@_AYiY6HchGa+a;E(s9i7I>@i`mdB>vXl{?EgY9q($Le+mQ70%23mA6NoZij`MZS}Kxt!{<`hz?v-=3|dWe&j4CX zHk_&HADiy$x*l0HA$A%qj-B-)EsAmeD!i)9kJ0CTBh3P65}{Ar9m<7|wF&*;8tP!I zjWt0aXo*Aj6b#R8m<(in4(<6c&TNF@H z09{QU%hGC)zv}#?pgvL>a6zO==VB$G1PAm}ouazZa0BaK>SPx!dsK`BLgSl>dcGt&=YK!|OD(rqs@q0FnDaw>alv|qf^ByOp| zruTQ>R$;s#TgC>|yn4srocliS-F+`Ik7r>(UDIi^>Ed{$slR8TyI!Wd@c>=fwqR4% zy|3Q`Xa!fK4O4h6BT4Vpq@4Fv|GYJAZ>dd1Fd`*u3jEk{sb{y_DW+PbV2{#;z*r#5 z!v-CL{hW2Ydab`6PLCD5tp4|#?Y56CvFaoo)D8k1xA$c8rTfnHEF^ZN4n~=XIuIER zHr@~vwhGl!ZzCd|`GRqD7ZFv@pOqlCUJmf_k7wZM)~#RGdCx(Y1m$#3NPLvR8pPee zUJ+s5KynCLk5>9nr}VSTJMgLWvrCt%Mvli533SvNlBk znd=y*`(adWDtopMKloAHz4Jjl^Xxy;x4qcJ(ZhSQSp_z;We0P%8?iLBtom{O`^sU7 zsq(q+%c;*sP6@e+`prL9YNTgv9>b(Tkj-p?Az~`9fDh7CyRN}wPn0-1nuP&T!`$c^FKa5jiaNJ?)iVetNp+K{##MY;T-rwR9M2b z9SEo>2k7JC`g;-0zeM(cSGCs?0PN3LRE=c}MD>Hre+UdANXmCGgZX9qKARvD$bh8O zGUyo-v_mto!5D%+kz)%!>PwN8p!LnOpVtH1qWHDP!sG8U%^n*rKPdBASz>iP4qjU( zro3tV({aEy52X~C`(${4PsM$dh&ApH$2`c&^RcK!?=ONp<7+}8KP!E}bIHE2`99}A zERWj#OatKGxdZ^%*e(HpO90@;125mWe&dJ#O>GSWuqlxml9$1z0mD82iMX#`+^lIF zSd7@a-<}o^Jz%}`OpklcK4oxq#aS+UbR(}+x*zH)ouGgvH z*pF%8s4pzTkLA%dM1}=0`2Ib6OavCFv|mg|w(XL00`IET060?rcyW0=svR$Or&#%c zc?9Ed>pBT6>JN9KU=4I4siCTG4&T}qt5FkMcKl{J1Yy+83?e)sl7Q6j9 z6*i()OnS7A-Ajk(AV9e?GXWy^+B!0`3f7rrMSvGxPUMz=1VxLLv1qY^pkv);DarK^ z2nu9eYmGnt_{ULV*O>sC40akg0_Tvm*b2T%iCf<7wXTyr*~$qdQpv{x$7e02`oXIp{60RnkJC1}I!|t8x zPFC#aWxF~)B^ghD`V9a;y959(0f67r0KoMJzIWsLga6Qfn>4q9nkhuLf*D}ae*F0# zTk#PaAc9<+#_bke#ckV`?}^q1UYZCRcwB}uj#@E)TL%CLBzQiQ=i-0^&sFH^nBt}% z()QDjdDu^mSZ9{OpkK&#_7{i`zFL!kpnW&09jhkH6~ptSf-v`wpa+1DTCTT&uGD&R zn7l%XKcPQi-v4V6YtuF)CPYfDS^rRA5J1-~4ESE`&c2Ep54?oQM1#JCf;yx4;hL}? zRWF>bdqA|DS@6tN*a+(v1{J(UqL=fU`2&kA!{}bClj!AN_oSc1G-26IHN|;MMh>s` z%>UU*4K-P4O$dm}GFjZlV^QNUj`#1NKR^`>X1O2$s7B9kdc-qOWJVk+$(ei6av=`5 z-l?>Wj9lF<__?m}C!f4FtHnR@#P=0|&=q3&j6zwdW92^AZ`+;|TGfOvKJZ5M&Fm}- zly?#EajI{vf>2Zj0;@`8sQW>|wzB$!SWL3nBCxfX%K@yObruGk%C$upus{l64TN31 z3al~*RTVhdGfOd1mXAmnviTSWyi?0%6xD(2@1+);^GrDweD>+vasS>&@!Sg?1W>aq zmfj_JMni&ZoVE%8a9S+-K;oNT5*A__EMQM1{kZFaWRo+2SV#hjvk0e0qd~5DLA)fb4CKUt7adS zGPpK`K7IeLAE0k+p6>}kHqL0sI4Z@m{%#|9K?2OqmFx}*p)v{Br*p@@_0ju(;7>(F zyJP@d0sy~<0RRvF!2o#hhYvpZ@IUh3X7E zaX_SKqWjIVUjv`1DRawfk!b;-wPLlxWL+@Uf=4^D&hA{0v>*a)5TZ@2NoPH#NdiQv zq-mnQzeN+Efa74vfZvm$OrrJUxZK@*~JyK(?8v#3Kk;PY;{KKlT`r~sP(^k zy~KW>GZR1pWuij!IO<}VcTgUWsa{_|0Br~Q4-A-{nY3iS1YBfi60jV|g6taU^9coh z92b=>Ec+<#upzo<>=DGanF$cFizjaWP}`>ZyYrFs8SWyChw2*$Gp)fOy|qiUXmh{@ zwvlSbMO770Gk@1a1<#;oJ!*&x%D#dM@%lSTDbNInxbaYD0-W5JDBU_?JlH%Qo9TKS z@7{}yW|=>X-3d(qD2VBzmBzH7U0+w+{{dXxg6k*uB(JAH*xJ81lYeD*^adjm4_|t4~_X8H+9WP z-yU1R=~;K+ycSx1QnaHIaDp2Ebpfi`tZr8Qk5~iW8k@Lt`#4^I<45uLfAep}Z~yk+ ziMQT3=24V)a++q2^brU+>p;{%*UL!jv-qUF)LFQ3?P|dtK%7-BffR znTt+cM-u^_wXR^{0v6O_Y@2{CkFyjlvu>d$!5qzf?1zp;lfCavG&)1en=tT27I@n4 z8tkilMq8s}ez-0-*B>-?iy95AkJopd) zxAih}^pyd%?~$2TPPRR3ZyEsutPr$duk*2>@?vU~XOW#U#g9BV4L^S$#<;sl+sj0Hn)Nullun4p5#D@2|GRSd!^l=;?zZVZa^nGbo zAafS0gO?dN`M>1-7#D{gA4%$<@3*4ySwq)#rgNmW~=q*WoUQlr5Mvx zC={vSQJ@k7H<7zIZ@itid-ZyBCcx*J)#G( zJ&2EJi$I%#HvPWecfmb?O)Dk5Ut_Yb=H;a!QI~tOvH!g+Oiu~!N9hFY+MWOu#4Fp9 zU0s*}S>oR9-EyyK{TiF{NNlI;-9J7u3IHP?qkUql!JmD{Wt^U+z>hR3QA>TYS|UwU z4kD*qe{35WpMLsQ+`W6Q3GfdqxR^Bn+I|Vj<e<$Q#2{jJjrWUylV@MTf5Rcugb2-4>+k8{x z9LQeVywI5d?S#)NeE2!uCjd7WtSt;L`SNh`o}yz^vrWimLs${pP2Cnu5+pDwKJv%5*kQ2piITts{}NCUSx5SIYJ;r5BmLy!GO{s)}suvQmYX(0TS zwc!^0;dV|@l<~tD*le(=IBESe+Wb^02xsc|fftASx31fAwPYQW*Ic{s zS7gIy8aTB+vJ45-S;uP1g!+z|z>#i3t=g_-feoFG^vMw?(Y&E9Xsp>wKsm6mqvAZN z{bX9;;}-bXRp zP1weP5#nW33JB*PE$ja8TAo>C?!WhWt}6C1Nm)HU|NNaeIsPJ^f8qDdwwb&AUSg08 z;KaJnHJ`@}1?!t>AFb_T-;|XOfosSRmN{VGw|zt*@UFg*=-;|u357CKId#xb6LelL zl?y;<52B-+4kC(*E)d}O{w5O66{VcJ1Dt~Z??%S1Uv2fW`ho)e8J1jt-V2@?fjAW^ z>=0tZyLzKi5x&QwglfP&@JeRTG48M?e0Dt1SOMF&ZGSIBjnMLCyWDSmxmW;!S|x)#w{%#vXzdqg+B-&hbTg~Tl(0Fk^ zg@d4YM?lmNJ1hi#Xooy5{{-t63}NeIA3JHmI!Y0aN)5gIW1xqnBu3^s_xA+j{{G+j z_=7+6=PwxmmjJ-H#U%i6aPauSBTxLN|3?~pG$Hh70kl%Hf3gLJe-l3rQ^Zdn+~|Ss z0|wSZV~tzIppq%kH&>7M<}@*iRT(`rIl%e)L_B?x>~?$vDBOz{boXFjPe2BfJ1?K` z5)K;YqyHj3$?50D#y+@kIc0gde2Oy#V5AGKI7`nnY8-TTGO4OJ}WGv6Gve&Kehgpwk z7;}nP1{3emIFvBpQD+3`BDJ2)?AwkMMKun}6H&|IIgQvRo(Y0t6YbaE`?zOWKbiX5 zD6$e1)9>MwSk!^@{@wKNV=p?n#utHH)Q2Tg#qq(H7EU{pJY7#aJXW)6CvP`}nyOjTDO65wMXUMLp2Sx{wZ%2cqC z`n{p|O{s@{d82!a?q5o%G@$=qSb`bwkgrj>uT0wiEgzFLP?(s2#*(@EDA;$(dW3<- z*2dm(0x6l9rf8=H{Wi~O8Q#b|;}}nGb2XVoNxa6!8S05+QkH;>)=FaA%2pndZrkYj|?kxl)mnJO-W77r}lv)pN5Y)P>^ z9)hbS6Rhw|fwqCa*t9;6^TKOUE>h9&$4Sb+Z(sF@roKL=y86KCf>@5;4(YG{svR~j zTkuc)F7WH**B0knAmCQHHR1hC&-yj=b2mO;q9noU*_{6ei?RSX0&Ln!y}ka^&J@e; zcE|C>m%kYgJov5HZXaNuP{CF~aHN&t$l@y^loi#4StT-}gtNwf0SnqN?3pIm5IPt| z#zjf842J5w)6@e<1?SqgYRQ$+fxTe=n~op|?qOZjEdt9FyR)v0Pn$FO)6*0Eu-=SX z4(1G^I-1WOCx-egIf0jL+Zr@tf?92x3jjk9307MpS`Ag`OVRr6>#hW|FyN1)%$fkd z1Qv*v30W=z0O7TnBKp0sW@$6k=iH9IS9Tc_aWFvi7}2WG%0XmRsBLllnsiUb(qCDY z%)T;Os;8GGz-A6akMHGj52x%6XJNq23_fU~pD_D?;|`n9WnYoVKsYnj?@n4h-@fpsn`I5n0Bd*@U?BJWcp7vAXu=-J!{lk6#TzoLB?6 zoD4_vdu~A^w^3p zNP$~}oD#WG91AJm8~LE?F2P;t6W-51|BbkQ^~u<7ADSoEPIGi2Hcf0Q zJaOrik-X>3Eu*g4D6Esz0nj$3Nx*H3W=A&xt8)YpFeL-%NPFgzHkN$eP3>1@0L3PO zS8tTqcXt4tF9gpJ%r4ofE7PqgImPk*J)>reRZ^(Tk1S$a1xXe4iu|iUof;z!NJ>## z<4e~+K+R&29bET~Ply4ceia}<7Y4lbOZ}PPI6c)!1Hsm@uVB1Ft$&>ehH5N~8UaQ9 zbz3dzUf}_B>Y6(%3HQYmfnX8&JlH@Ljd^`rS)>UN8D(aFXnkGy?eth|rw8I__jW|_ z@{(}Y7cJ2c(4`@PW;3PnNGu(dZ_vt?;|lAib01NZCGTqN89M+6DG@j3)n+EZ-Mb&g zbI<)Fav|V2I2Mr2oJ#;Ir`RoZsaQ6~=NbOvMIe&y;h1122@Qe1jsepI@dQqm0y{Q{ z+K3fmS9}%*JUKou{Ros6L`?M{u54b6`)6-P<}uzkqKl*de4dd4*fgdA7(%{48X9U9 z_mjpe)=?*es}$pGqx}=QUW0uTd0j0#H%o@Ut{M5-1APGj-S$sP|GA3!mC!xZe`;qM z5X|O&1IOa_$0e!-iY7)&SiXX_1PvrT$AwHK##A0;7BVWUyG3!F_j#00eW?lL^G6?( zn5f|xxrzarC^hFXo_gZ;y^&gv;c`&8%Jo6l*S*POD&<09X$eqgHtpZ98#M@YZf1gD z&$+!%N-18h%jLNHLx7gzs(b0D~{@WhSYCm9Q|_+AYrW<(BL(?K9IUi zjb+ZctO=V0>GT6Ca3ri{tXXE9h%(O*9_MVS)ACXY{0`*WDU3d`0_tyVKyMDGu<^&b zigl%ih9ltgRJ$F8vQu6|>oAF}YxHbGpi5W{E_PmQ)`OB16O=NH+JAuvSp*g5C2HmZ zk_e33K0g2A@5kZcgK_2X@f8Fs1WwlaRApoV09f5TC*J$mOufbzlXeNm$^Ah7{qx_N zY@}t~HSsVYPs{_{>#7@GX@y1uH0!b8K8?a&J&+|`I1aD0`P%HxG_mcJ;|AffQrG(a z!*dWIwhIW51OY&sRiC>B674MctawIApi4g{FsVQ_O>9%!@q5X~kdS5E3<6}H#S>5b zVD3v@#DuUU#xjggRsFtDuU97kOfV6^^EnUcEuaDhlWaU+&T>i#amuX&+@r+gQW<5w zI{IfVA}cik;`lxUBNs8O!_8xHxP370oxY`sY-e563Y`$x#fDnqCm4*3wMwL)!TBZv zk8}%9CPs_)+XnBsG8C2k{PTC?{{2tmg%^I`*L549uFcx@w$G?_7quob()0C&E6ge? z%4ayCz0!3xR>3}5049NGfFF7nrm@ae&5`B)sJ#z^dJWDpC=pNGA_&m6Cwj7b8&@R^ zm?yzjs7De8-0Pk-dq3C_QP|Pm5eB&^@WyjiG5_JHfp;}Z1Q39S{-2_jZQl;&R2@Lv zFo35`Aq0gt0vdImENO$tp8_eZSNcZ-LE~a$p_ewqSlBm4KF@5_Au2YOc-_8FiChoK zXPCv^oY*5ae;WeAIpRflv)=IfMF}V4z7kAh@`arA^5>#7}Lyj%znK=gG0bkoL=WO zk`X4jIBpQ1(Y*%YHT1KN7yBS!lYOGQ9=|g~XrDHCscISKb~s@g`y#O1K+KPQ0mbh%{jA-d z5InDew=(-ZAS7#Gk~(~}*lc>R*IHw?U%?(!#Y5{FfV5hYO;8h$(02NDymGBOxSgIr z*bCHX{YZ@NaJm`M#!P?$yExWAMh3IGxQ7w}1`WswV&S-QkA05D92?lVNd=9iEFi$E zGZWzPn?HoL+CI>rULRcnz9r{fQKwC`{ca8#DAL$kn?(Toc9ETgIH^PhW%oKw+xatT zwa|&e$-s1wP@hYnurmZ`TUGo07<)Y{as7e*3>@8SDkqSCeH?5ajVaEB0pDJ-*pu6T zLDM5ID0b3#V0HfE5+++V2cnN0N>cY3g8|+fei-rD;tufK3;$R|7N<&wJAQ6A``-Pi zHJ;$&L0uhZ0#HC!IwQ)~>uoYku$;A4@OkC@5SvH&`K$RdF>tvW>OYmS z1+UK`zqv87^K*w7m^XXq&F>47PV76gg3j?M>es@rmoGbP7i&1QcK5G+^5L)k`Z&;! z_Wy?fz$E~12>^KT|NlAwaPyge;a@GKKA;EQvbVN-D&H~?_7VXXrIP|~@l1aV%e_V? zY##OFrHH`uI1;~0J>NuAQle)V>_NF^)3z$8h}0rbP?1)HzfQhvn_fHSG*-`qdxjsu zk{u_@D~uL5MzKi+&rWss?J0Z5m$QY*5@3`HII_IFzbH(ozj^OOal18tu`xK~bpq;c zz!dJ2O3|BAv3&*bqW=l+XTva~1R<#C>dw*P&fPbnwWGLk<6A<|yNY+h zkw8bG0E+V~zehA-MxdjBV7K47YOln*tTdu9_#RH^5d4E;x`(KxsE8DdD}J1PJFuzK zvRxk_S!YZ;x-Ja3>%xFK%$_Zr#RgT++y%)p<8XR1N(clX*o1&eYRLj=Od!w{-z|`9 zYuQOOSO|Jue~D7JHddyBeTqlYMFb$gk0SCk9)JA%%e9tOV;F@rSuPN%RRi@2_u6e=fC>b|zM z*={A;DN#X|m+AhSk7u{r&49puzk-dM?Pg)7EFf-lAnA!)n>aekxbqdpjztsTfh#Y@ zy>qaqodRKDpoV7)H2o&{x(hUd3FG3uMM3V;Ul9Lg@W1xHm5*0!OY<86^`cQrw0I+6 zbj|$76g6u9JNokaQ8|iA1PZgmYdIqJHLc}d#n=&G%z*)A)`4I+OtmkE`#Z@d^!v#L zRU^d40gzAu#HN!v3CdG5>S6$Z8_>GOZ1iU9T%hB`W8gmZ=D_iB9;$QW{3F-#!pESr zc`m>h%yra?Uz7eGv?LXlz>k+eWUxulRks*i0tQZ6nlEx)&c&FF!{?cN*Si=rS$Teb zgEHZ-#}~Vk&wuUHkN?DPTrvPI0f0*YAjZLeF9G-$f2Bly7=gWQ-fPeu%^Z95GlNW8 zENciUUScl}+jG_a956&9#dknU(~o}h?Rj(0)L{u)Vdm~4aTpwLiE;Du8+SghnIo;% zR?4W~G8sls_eKl|07F!IMEkyc=R?6Hmx~;=V*buy^z1Q3-)v;?(j<|L+bPAf4b2%B z$_QbiS+z}l@61Qb*i{n+TgCA1-G4uhjy{No9(-w?*iRKc{!JN*K9K`OA0mA03kBBt zoIh)z+xEktP0RUZ!KUP?>CHoa2p^Ey0E9o z^*3-egEbLVAOq;DELB^7FPzZge!&=mzE_`jVZgh0-;ZaX{YPi8r%sgui(tHTeF1*1 z=d@1!`pY(p`8+ZP_gRZ^%qp}zb3>;*)tvtXpwZVYT!~(MzsDx(;*?xT|%6}L4!|x^F z+h6!oBC1LNj1@rOzc}MBMjAfSJu&W5;W#M3ULUOQOxAEia}l7q17ptjH8e>@)_mr!Qn`i0Nw*dU0_#%r1eWLewqZvXjDKl&5D>6!ma z0N@e;`1dma5b=ux0RK`%eJs@Q!dD6iqF|i31K~x>U~od4w>2A#@7l;<(>O+Hh|V4WmD8= z7t!)#&QO4-+h-Mkf;FLWQ@ze>jH0kPg+F=R`o!HIXhT%ES*pA9#p?ElO_{@p^q0N> zD`pKdicKt?G9F1#M|ImfO!QZZ=kdwsar^eG@!$jBiOuH9q7?vEgq6PZpsZ(%_?dzs zvN%MC=Omxt4ewho0g|7)j)PfO1`=(i8;1#N!3wCXS`Rt~WhfX>P73xB5SD^q8+}u! zI)eb2WiAKkO68N3#^-H0(sX_MOk|cB1kf0y?5sIQIH+i2Mp55Wp8=ERwocAaB9Ia$ zBeECCKvW;%Suq-F)R_rT$}XNbw+PHMUvk|un9j+QzuQNymVe5;iQ)TMPQ@<`^0a=INu=Si`i*CC%tZe{y`mjsT&0M^Z$L>Sirq$`Cj)PfetVe6zCTQn#D%5jCFmw z6chyf^om!B0T^{U=DD>M02jlLIyJy4n9XJ~fHma+jEAcK&R!S;0A)OBE8~svemBu+ z=t8w-Bu4-OHs86xX_}zBi}?C-o-`vsYvlrVZMZ42lE7F^+rH+4{n>E_f{R+)B+XhG z%sEvnFuP=MN1G|T8%o@iPD47`3K`T#QBIGvK0i=u6U}z!tuh3v;<6*opdRv z?;|4)Dw7HOB9;k^v?AeR5(lynudYNW6&*yEEpkJ#^U(2=KnT3fK^9wXKe{ zOl!sq&v1dGfDp#2Ql|pSW}S2o_6#+4r164#gcE?>KP^gGH34qk{N*Thop7sUXHz%0 z_sZ(up3C3VD<}f?p#6g9OO5wmTHgsETC@6{HcrYf@0Ug}%E$Pag)3qIsbH)wAixx@ z#jFW{(xG|%gXxjj)(7MM**m%y165zf{DSYfy`SXePp1N9u!{>$h1OLjmxSHM{v%+h zaR7mEUGBvOs&y~L`1G6!aOb^v=GlJ)nPXkJ4ggG(&I`5pCHQ`l+P}WjzL~j4kP#70 z!0!}=pl|v;A%LlmKV?A8R!~>_J9A!E_Zi$ORxq-3O@OnLjH7!@Xtuc=;QGP0x&UN5 zCeRTj6{z-W>ZNg16)&WhYn%{xB)dRl!xNzC>VPq^pUJv2`#rO$#eGHUeN+GnTu=9; z1-B~zS1EFb%ixXZ2%+}Kj5)`|L)|YKl<4xfA;lD0N@e;xC8($ zumZgG^gsVkm9lv@tc^3aHbM5HbRY@X<<;f;)C2yY9uvDZp#l!b=P&e#32tE6DRY z!XjzO?Cje~eE#|0jf2BS;_%>cz4W8a8AN4eC)LUh6oi8s6GjtOy|!qirktG29&SkC zpMMJU9HehCO{>7wW;5`@UP>06Mw~qpi)Cs=fdJrzqPXc zCP!`G_j{#2Ie&Mu{}6qJdRSu>8TH@G0&rV`p34W?6LI0*#g(9*y9^AiG*u2z$}Vm_ z`O6VJiqa|H>RTPte!o)yXiw=X{5BzzpV`118D~oMeDvDxMz3a{czci+wFiu}n_8C@ zsL&e9F;anpK!CX%K>eXhwW$xs!Sqn~k6*Px?4=1t614{i?rP0|nBz#)7UMPpr6k+K z1=9^6WRO*&>0M~TfN#gq{mXP?h8pGuI7Tb`N?D&mCqw;*3^qV}*yXuL{$O%cOt`p^&L1AAukHZ)Oqxl~qQgz0 zhZ(bqa63u24j=mw!d(-pAfngzecb{&OqCos=L=b2T%e9 z*o_7%C{O$QiYPG~_wR@@)$P@kJ@nFnNLQo{zQ|}rWz8sR;WkKXAJnCWSU>^$(`%Rc zqM)sq*7qz+6lT!b5(?~^{NZb!vkBv9JN#SQ>OTH^Fa;)LY^&=YEJ3x|UuNQnS0#Mb z@WO8$>eHNn;X4zP3u+B4Kf15K{PIWhA~&u-7bTP_OV~J7*=I59vj~61l7-W9jcUkv z&RR@e72bg>Xb+SK?Z5Afwc4;5%h(=l;MUkA+tZ< zFZ1@*uF4(Ygi7NQv+Eh|21@U>%`;s?VE;ax8Uk6IRlE!LuC9&#()q(pf{qf)^$0jK z1VE|MD5~VyVP;Z>>iVT*eDd*+W+uR6kN+St69CSB&1A}Ms`c?)Vv3Oooq)0EmHI1D zS|F-7{^{QA-$k;FpkMa{Ln;IWact7zEKCB3N77$@61bHS*B_i>-*Og%)N`!h?r{6) z&wd_>`=@XBKE%DoTJ7E^v4V#-erGg0z>)3Nt(~fG>E6*d z$@gGV4*)d$o}tE=Wa4?HvTR0TpDI%W^GECJSTtr;Q}fsRzV>xiYd;JAt3j(`X6fPy zaFO9e;T#KAwf%nzGsC8M1)yqi-ac|PQ-tP!oPQy~(9eNPE(<=3y&uPRc3>hlqO|6~ zq}^f;IS7yw^8}W!1cY(Z@)e-X3v|A~bjsFq7bvh-oGHmtSRE(9QeDfmp9>TOtc(+l zK!LCCf8f1rjWC1ed75|Qc(oiht@#)FshIQB@R$i!g}G|kGP>R;_ka4YfARTWdH??f z0O0?R+a&;S2>|p~zXc=nlxD`_S;Q39OHYW9vNj2U72YY|vHp!8#d4k8xBfexwrIM7AZgJIx>Ap=`7 zjLXdMPb@Pt?=~|tUgo+`aEn=)sRSi{5N>Cc7IvQByrQ~Zf<67 zcHlklG0GCMK`hqf!d)hk}nQ%{oo}noC!n-mgW9 zK)hWR%1VEp0gpGkP_OqkpUmIL<3+4C@!ny0Q!jrZuDB2iKz%s}eM!s$(gz&k#4Nad zv$B5k=BKUJeK>%bN0x=*B@ThnY}#%k!-5bw#&~Q-zqc_*qVJQ}Hs*tjV^L$Pp6d`2 z=9GY%YE@<;4CW|;qx~?EU44GjpY7~;Z{V0)WIQn2))#E^G#udc3n@$}3+tY8#mi=< zV}VgS;NK#=qV%WnW;@1Mlngw@CKM6fUuE6AbLVSza&p%`_@N&$E7px~+wCxBv^Lg+ z>_JnG1r1q)qhoG!c=3k<-fXkk0NHoy0V(2v3W)m-Q7LeX1V_PkvlU(f zb5Vr45#hqoY_GiPJv+eThnsGA2$ab5G@a`9L-z3GOV<3v%yjOR;%kiQ5^|^;Gh@K_ z7+)P@kRqf%)pU}q1*|2bhLOH6=BFy-iR?GX0ON2nG?y}oCz<0_5HE$c(8hQIyxQs} zm$s$Q4;5a-$l}-$AUZA7q4FE@3l5Jpxo8y`rNEq$P@pSlKE8VZxO7KtHDVEK}2Sqzw&&YAXQXWaUI^?E77+2x>KQa>?6b6axzX|pr!l7!6P zDrXw+lp-edp7LA_{-Yki6|cJS8SJK?C!*yzs6vuX07^62P*noZWbln25W=ZZKsG@4lH$4z zSiJ>7SlrM1zfX>D+WogaZP%WX18y$?RgHGW1>UUf)2ZPlulo-ufT09d2myH=YzWjS zd*OXkr7Q^JHo@z`^%F`3@?fub#OxC1T7&7|+@@fuk2yt3NSSZpt@0!+`cN!=C>)nA z@5lY@q?!~*A#l9V-3VajcHF)Z^I4D^#L16zXc#CN!-_YMw#9sjXCV`XhTO!Vmb@fc z2?^eajd3L;Y&UOyvcsTXdigiSd))ip9B*lx8!^~L^WjewPhKc7-aHHj$wsN!HS6#u zI%!nN9N^X!NI{NSGatvz)Myk2K@%f!GYkSH80y1tV3(hpdRoZG4`wT7QO7X$Ja60b zloQ~K2+ufq#gJ<$g9HW~lya1i2_sQ50_&;#rXq7FYbo}exi!X7cBV)tXm8&7f<1h= z=L9gTwyC>8vZuJR@ZwKlFdQj7@q${UiR+4bPR|9S|9*~?vNsR~q`xdgrJ*1(rjhQ` zhhfT0Nds#U!aVZ4svO}ts~TgK5WJ{U$vK!pf$5W4`K_q3uPsqzhoYw# z!Ie>Boa4xY2fzA%e|Ybg?kq6>w|6@N;M@RkZUC@nHvs&$f1%ck-vfaPi|`6*2=HB+ zGlYBy4dVrRRDogDD;%=K0bkv$>g_okZIz@x@u; zti>)&ul9aA*?rxtdXT?Ko_rMa1b`G{r4#pU4X=%)5S)}gU%LsM*p0ifh4I8YZ7#DxQxwKEIgrF6qX+^3Jn9EO{MQ9XVOrJ)-J213P~ z_xX3Uod*h}bR)o%$KJyMY|M>jZj&87%f+S=pTov?0o{ z!`iONce28s+bCfLj7eM<8B4^GK!E_)aYUIx1{&YRY%R(^ZC3P_%6=HFSt~+bU~edr zAwl;CMP!Os9S($OOYG}XcmU^`EPrK!K~3356|70?@|)9NtP!k}jFZRyE1Y0l!w6)A z7X~+{e#7v{0Ug_1ibV(wp%fS$t37<{Xa6q`?|j#)n(H__0vC^MBpKC5{O`FCtj6lCWNZEqCUg$UF0Cc@l`-zYIM4yY-OTIZugNB6Y zrk@3;{B+VUO~)|@<~bfy=7v5s1{3x4mU!1la^O@9Rd0llg|B zU^?UGAbNB@myBe}q;7FGVGOL7YE;nqEiu5H*-(NtHQiu~|4cUmynOw;ld|g=BouiR z@!*;t9mJQs4-mYKzcCtthA8TZoab5AlMKssw-`4N2N;53IWN_em>NOdoD_k_kDBH5 zTHEP`9un|m_eJGlpwGuxlk(FTUr}=Ro5OT@uPmKV_#U4GFqOK8UGKc|B2NEFGxuv}$lT=2o{4m~Sg0)!d zvthU7eTsKC(!CNw7G#Q}zJVkGKCr*0+Az{2GN#wyF2oRq|Ue8s3WGs;Q(SaCGibYYAnYyXQ7? zh4z8xY(*(XekpBX4q;qVg*AgfT%t1Wx@?}(92ez|kc?buE-pD_km2A2up*&H%d&DE zr>zUhCA|-&7UBMO!=QQV>wn>YeRA@(xtY0Oxy^67Uh64t*oGGow%j5q2y;QGt>kJpg;8ly&nF}`EUwuC z8*Q;cJSLu0M@Y=isYL}($hj)_HoWFZfjuxy7?Wo&3T@MSAUsB2sdZfyIF7{^%|j1+ zt}f!a-!sV|%25=4Q<5PU#T$i1=MLTwsNnJPWC-Xa=naJ=(n6DTOend||A z&Gd!s>n}bVbymA|>yzewY%jn3oArIe`_!L{tQ`9|Y`j};4(g|Whtw~|Wrvr4Flfg( zXJ&Mm{5g!HhftCbKwFG?GS=k0+v=U|=k1XIv)l0_pyi?bZtL@QRNrIAyDyFUCBp;n zN6~d(3_3z0>Jl2j9N>EXHk9N#8FRwm4JZ+VElLVwTP9LL+7xR@Hv+tM_sjOtkN&7+ zdog2eZ7t{a55o$fW4-T%piHwh5bn{5{koDKNHO{#h~k{6&f=ONxP0YRvnNlihXX(k)+~$wk59j1ZaWLZikED-?qLbCCu0DKeLXykGjn?s z)bmhs=reR0NJ|Ozm?VU#4untCRm&h&9HACA!d?P(fb!4&!gEbo8AR@hW3fI-JL|eA z;fB$vvVUYHvgj9#AG|*x24YNjKVq)RJ_;$LWNTUqP2=W=3Q&C^`duj)JP8|R9tToP z3r5;PRvs?OP8nkDRmMO@CVeyO3r~z(D&b>7u(+P1N#(f`-^Z1@$Lb(+Jd-s?ouG9E z%|vD%Z-PiP2MMv(=FB<68lz+OrO*8@|7RTwxDNeq=LUdt1HiYa0f4_>y8dte=4tcX zzX+rkPm8k2i&aw;cs4V<3Ti>uYw@zl^=!OSZ_X3EkOfvPu&a6GJ;lJ5XX@r0$CZV` zbPo3wc<`Z8iq@}WEaa!nr9AWI$N6gog=GC&9S`Qn3K}BL4KFO`Q-CD9t)yB^f0ZOPt$e}LpBSAd&F{Um`#QeW=8Qa7_=cc2HSnq(Nq{1DFP!qK-phYgE`$u zTULOhU)p?Ie~weXF`)Kp22*0r)esoq+;L?<`7y{yTq7Q0;Ni@}O-eNy%KT>BV>U~0 z^;@?-)rkRLdilFSz7+Z;Jh8!_u-zV+w+5=i;cvjmiPt;0Cc?NG{2I;NNGVs9bHUci zfcQ_6#bV4;3=%;$7a2=f8-w&Ni~yUiOg?$o=ZfrG+szAhar>e@KK&v_1`WSbFIJqR zY2O$x)BcKgk*+pU|Qu@8Uv2d&l(nK-5?@0-EN zFbpHDv-3A^wred6&(S6_cjU|tjq#|1Z(?s4>O_R&+KSKDX&0yfLQx1C zDK;@bF#gE)k2s*YMipHG`EDpOlpIP>sZZaq$Y_9yTiDY?W?722<~RLQ_w|)?OvX^< zq#}9$eVxxq4-?9Q9CR3-sty1OZJ|iNQp$?@?1>n)tV@o9fN?=ED1uAxkx@pPDM}0o z;%rgH*w^j@H$*7>q>U7gCuAjk$}}rM@Nh&72+1%R7a-#iu9juVY$VrrB0KKr??*Sj z_W$|czXS3=xASst0C@Ks0Qmmm%m3Q9Z?^CGR|Qf=iWMnqP<)KO(H>(5W@_U&Kj|6aNJAq(ZV4QPE0Yknb?TVUG7 zV2K~|yOVScqB|w~H1idYk0OOyHrNFj+wj=weDT)0A|P@7-Sc0qHN5EK*}{tqnV5U8 zHw+m)b3h5m^0r^skJYE)0B+t&M*;|6#;C8gCh?;er@ay2q(LAo>xL?KcwJyO&M?en zB{RHw3mg{Q2So#?g`s$gBcq5+fAi+2_Td1pd=D|RvaZ5UGL<^KF)j@3qi@KJus_!O zc<;3(R@)0ClciWF?ZVhzil(i!K4*ub-&!C~NFf!xD#@E1bqCR8n|C1$hY_IH`Q%Xx z{(yl3sdn+`1-r0$(H@;db^zj7UDmk(`c@%b%m!V?aLpLX7+OyEm(o~qy-U>~AmY3j z7t%-GyL;2_-+$w6B*2eYSwLWv(v|;ExZ#FruClogMOj0$!~oC@jVK!(wGk+R&2}qf zp0O6roUQqL-Kyt^SvS1pt5 z3qtM~p=6+=lDHLd&lzf6Wsl%JgK$skf~-+f2G)v^M|Cz)-b;lC#Zx#-QN>=u@nrQ9 zWtwyKZrO9auKWu*BIpuDFg}x$m43+_N!e&YDSAD~9`@n|z9ZU4<2hGT8s{R4GY$f| zCk6oFFp`WB+J2FXs3#d=I2TzdJEPQ@q2r2tL?N)EVRu;8090;Ym!P=K@$mM~s0hAA6sD<4ch^vV5*^8HC@Vsbj zYK~n#X#7A4Q?}$dhCmv`Lv7jn7X7UTDm^dqB1_CDkaiD`&v?~Bi7#HLw_pe${&-(! zbUPUWiaCm(J$m#NyMO<)cJ=CqtxiXbO{DtsV9D>KS{Sr6Wg|ko5f%`h`)POs6HQo7fe`DGBDO%vo!#q7ex zX3fn`jxBpK7gyWP3D96^hu%M-9Cpt*N}EN|zj^AeuouSASyy^!hdU^Z z_@6^Gn8>;;g|CQF3X(@5MUC~#LLrN`0N*cWSDu@!%}ar;t7 z0yLj_-&v^whRzxzgom6-iZ$MA+PUffA^^vDEo}*vY2*!F0@pn4CV$NauR^sux4&Vx zZhiJ`Bft-bp-fY5hOy9`HdYCf8VWe?$J4o?TOjK+ybx9TuTI05*w`=Pol|2%ku|5- z$UrhTvUZqt1i&y}_IVKsY^+V!U$agO_~3r+?U|-nqb7TB{F!8=0Y*-T<9!51FDdot z_snQ7dC5!4t`~a!CUNJ~ehhDa;a?mj**Sp6HbP0<*wfjCveA)H1sUtLEJMgqwLe-> zhm2mUO!;*fk}I-_J6s{0k5ZqDuCpRY@SbYPDN6+*6#c<^Y}BJlrh3y4is3octVq_V}Dm1S>|>~VR76?jr@GXO@s`7FqA^BQ^=BLlj43#3|Q6>A?vLF z?n3B@QPe}W+ugkJwLkX%v31hFc5VPTHvqhw3;>4>0?GRypZC1@FT8f)((AuXt!bh= zt>$YkB@;?%+FK>>!*ff(AdCAN3|_6r&)!W6E#}#>W%6)}`~>(-hLCmczh@j)1PLK{ z^7ZT1e<{zn@j&;gWPVDn_srIMH5jRXT%n5ES6YhlPM|C-w?MrD8v97eL8tV2`dL#+ zy2MH1?WuU<*i2K}%tRJfL$G%t*vZ~v?x#dlqEsaK=GoapyL0Ck?c$}E?C9u4vld?E zJtBao&Bz%%X?2>Ekdb+f(O~=*(CpUYoHAhg!o(xU5S?32eaZV$Mw&1g8<>XoC1(f; zC6+w>kpeO00IXD6hmY#w7022Nmx7t3qb{LD_~H^A@eE2Q38Vkgz82VoY+ zgk?KJKlzv8&e|F#=|+H)CvHbalhrz4>SlJddBKjR7wzFd z0syQiO0KqG*ewYi$HgdK5LEg4dS48lkl^Lu-Isq99Yj5Dv^kIfZ@u*m`{>7hw2!Mn z|n1Iq2(%fyPO&C5g^spnJZe%+#X{`DI;FjbjYb zx^EO<{oLO|(bqjXQcz0TK2}Rn0QEQZ0;{pzjbA_vP?u4-+6W0mfu)ej5`|e2Mys@a zMSelaD`^eo=`z}Y`Ex*)sgU$7Ar2|4N`2wl-1gjSxjkCeNu8NT>nZ#S-`Ce#y}HnJ z`r)ALC+Q_Qm%A@!yr8Juldd@+J?HEiV(1`DwC%ofa2gWL7?QB%Yq0byM%WK5+}TV2SRj~p;TP2xm6_w zbh(rB5uP>o17s+8tkz)ve3CbOEq&f0wOfkwSuI+Rpfj)_ulA`Hh015oeJT3MRp~t^ha@c{M5wwp>7K#(WkpOF@KH4N-ZJlL%a~8aa>f|o_E(}vLs&E&aqAgKR^mEQzO8G`{d4l_~Z*Vw1Entu!R$ZGVyHY|E(SIu>dh#dtVJ)TYi=k<4ResU>F@9wu`8w7x|F*(E1yV#p z5{i1*^|M;k0k-ZH&skhr3Ma_(E~`#A41p-&`|C2g6J)IA^-7bhrilzpms4;|oS%;5laq8gy&!oUd z9_@Ep9Hwv0`E7TPCZdYk2A1am*P~BRs6x>9;>$|C5BRvHT*+~-ejm5&se1hQYj*$suiMotAM7Mp z0j1^+;!Wg!IZFb$^W*#I8`6I%mq}CDmWRnMohke^aPtAMe3oupTaCu5pi> zX#;s7V>%R*8O76m5XB8>cr!aWL5OunxsYrKW_EG&ew*8goy^}b2l|UDAnywA0EVQ| z1{GY%aa@{}m>WjgMAm~zhy`=Zcw9hyvltNKp4|xGe$vAM+%4LGV#u1^k4HXsVH7VW z-eFHjm=53eh9`>@k3l}idavjj>eBrt!dQU#vQFd}_vnVPH(>;L@#WIdACDgn)JGw- z?85fNo*m#(CkDg-PN;?}^K{B;Qmi8IA`Hi($BMH+1`D}nOUMs_Lz5{49aR)y5CyzD z_wLPQCT0KX)}#+fd|CgZ8Pm-^5Yg{-Wg#Z?(l}hY>e?BLqP98JiI+) zL|i9iq7@QK;Y)Gu8>{``0N|Y{yaIoVW+!0d&6FTml%J# zZG&Sqiq8tfoPRY)qtSIq)EH`-F3kVr_A#&=#Z0r#9 zBZgGsv$?eI$H*iYB*!r^sQW=9zaj;*XL-d7>@H55^^|tL!U^T#mDZ|(tE6G zMih~KHD5l(xr(_gp>&5)18{*v9(>QS{%ibC`Po2CN(PKt)^hJlMlj^Q)~{W84l+G( zT}#FrB|FaCDO%KM#eHLLg!AI~*KU6OPyJ=!{^tgOa|6J;(*U4`fOlX3INH28J@?{2 z^Pf*k4jX|I;wkJ#n2*H-nKvJe9b8oil_A|&c-K{6lt->-;a@cwEIqHBC9JiSwKf%j zkLBy~#=g#Eis$-pp^pxe3~DJWbsZvn)3$1y-O4>=?yQ&0Vk=cF^kaqtlBQ8fc2u~o z**lR_7b0A91MzHY-;DDl;{dY?kF26T3inqpe?WpGD?l*FTL@XPX~#2fNfp>b0`A-G zH-Fxaj;`3HORseptPkZk;=YTG)LY(yWxFfVwW54`n5t~gve|Rm6UA*Ep z@0y*SR5qt+A`P9p!C-E7slLyepW4ZMBNG%MP6_|@dm^RxOgV9GA5=t`IwmA$YDBTfyfU%+4_Q>tp^VN2{W{(~;;?YMM%kAcQJDT3R z&kj(Wd-gBne-|S#{cDt(JojqIQsjn&#AVOFW(UY-&=Q$PSTiM-d8~Q2@4R6*Z+^x; z^pPLjzcZuH#0Zo7QqeX&Lj)yS%fG<<2lRfPNi#XJ0W=IkgDa%J!?CV}@u=4kp-a); zQp_$KH55*a)S?75C)DPZSKS^yobBOT#pe03JwEv=O8?%%Du^9HD`6B2{ssjeFG8_B z%L`VPl=sno{f%R4L|2G&Z;A_(VBOhF{l=A8%<6Iqa51B`Ag29d>`I#m06@zJnJhC zt+Jr}=kto?LpfYAU*pJEdS4CQ)cx*YC}~|AenB#7Lutcq_xKlXedEvkt(BC2?b!_g zzai2Co*Mwp4FK;v72rI-xqIo=-}zrV?4eF$iSR~2PEU7(~3g=~_ zKJG)on@F8&tn|cUA0{S&I1sPV;rOv!u%;g>v{TtK6Yu!&&TXb`Ts!J7{qOKTXXS9Jn{j_-dB>38<_@Z2s+(>s zC=t^<-kpg5ourK2@^eE4RhyxYeZ*GOH~J))5=>0KO zpALQR%6^{O6|zYW9)${s~n=fx=C!}zla;<#q1kVdW)gTKIK4a)0S z7{9ET9Ubk@yY|9lZq**$pZR@s-No&D?9zpod+NA8S3E1@DUGeu#{#hp0?4&JBQaK^ zusNur_*)!$!Mx9Jp>&4s$$W{=#_qlKrrm$*>u-kx{7BE7KAZr9&moV{{8qc7DQ-=??V83ow~mg(&3JW zHP*=YzOu6?kDEPwSna{%UnjiGDrmtnL9ST}{VB$V0?A=nk{MJ1RYTE~GwUQJlrS?T zT#9q>LbJb9C)ohpK;#t-<*XPcAQN1dCio*Cq>nYN;v^w}hx8-}lA9=e$PZF->kft^ zLWZg#b}NlZA%={HCOA?hbG0mx9IK3+z25wt!W!gv4b{|p<>y(C;a60{x{~dg)LG^% z8G2Cub&k5ZzZ3nr*!(3qwXERzuU=S6u9`_c%)uJAo%)*O~k3z*~i9|@p6QNSb=9aDZ$&=#dw8s#_ zv9`A~s(V;(B`ugj=$OM{v7gGVwWzyH;gGE=$n%& zZya;0iBvJ*dW4w({|?32%sRnZuams%q}g*60|Wy+{)Km1lrqh=JmAZ9GYl?065?YVhn8|_^L}f7(o+OxVQk4$qJmVD_nhON zR0ll$)H50D1Tp^OH$ejMzHAhA0t53P20ZD+fYG;vWHDa(n2Fa_)2P*H6X=mp;-!!3 zG->L=X$s?lK>PO!>Xe09VXqFaGwtL&z7uw~JtA!7+VlHVlTRLs5IGdh_t?eFOZMQ& z7eE4llg?p97y+_jJHz^mf`??KcMT;{B!H%#%)esd7*3b$R=7F2MYo}*-|JLmrf9V%; z27q$|z_|h7TgL#vZ}m_nfa~A(dw;K4eLiQJD<&^khPw+pb6PL*0-lxatS@UGd=`?- zR<8n8TD-mKwfsXVNR#>gSU3szq~vyz7n*Y=uC8#tdXTm4u=1zVDgq*P#C1wMh;W|! z$=o@XAzTPEVlv608q$eMR+!Io*}Or`lX?i5Wt1FTySC7$7)fX23y-5%zUgN+Ms2y? z$~~j_^eScJ&*3HX5f^55X6nM=HAyqD*J;`~`p>u~ywKLd`>(mEt#)Pg&YfSf-PuFC zcJ)Jhj&T>|7zLM>XMvHln4aVGhBs~U;ngw2yH@eg!4w~g2V5D97isy_E2Qb1tVEWB z;en)nQXU590-OZVj_i@X)QJI$ou24g0zsmY60(?G*?!RGc5El@7GtLj;i|x|u`!pL zmntQdl+YFg!Z?|GL@Gi{MyG8cPf-5DgJ^Y+1hBJxc7Qr%Z@P>V$3gUbmaqx`T912tdstJc~4G7Dz0PZ;yyQ4J5$a+@3s&w6j3dM>s$y z2JC%@;UyVNT~oMaa@Y77xb_M|d|@4C&56IcH$~%pLoqPkM}+%1uCRM|Z`fP+zGmO~ zUH_aAgMwUhnEeT-TQ#tFb9u*#ks+ie)E;^L8Kdxv#zQ;glS0VW+`|A6+##X(cUb&1 zj7Dv4y-oAndU(&-Zf7Sar#5Y$*cZR}l{$+2X}0%5T!HoOK}~9WybXBGe`8!(l)KTo2iqB}RQNQv@O$O6<3+z1g;$ z38>}KxJI*Kj!Y${W)KcajdEg&-MBKIn*T?VL)wqKaRY8c5>@4 z-M;x(KXE|W2daB+05~@Qyekm^d}{tz3;?hH?%!^uzOrb!lTFAq^NBCVT*(GX`XPiS zH;Fe?9|tM5QlTDKsCdI;8+GD|fz{MF-oCkeb1Wt2M0>a%p;o(0+34dCeZgWJuv*ze z*UZgy7|$e-)SQ}8V&RLxsvS>)#JlI`(~UXCg?rM!v2&ud!DXPS_d>ArCh_76^TF_V z;GNV|Ic|DfA^MQu6|w-Bm6d$H8rvC}C^Qc-W9!x=o{fzyDQe6cytw4@htYN@@+A~D z`V1w=;(ch67oN=?rs$ANmmk zizg)%z}cG+#TC5oD8}ogNA$Nh4Y7!JXKxQI?_$Iwm04AT;xLF@xwa<;+?^tvSIv4N zSxPQ#K481~QRf73mXB-%V7{6%X)`#HPDyi7V{t;DgHM8R0LZPHq6b8HkM|Zu*M%7H z)Lwn{dxvtta~Ab9D`}&7c)u9O0qYimH58XXR(*y`H~`N;KJv(bki)^U1QO8ZX)}mE z8;Xmjd}KdrVWb-w2*%&h7mx@}H4K5lQPM*IGHMQkbDY0d{?E>Kb~-*MC&xC=PwaD_ z|Du{@Gp_<)@rnxv1efyOM%Y{C9)g?~k;!w4+q8!ZvLuun3?!uxBIKs~ZhkH|dG|z# zq9}JrTrS^hvMDH`98EqTw*U!YQgG@@QO*$7G1ruq=AtMf<7gULVEs+Q5C}P9957yG z!z|%~1({_3QAb5aq^V(0`(4ohthwu&xYHh$Por zeRNwZ&b5ay6GPTuj6D5LbF$;|^!RIk>du>g>+|bM|2YBRxdGtaXaJDo&u##C`L*Bq zZ92bVwH^cHp85boK?-qs?7bvrqjFQOkd+Gfg zW`SCy7v;Xez@qoh?6gUlO2&k|rAWSh*FG<%eWz84LJH)s=&h+lh$szjT5=u;d{KWd z_%a}Rj2PgUQsmwrDTk6m z=&mW+1f}C9gr5;(?xw_e5lf6PyXdKwlz#|i&e-=5g7Aot0@H?P`0|y~N#ah9jXGP) zPKUvcKik!#4_NbKJDG2(a#9>ajI~7OHc^{ZeuK>z;HJDRu$9fmt@OED%^d;v+6&coXM1A6lCs`xrswS9_LX4-aE!u<&w)P8 zYZs~1bZk*TGjlqb9?}vC^E!Ph%IrdDfknDQ6VlVWcW!o0fRBFrKOMRB2?H6%84sQX zs+D+hVEW>j8|aVFi`YL~1F0cOSFO5!!6QOuj#BLCXg0!#<}g}xg})y(T(?mD zTg99V3)df+llkItj5U$VQVIaszj(fy_h=I))jth=LUdpAp^iU0w62@`g!TK z-}cX!dh}h3uPga&%`JP=H8l#?yTqkZRJiK}PZ0V#Fvk4y=j%kEQ?eyRAw;`SLV>A5 zWEF!ysUdA<#U0P*iJ|D(?~W>sQ?MJVNZlN|CzAg(~|;duYec}W16=8xdQWU z^NzGxqdn0t-Wx>>IXoVCGW%gr1f#v7*YGM!I6#}t9zW#S+CW8Jy6~c1KDyq+0h;gPF6#A?9S;vR5Bc6xHs4FU5!_kTb6>Cf2l$!sQTxMespAvyLRhS?`1N1fOM zGy{n`CzNfT_k&cL0_Tt4wKxbYlv)fGVj7*g{HtMFB_usjT6s=n z#1(aq>yQmVA};uVSr;jo&?<5Vcf^?N(teyE$VA59T#|%zM_bumHVsYLvfBWPHXMgRJbA&b1b{}1?eFA zdy#aS_wWAn|LNiVU%tEM^*=`doEren4FLRIUV8Q4{-LQ}`2oYji4svtR>)$Jk((#q z68S`R`@%4QRaUaIFXjFh=p-aPyt0c~_k9JfWGGe2>@1KeYSe`&px$nzv&x>&BAbw= zOv(y{D7YD^py_pz$FIVfK-aLKt5P9Z8y#N(G@YRGb0>MdBm zJ&s3ieTewnFscx@Z)MF>#0&^UZ7B12Z)@F*kPd@Y9D>m>JeUn-u++G|MLkA3yVgm{ z%sn^(`c`#%+z*i%VC1o{)oi=nn2qp(tJ@FuaDe088|LOR7RsLODp-kkH+x+$-gy~w zD|17*!5vb!%s0Hw!iWvKJVqwwP_vsiKV`*cd*%9fOJ<0Gdxy~{j3=TXcqn!Oy)T-x z9Yvi~gJr@vn_^AH z{hD3AaNQoBd~VF;Dc*eq{PBL1xg*2pQ9RM8DIhwkLH6Yd$u`AbGM;dWgi;g+Au^Bf zTCrZ-yL;2#dYT>J+kaHx-eL5MCT_ReNFy4dT^ipXWS$_yY_plnNB>2MImOakB4Y<0 z|7drMIb*{hc{(yv;QepSJ7Q;^o1LDW?Un!2Q`?=L*>1P%27u3e_EYx8&9}yyH1$tF z>1)|UZ;}OOz_mp#u&Of1A}VZ3drYj+iE=O?;RVm;a%$o#Fmo?)|0?g$;8P%DML!l~ zzsOS{9@6*mf!s686@zFYJ#Ur#^*D#j6^RyxO5{1qaC>nCKmxcb0E@8!`xy6HOs9wZ zlTo6a%k}Jshkf4?;)AnC(U16U^m@UtyNl6FL&8#KrTpQ(xvhGA{+%{!cvY5_M;(Wu z$BO<{@sJEv;+TszYM)6sm17LSXexD9LFC^0+8_I0oSeLI{0<2K&-nn(4FKN?1^~Xl z_vL@{JGa~C{{=HcQCx$jlfFd?i-(JcoT99xo*~c0@I2(jsV1HP++x}Lo{KJEMbphu z->RirN&&W*A=a`;m#or+Ww_*AzdVBWy%rQ#)7XA9iLtiA@7YSfh*FR~(rsZa?W18J z3EM!n7IXiUrJ!WbNXD_R#e|n4a6e=0c%}@y2b!)5XX1CDX_*-~LlxE9EygT& z1cdmWp^_yGG8{+f>V`iOaxucNlt8Cz>gn1`25?S&)JpB@3&HpB7c4` z@*@b2^j%^#im#nzop&9GFP^-dvN4qPI>8FBZw`>-79L{e0eP{Dd6#+)Ar4UiN(chBqJ+}b#|)Y8xGas6`}2%q7_A9b3W zO`FL;!;LnS{rA$d0PF%e(CgXl+EdSehx(tKp7!r&Z-3^qU041eK77l5%iW-$oGMN;j-1wmT5j3yMNU3vcF9IkT=L1YKel%i_n&kFvc zjJCkptk(z;BZ@u$vSzNQ;$LFdS5%sY`#H*;6zv<8gQAFIV&$3T2OVi8f@JSdMOm)I zA#pHGF=!a%&lMtlC6T1S7i20Q%f6_6#yn*0SCoi2sxlfwi4$e)bCGpwCEPP3C|N&N z3>0#1k`A=L2v?{|HV4&f1L;5Hvkgg+yHQH063`puN_~_0`TH1zvY9Q&3pg# z5SS=gAZxZ(AR9>*S6w6o&Pxp5wEFJhp-(B<-688L3U%a4fvAzsmy#ez6))&|yp?V? z9w-I_+9}WOiODpR>Ex7lZn@M7F(I|O{$9$8!D0%y;t;&`+6?I`{GEk)Nyq25Ry>b) zT#u)y|43T8JWn)4g8M6TxLVY8qb1y+XgG;7%2y@18w`oPosiYVxpn{1csj{PJPhC|YFmtGJT#N<9YTc?*Imion#b@ydVgw*u;N;|=J8W31#)p)_dh z3jb!((+qpGAT}LypE-u25zulJ=J8!Jf;>e6eA=v>*~_ndw`3@Bye+CMFz(V;A&iUh zhkO*nLNX2%BZEW1DB6#OBAN@tjdgf;hoQ&9i;WV)JS$-kGW!pw2%Oy>-3Q$jMy73j z&MrPR0-S!%+;?V4m@ld7YYEG=-tbY*w?V@)SwbaX3hlq#B|IwV}mWrG%D}V zrj-)1uQR+T-Z|wWVO6~KN@l2Imp%7IQMh=~JDO)Ti$uxJmP``3Dx*?oF$~sOWl1T4 z*Z9OVE#xW(0+Jr-YunA6U;ktOkF4OIhXI@$0N&*WfOpOV@WS)|%u7d?UjJ?SqNTj@ z>kZT2b6)m^rbA|=EYs^n8OEd3*er*zCz~cQ^g!ZP8Az4HDHn}jWs{HnMTwXo&yubH zW2cL(z{o6bCI@n%mK^7=b?{vJix@E^wIP46mBMr8lIs;+N3s)$(l1u&bSW28Bm_YE zr2rY@+>+y9DR4DVG-N~1UA26(S%t@5Q}HA(QH{x8Q7}A;nfSp(YDD5h0s%k(Owwk%Qp-5#FH|ffn2zJ#cZC`?GMr6c7$gwA44{`n~CNtS;x8dWhu5<-NMjxy>9k7k_%pcLv; z5>TfHBOIWaJ$XdW24}^lUbD+buiB%NFIqz(v$Bu|i>o!qJAyGEWkGXVV~sh(`^vq- zkSO`N3y!W5?@S_gb>)J+@x~YJ(udQ#dW{S@HYGe#hVI zC2v&2ht-8z^Xc3WXVxTb}m5P_)feTWIPHr!YELxcq4O3zSmq-6<>kQQNJpW zxnx5IicQhh$n~V%T5si&@~rJx=p&%%@_SZ7Qa}0fB-C_tYi(H>0~Hz;?(brO$1@#M z8m4J?RUR`0u&nG{dQ9(4|AptsK)blkAKffraNeKR;_u{LS|W1{lSM^f=elGdQo#kN zZ)}azcn=z5ln6F=yGM5W_RraF_rR`R`GA?%4h1iP{wbQ$DpJ2~w_ORKZAwHb`TXPG zX>cZ3eqITU5y&)Q79%8KZad~|d$;XQYWH}C zGL#v%`G%~HYGQu;36S-tE0=OG3v^Yfaj`+4_i0-{e$v!A~LF1<$=O_1-ytJiO2t3c7LZ(uJo+fX`a@O^YOE>De+-iBw+t_c8O|>g=m95Pp`t;IdEpo=Q;N541UNpvV;}tB_ecNi6?;NJ ztd>9qltJDX{13Bv%nfhB%@M|yuAq~045G#nz9t46AE5+2#(zNiiyhn9ZnpU;0$|q- z0R6M;x&EKnuYKa@?AJg0Ios`G-UlKikPslg%*dc)!yx4;eKGwFPX+0P@xF?}N{TqV z+y$d&f`Sv0qo@Owy~MK%e~p&1u6kyY_Obx|Kmxz2xDwt-34{c58l-2gC$~#b74%i! z+d(uaq2X$J*mt0Gv&L484Vr#WA@ZyD)7A8y7U!O{VKp07&1+yHN#>Zc{A*SUo=D06 z8guO6GbwjNS$(z|8CtS2bbY=Yc07SZKn9idKjx3OlTY0I#-I8NSV1uatb8~9cfFh& z2hI%uzmW_8_N*j;+oKoCwda52KL^Pj8-RG+tgOSw-NiatQ2=D)S7ji>yIBbOygCHx!G%>R(dhXK;olC#}Rn1GRk5+qBxM}6$3>9P9{0YA&`NfF6Lu+qfrtj zeMU!dq9+hk);2(3`v`_GnB#$7@(`3&--M90#Sf#Ob3SQ^zlaluQ9kYfv=!mAXsR-l zydVZF8?(A)9|V$Pj~;!^-n{j5w%J^^OBb$pBS4UF_4zs!t$6lQ`tcNEkr~>Zw0_p% z_~P~0Oxw5yQlD|Wc^e!Ak+N(9S_9&w9&cRAK12Y)Qw>x7oT1!A{k=VeWDK}DbzFVE z_Ur(=lL+^4739LG(TT*0UD-S}0z9&l-K{|Q;CUb60Pbh&p;VBiVL(7pPbr%dVKDCqM@4MdSS z!uRJ#&i`@TO20<4JB<0%=DA-J=!5?I2p5R;=;%UkQN*Api1r`|Y;z(8toyW(Pb%K~ zL5{hpSN5C$$Dbn$LxNzJF_!d4e2)+S-gOivi~uuv0^~?d;h})EDJV^Yy@*l@HBOG9E&E_^6M4=$+Y6-poA71~R znV;GWSWh3zHS|HDqeR>h9UX4=LxriVcreS=`A(Jag7i|x2m^k09X)0V<#{&BMYR&}kXXv}>M$W{EU=J(MG4w~WR+j3O;LgUmu`GW%ScAOGB~ zH~!4uT8aN(`rFP!0L~2n-wGiBt0aI=9)02X`nUbw|Dsu$@B|mhBfM7Bdo3OY6v}Z9 z@lz05<+7$oDS=Ev0hF{rP{h-&_3It71>9W)+3fLO^Hk?JUC!4lloZT4CNb=ZCsDM-nLGUqeYU#&ek}hRDliM^EyL_!$Yfrtxa~Pc%%TOYH zK%q;qYZpFjr)O{VZ~#g4#@rT2cVca3Jd_+&&Sx&gdW~X-7dQ+LC6P@MYs@{BGoy0? z-1vl4IBQr>5dyBh}Bed>`1I}6yq zxq2o0`EHR)5z^04yu68`FbD-_o9CyWHsbmF-^0^?V%|>1@4J0&|2to|pZ)p2V_*I1 zm#y{Z*cvG?Ba^}~wvB(4P_x2CRCJGxSCVWD^mR6+*BaqYlZ;l%oWj3x9AaV(AI!W1 zdRs~LS<4#ZJ3J;!MG4*5FEf{bDqvdFt*}mF2jxu3spWMJA1>K}k!7=IFTVJGszPZQ zDQWMxX#3>5^l<_|ET&nGf}H9)zoZmrW+ zZ-AFanf9MrRY6N_!h+xb=6%a!6lu!J#%y?W zOCb*2fG7t9HXB1E$MosxTRmmqJU_O}7hg9cr@jH955g`KGMOF)$e}xgOSHQVFKm-} z5Z{3l_rJ3TWsrtdIbJI%hoY`PiZlb(9J$ZC;%FXW1^wE2JmY4wzxK+tsT%=Ko@k8Z z8e!&@&I$0qPUc(Y(jkpp-q{EMxUe6n&6Kl0^W| zwsjBb`vpdKAw?UzUkBIdQYrapsxOSos`EZWTN#&>-j;QN_cl;uO1zyv^q+=;f;fR+A#)HZ zuLs|et6aI5QvX4=x9T)k*+{=r%BnsA>S3MRLBhbknGCVm*~uG!?)J^U_KCw;0L~2n z=LUdxkpbYHp#b>2{Mv8-H%i%lTe`(FMp(Hr$rqFOwdKWgC7w-q1=O1y0+xG9y(GC% zS7Xz-gD>abtt`T;#^WLe4DJc^F6YlDA#cs8Z?t_PFeDEfeN1z?T9yA5k2@O(FX*ZWn5)NgEwzl21%)8W@0oC{rp{s1q@NXrTg2mvLs{QcyLXGDRm83CgxdkG^8J zZ~ttEx?Z~Yx;gM&D|MC^j7$-lUe6q*UWG;8561u(&qLOLsK|%s*nE)E6%k_UP)bzk zcHUJX*s_6Ws1hbrYlnsRHf@hKac8@9#cVgbot+fJi&&B{kN5f7(MRq0?4F&q+ZbH% z`r$EKm?QCbj1R^|LRa)MjeXCd11J$OCs4GdqLO$CIw!!VdUk;8uYT{ygRcaDbduHV!&<$uu=rwa=skZK%nAbiLJzvAhYxL|4z16eZ* z!=KOQCCEFs{rLEeeiuS8w;MOU)Qtd7i2+5S*OZ&ArL_jPgCM3NrC*((4MIfV5HsALdp?rf&}^^7N~Q8?p|sXOvRddxZ9s^)|-&@|I~?^~Pl~VyR~- zb#cixFt4=75<=9qAGObPzYWKMq8;@7gQ2okZ1sNht!4z!eq`(t2NlKP1nqBY#E#`G}p)Zw(^=-XR~^ z0jgD$Zt4l8wi|s6L0ZSDU!rTG!oW=o2Ow7pO1{?xKJu8)2$?r;eA0@K6oHHzp%(oR z;};;;6ObmHkS4UdTxOTH?7F~;HR=s@g7K(rpQNjt( zkpNE~(tk1bN1K5JIQhH^>$1jRcsRI*dNE_&#F*@Pi+4U`J5f(z&L99vk9t|Z%*zxz zNH+qUp5E)kfTHLQgiTHl8O6jv;Nx5$=m4?`bcMf@`ki$nz}Z<(>DRsfod|HA?bfZ& z+0XynU$L)$<15y@2*bYt@z)scJ_n{aF1#svPOP&&i~>^HdADnqQuJ`Wz%>Uux{g2-{K>WA#(eh6iH^8|8;JlQ>27rTE08swZ-|xNtZ-37; zUHM_lMZLu2TUK~e-smI-lk4rkJ695GYupE~3|HBK5QUN%YlFcZbV6D{d(#<9XE9Nbmq{aP_|71@^?=tf3p3nOyHsQY@ju@T(|m!8|{W-(85OfC5A8 zqlOR0GRXTl+{}Ea3Bwa+K8Ik8VqoEUMv*9mI>%=$1t{%i2-c*jt36x5ojbp1j~{>4 zE?<7#w%d#5K8F_!PgtY`3-56MJ|!|C4>-5AdADHHFiA7#t;N5RhRzCe%v&SpH1C-R z8JU!{MY{(Seb02i_ZZhI^Sr~+I}%{m?DUE8Ebb86`5q}?!_$3b`(ZoX-L*5nJI1N; zzLA$5foaTLyeI}6`G9P@wLS-WkyMu+^8~ran`9N}-;0~aH9<0GQ@qP6@Qpci<0%rr zPwnOF-yL3l0vp;K=nIRY>s$+xP9?CCoaLp%#iKCB7AXj=K^&L7L?-apIJVtdkcZl| znXJvNE2Du(nPX11{u%8884#m$?fKfr^!QOIAY^`-?5bUyUb9E1pNlnH`@h$@!RRC; zq;VfUl1W5)SvY4X>jC{oVXJ){-F9JohDD;pwMEcjZgW8A#nbx68(*;FCwJ{5ANiq} zdjaL$M@rz#->bjJURf$323l0% zrYJ%v8^3bMr>%!ACS}}hrEs2Ev{#|^!yvtSHS%!WPqy=TC?(Iw)CaWT zVC=pL&k|?)AsmRNeWomhoT~A~K_`INtD3e`<$U)3H@^IT{U4v5-8ow){o|NUit}=A z0C@Kq0Qek81^D92|LTV~+ZTRoVkOlZqnPs1T&lY8CJVBX_q)O+Gkgm+9uz)>VCf>P zq$-Q#eaMI|5I=-b@>HWF=eQ@VwrfTM3Y%(X=yms%umzrQ?f5z%4e z6mkum59-~aG5vQ!!U+#RsGroxR2qt#gd{kO*pjn4$l#p!+G-XML~$%)fQzFjnULUB*x&7AJ&s)`~C~m^Uk~;ZG88fx;;N5>rmhxLXLJ8}2cbR*XYo zG#H!!c4n`=_Wi-|ABH*N@2!v%o_zAk*U=3x{LQ+JfvB~aYz>TOAP6G+!Z0%Jk9t-^fUMGUCBQ^JGIl3)53@k---U_Nm^ zSsRwcI1CV!e1N&jUMA{(+$YvW&9k4mSv0gTAp_aB)#(yxVV5A?f>^70zG|NMwUA6% z*ZK#Z4FwOhX)jx>>;3im^;%QNMxG}| zm?}SW4d1zi%nJ-Xo_sT0$39G`q;S1x7>4x4YWQk8i2R8BjuWlD_0=!_@BVk|3P0c9 zt?>VI1HicfK#xBo2f(!#fAqZ96LW*L#`J1a@BaSWMk+S`r;|kAGp`k5@SIB0@_t+sa zM34SJ(V#8BPbKUiv%v#L)3$Qyl6RccVM#e-(y;M_2Z2Fz8Gm43jCG0y<{o9C9l)s7 z2tXUMz@%_rOXNZ_$5r5B`c8ON4S`ep6nH!PE;-ag>5+L-r_^`-;24jF0ZYd~j3LZx z=bq!dQVeEz@d)QU*wNQhC=Sew*x-xVgZp2yTep6;AHQ_*x>=B`4aj^b)1k<>k$b-m z2zaCu3|RiYs3*sVQDCZ5^z(p>gAN*%#e1BIvEU8>ZDJlN28jvp|8Dk#biGZREgsrh zH!{aAUa_v&96uV0W$94yZd8b@1T&s9yS)8KHv*it+vb5h5${SBqBh|R;PsN2cN1QU z3=LJmwUmk2@KB24cUiC6P#`AS;23G1h68vz>EQt3b?^PyP@Zb7DK#f(jy8^mfpw&p z8}mX?U$MXR-|(klSg56reD!lEoxM#_*Y>DCUnlWv(Y}dyb+g^<(FJa?7f9xUn7gD1 zoH`QV@q>P!uKW}BaMdnMuiE32&l|-sQ1a@cbeoG&Tw%RJG{?9nV?b2Q+~6M{ z9!L{w$76A>D zLZ(cdJ&G1c#ue=o1}*ARGT=mv=s3QxJ&AuS8nV+9pPX;SSZg%kyG2+X?3hC&$i;mP27ryW4f*RtRtRC0d=?l`Vi zV%ia#7Q(AHbG-mda76*mknVK?A-+e!P}9WFlw`{TCRUbc%DUh1brzV8^rKm)|Pv5+QC-}N3I0Q0%PbOWwh3tsjR0=PPl~lBFyUZ#7GeI%_M>|wK)Z(k zhqjjT=@(Xug4crwH4$o=L>PV&QYI8V1luvbgvL}5CmJ?DO(Q#iw|#bi;ADWUDd3_E|HZMY(j4H6x6}bBI#f{~~|(%sap$$yyMh zG%))sVVZdGHgTQa$4DD##rl0pDBRYD5#Ube1ju=~jgY7w&mU0#)6=2s?@mMc?`VKM zYGCg1{$Kj?&)YBl;-9x$x4t}<;~=~YfVwte}LYjd{#nq*t@v5CKOTPVb%Lq z=JoLk)ItT#BSs6?tRVf9`(TCNAYk&O6UGsgWoWlB^pm;6@v1Kp0^(THcRa4kXcCO^ z@yW%Fxl{?+U_N9?m69Jn)!W+f&)@pSpZ+V)JJFaMy6z9WZ=uY9-L zH~;$AUV80!Hgl^hwuhDXndEKxP97dZy{k6foqB}4O=CJ?y3Is4Eh{K%1c?f?k z!)Fs=3(A*ywd5#g(PO<-=o}U3qMj|>;+;-u3SA$-ivhEjhsqN|`!W|&*CAMQ++}<- z0iz+1FDu=|L5gyx!YIlYo7K=AG)0YiMd?lYF2~J|ihPz7jC^;pGswIuONk+?=1eYC z5>fsht+_RUM;JmF_}(Av_pQ@x zo9WEz(4o-vG?^UdSAKqyX_IrEf%D-+mAN_TE zy!*T{*KWM2d5r(u>#)*>;1_6t zE$hYbQvKSm{uz7p=$?J|_x$HEbRq42BJO|K>mU1#`P8N z{djJTP2TOg5(W1_ue(LJjbr=YFiJFxbi;TXVJ+dyc>cv|4`?eXIW#OwFI)ldnGRF+mTKt`s7VDuNmXk|Z?{eqNxs=rJ48R;^0Q$W$?adpqv z5VIVPud)f(XXOh-f~%iBZ|-IAJF ze`QtgIWO;O1i*PJK>gT%)Kq};{K&6=+wb|EX5~43CxtOzHWnJ;3$a(ipt@=el!cEQ z@m0>#k}knoUW$5N<(fiFHVY!hl9$%ql`XfDGh0cXQJy7+TUgzf34QUV&v6f8F!dUW zx7;UUq0P}3>v2}5sSm@`J?@`F54Bgf%@5okj?YtY!7s(>{37I63O$8EE@Y!{ARvgUJV_lCd?sQ=Uz zLYMxn106KCCTZ&!XHo>3xx|`h!LE%DlUnm416{?-KMkWmExVAo__qizMu(7JzG`-I z(yZ+&^;U$h5NrcEbLlB3K)Y*ae$O1QrkQxjvtkhNS-2&X08Vp*@T?xn26HvuhZ;&$ z<2sueUkk|jB#x9g_1@ht*y-sVd-c^HQN_azg&V{KG5si6*lu&GIXsJfj<6>QOh3G{ z0^tv1&e67pF+yV0ZCuBgv-XRSO*=_RIR1L>h1#=0oF4n&e!$qcovzuH3-9a1fU&k? zXdvBWu|V^rcionWMv<;dqfYMc74?*TcmTwK%mdc1mSU^n35nlIeH#Ap~L0*O(_@oJ^)f3GFd`|Ym{=s(L+uOIXW+zOHck7Y^MR}dq_f0vYM7X_*=C@dMF z$h#(J0gwu!XlHapE9{U(?E@A&S(` z8n55g6Rt#=mQjsL2*|T(n~^UB){Z<0Qc9yOP__l7F+KvM&$6>)VAS?i+Ea`(@Hsh@MGp`Nd)mI;$tz318Ktv`4B=3o64h5tWu z1^_=d7@Qjb-Yo`zl7G+4|9m+9%B#QaUnyntJ|hj&G7(@}P%T@5)56Wd&WrAum}*_o z*K;XraI%1(qjX~dRYE$HNfbt>j=VOoSNlq?{gpWQ!m^ixG%3CUXV>RPK?GLEsf#dn z7FYg8wOuKazZb=PQ`G%nc=V>P@M2wo+?X^1U<3Z$#M&XnBwEzgq0%*lnJR zRdU8lh?B$SZ}OB4__*I~qy0 zhcN~vPz8Y|2m@oFR0dh(9P5%OAP@rFZ{?Y8QGqz~{mUnmyck{a;SP z<(7?L8S5#ul873MTe`Pa{xG2@|eDc_SCR{o>W|Kr}BANvXqe83QJAl2V*Tr|PC0pQ#KaM=5wkJlHk|9d|))l0uw^2Dy) z)m(*3L8)aoLRo=WWA#>s@3c;?K2BN?S7O3xa(HY7lAQ%;+HASF#yu&4omyBu5#PvW zsfruOrxmNJFIi+$YM`QuVUk4Dd%g}uNN`6izXILYI60~lD|&|To07*_+NfxQcmiY$ z)OdsPN?e|1=+o;m2SA?Fk`gWdsK}1x4cFI$bj0(_Sczc+&p$s%)?qj)Q0J5^v8X3- zflZm1`6;*@xW6RVmW0kj(29e*#@FSGDMVOq@F89xIv;Fwd{feBfQNc z9UE69B+u7-DyMFzLf@m?=^o?TSo$tq!x4)Aw z&H?SOwJYfHH}fEj1-)-sF9u2=j0vGAY`6P)9iD1R7>wMsl;C3-=hGHaMux$l1`?nf z0osmv7Y57=M=#jL&1>BV(AsV_zq`bb>yN_S;zb!DCH*05z9?~`*gBqSlu20f#jrQf zBcDtaB}jbd;Gn5u>af5Tc5UH`uLxJTk-#;&ir3c_8|-| zVN{Fnli4^H27qD%slsvAyKya^|H6GRFz~mbB&U!yMK#sT*q&vVOx9^j3a!WjuIcHr zti;fY_dYVJc;40-e=oA{W)eQCbG}Ly{GA;Vp5(P!a$ftT^mdl-Bj-s;koz#YxHvs> zJBuPUxgwvHQwB(Cpdc6ByYqMd*N+~2;{FQpAOF4!NdV6c z0Otk(%YPp>0_f+(SN`>nZ8q2bIpAR^&^XeuLK8#YP}cHZ0|A;Gt4fv^cZBI#pEQ(~ zu^1^rA#Wx{^4~*lRV!Y*fKvnIllI4PpwdfBvvm?Ov1o@7_rX4kva%Gx#9}7L&-}lmXa)(qg1*u`wP3blL~KaVvU$?Aa2BG1JP5XKqerP z><}J(Ys5*rZ0e%x;1PqA&00>$7jRq(^AjnFD^ zb>&Z}n&Jo4i-*GL5J!#kAp2?t$=GK68+Ef!MjOd5u26L~I@AP1uPMD}*&_n({;J)* z_i?MXwF?(tHgoS#;WoUC^GG8Yx;K8rbbIukz{zXH&^P8}S0u~uNEh|a zlWl_3b-^faQ*Hg(+q^@-@dk=$q!~mREq3Xu+3Bg-=}C+o;T7Q#hoEbZ1n5Qpe=DXC zijB;VK}u$LO5J8j{TIbW-cjRTQKMl%!Wr`cQzzZzm?xn<26WvD)mYmj0Zwn*E3f>3 zu4$!Y5%r<>Y8Kh9S;X5IR2ls$B%CB$^D)h0Zt#;y}Mtx zuYLVj?N@*Guh^Hr^wi`3B-)_f{!w2o7bMK6L;P#pYs|GiUPo5z)=J&Rv8Zbp_zK3W z8qofYv1XZ&)Rq$a_*iq@FqGGl*6(7#?=!I@^@`4o%A}htnL$v?*U9fGw9ho%a)Cq|ex%0Sp}o|@ zGw!1S99_GgpP>FWFTML#bp+ka4Go{G-MS74y8sFG9KtiJxPn%VSwck7`-s zNOh8GmA=p{Ra!KyGhwjo$&d)hI@*#BaPb`w z?@h@AvDg;S8yRMpt^3rILowtWB&h_~i+)x^gQl=*v|kF!dyzKaO+z_|I-NeV5Tn|A32^i=dwlxT2yn`KWq5W)V~O`G2>ccVbk%N{V_8i~p#mrp z@~#f92uQljOPWXDY`E#3|2ucSW?%WruXcF<7ryWd_SLWbn%%zrm3@qV-5C3M4P#P2 z-v6~DjD=xfu#MG`1~5|xwRj*{;fKF3KaM~GZU-C86=f|4jNpRc5P3J2F5^|>vC zW9E=H-AY%kmC!qB=UQmnDR~e5cays?UKH3uZDSTg- zE7YZ>oJd9PTLe2)^jXBw#&Io@fhxc!%st9Fl^^%ZqLcQg&KFQM?ez2C`05|}L+>yH zfd7Vt`kxyB&J6$ulzv<10MNgWF2C+qum0x$cqtst?60Y;WWZOi+WK`^gHzs76!M7U zO+vcsdmI*cfkY?Q{d!R^_oCuUStMN{orx6C1=5Dq%~oM#TIbpo8TqCQJ5V}5V69m> zjFPP|Fo~C?D8hrW6R)^OzpW_hzSOoNY2ENhhaf~d^^ul^l4wJrUD^lCYf{!Ef-~Jd zH64!5arM;8JVM%GZZC#_6t43GTb0)GlM?4qMR<<${#OD*D&Ash=X-b{5Wo$EAB94M z=_!;9+EMppfp6XWq}{&#vwd7Ic0@ps`31pPlduo>ehdUpA0LgQR3apc{O=0?ETnqk zSfpaYTRxRJ1hC*B;NDgOiC`6jriFNS^|{HOJeh6YG0)&87?LGks$2Tf+pd3zlJp_I0ssgNZMwCeALF;5p4plQiK@46H*pPfRhuq z-D&B^HAUUlE=;f66N<{B4oD9Ss=Beyulys{4GAL&Sg;BRAAS}6W|gF)RGh=9t+WUb+Mi}yBC2P#RX zr0B(%nW5Mc27q@LgGinixxj&<@=vsfqI)#nUC<*n)>hVkL4&Agxny1>6zUblxnP`I zl+)tbHSCaI)`Q#?IVetmmHPxyBfqcBU%3U(PNB_fAX(-t`;@-RdJ}cfs2s#8>RmTI z4pLOtFqBw1A9=KJANpPBQVT*HxYgURFVkp@mmd^)4 z*V>bxz4gYQ{;LP`{&URu`!`0+|G5F+yp%%*fCILK0)E zP#z`wSS{`=i@jKcfe)>DNUcOV8gtDBE=3{hMM3}$7p~8XU_NeHiN!+&v{czy*Eiv! zoV+gZd!98me!iMBmBd&Z#!J}@;-5i26 zYa_zom8-sohM#q4fRY#TE|2*2W>>Z!wbS{Qowa-1QDQg4!|B5q7VkeFwFH*Y&I#{! z-6)!6VdfQUj9H3LFC!EISh%K|v{Jee;B5DnU4Qk51H}<35`#m5ag>l76JrYD1D3Lg zkV0}Gcu;1DKeg?)q*z{;l!cLsGO@1rcA7_q00=5QkOsi3`)_sHpNA2^?Bq$1Ou|ty z)r%boaJ>7n&3+2u7#teIxS|BLF(!D+Vhl0jXp*!x(gjZ8*+dbmMGOn2ZpZy*L$TbL zs11rx6GMe%;oakSr0avA&q|GTV(eW$zNeIaq8N@j1~Q_z&+z6ehs^k$y_K}0mA)JI zpK>T19$_{m3B~di-60|KqHQ1%;Che{Mvx^Y-f-6FGDt7mD#}tq=4vH86XZhFEgut^ zj=ht20z(7M%JPgF^KjNc=8NLIW&ofa$`g;Gn)h!d7mZKRx|h83)I~d202MZ4JM z;Q8s7Xh_zoeo=bZ*S){`=8eDb(`-VE-v4$E|35bXd@C3Lo;?JB<7J*t?d8{g$G=(1 z<|QlZFmS%hFZL{@lA_I=*k!4&EXGnROss%aU+E;`%im#9)WJceH7}SM-nD-H+M}q@ z5Bw`b(j~qydwVsrK?tMk2necJFABq2{w)g$hx>Z<7|(pLaSD?^aPLf#l-Zk?pG=8w z${M*5n{^tOL@KyMc)0@8L>6DQoFnKbh&RHlGy{|kUOe6)NCK%F#XW`Tm@sbBr@`{= z;(1Hw3CY{z$6x7&fG1Dhu%qqsw%tBw-u7sK{_$qbS{T{FNYLv;!2y+10(Kwp?+)80 z&0wVb3rM()V|5B|^e)yY2t?ET?i>IzXpOtUu9&YDnJXfCR}!`p$fud)9tyN?)5E%VQ#WnPhV85fE+RJd^{&e)1T*0qwXe!F>|a_^D;C$EkcrA68DTy14#!Z+ z-=PdBXc3g?^&&`_!iDmCS%+O>IFV;tRavz+k$sS17sSGROY@b`3_X#yYyXSWN)0Vq zB$P9w2Ph(U9C99=9)IzV+ zdg^v|YT_|z4Z&%%*$e{2YFCdwW+%IwcGljqY6TBzrpC6X5y}CP=6oeI3euCu9 zwznxV1oVFcRf6zJUjBV5!fJJpMS@l!F1srA(X-x}3mIbZ z3w_=m@7^%iIAK;MF=(!Yp;?nT8{^r(C%AJh8%R*4UeD`yKd<)hdxS-NH{0Nq7j)TV z)wU?YHu@3IzSVMZO#WCyAOaM)|ELd0|0Fs46Y(g-sWm*P)hr_=a!O-}>`>g%UIk*j z#%3-BrJgY+<()b;aE2tAg|(7Hx___kfco1yJRSK9xyx)ZBNE4pl@+JRl0tYe{Ik|< z#3psGKfAnCOVx0IxGcAmK7H0A@1ib^Rr)VMB3AwojuDJMI;PZ1Bj_*B?Nj8Gw?XFI{d+(Azj^T1FTQy&1R#GX z=LUgu1Hii|6`&rsLm>ch>^(32YaiWguKhCyHV>G#woVz42 zRB5+D-As$N&9vxq0_V;1XNlZUjYV3DW|fd@)`SKvIv#jHbiU;{I91xkc!JWLE#t(| zQj&sIisZXj>;9CPaq<3zl9-r_1w$fUAOvZ~Vv4aF#9KN~QmD?T1MjJhU3gl71FJyU zsLlcwY?d2;dUnt5-Tj2U`R32qZhmY>NAKzPbf1TJV-D}|o-?4$7{;m=o$xEJh7Msk%Sfphr_Kp*a_VMnW3?9Y#zP(`2+^zCkJ-t5)6V?f zIMzr#WqGc*FtRA?wo)P_Vlp3dpj#CrTSoR=Dfh$p6@oS)hon(H!U4W$XJ_~9_1Av1 z_wU~8zRjUq-MgQKJvWH`+)4Wwinu^hYqiZ_kdK|@Xj`ll#x<^;>LhjS75^fHlYuzt z*Omdn?mPn{YXE82x&bIc7$6432yk{%hIbmK`U{)q?f&tv?C(c@ZPMB04c=Ij-5Q(* z1&#+`)66^dW`|LAL#>Rrpo18D2Thbizcd|gchSffDb34@-#i z>M#TV-{05E-<$rvhGAL}0olkwTg8|idjsJn0wb>lWj^n-6lLOfl%%`|3A{cR^~Nsm zfePVYhU04pA{FSt2_m z!ZW@iN2>86%W9fMMd}wXx^n*%)&mJ0E9_0{ydAps=CL==b3;;_k{a0;`MHpXHDkm& z@|8KlKQ3Va#&fQOPtvBMhDa&=o%<-u+P3@3=l%!(gLyul*FymGdw!VnnE!JFz`M-= zP@atkK!1Dw#ee!eM@QHHoi!R&;#=Jq_r~hKV9iXJtAk$aS`<=8M;z#Xxxc1#e|CRn!2pso6T4EUBBSqjjGK!)tLb*$E zjLvt2L5VF*FhV6il2n{=EJiL;PFZHLHkwYJZ7-yj)L2}B1EY{6_%U$dwTu%}=LvZD z@bh--#*f>{@lBhii`^K|23cTH&%dOaDiuW!xS@w)IfSM#*fa!H3;^Lp?$;Q9H}YnWa0CRLYe(O1Pj)wL=l9G|;Pf>s2>g=YorxD7N8p!3 zSwqpAC7gqG*aJ)|ThoNA*h3s*U`XJu4;h7VrZFdhe0xa@w!(af9Kvt+E$4qfS0-T&$CkAX`TY2qPIo*1$~uC#gX3)Q9pN z$G5SM27)Nq`^WX0e%!)uQdjUuRe^2lsrD8^nc@GuY(*RpxxP zuC8%Sx%N#SH(Kc`doE;Dl61CIEb{6ql8}Z|k4PJ985~Ff&HAkd5Be;}`sJ_*E$Ufk zw$u08w;U(rM?L%g>>-<@&yf1L|6<|Zhh#ENqb!hz@4oPjul&FMKZm{lhdBU#gA)Rt z8vxEz0qWm}c>wftVS3^8%Jbj%pUiKbX+k|nmb5_Tfzpr!T<5o3N~6in{UM~GxE_&> zCByMFFSbS9N|@#84#NHm7jsT)m0OjS{e>_G7D$Rf1!))QfV8YzXGOTkB!~-ydJ<|u zzH7V}&X@`N@%T;K%fiq2(Jol0DcM`d_yf^YiBmULy}oER2%a#fD1vOqAr@Q8->=aq zJo73%QOO_!dPm#U{m-NEY@CZ{MhpV(4UbA!oK5*JA)c`HuJK@>TEY;^tiy;aHIt zgg;%LKC&kU^tb3EmUNv%?vf%fysM!&VJPCgb#L;Xnzx0-m~q{NELCFeG%Mpf#$VI4 zb9G~P?|#uvPj1^Q*MC4gQSrVQnF21g%{8!{0Fw3r`mD}{U0dpb|tF-)-!EE6lUFGv4aWqm^S z$oDV$tU3Uip-Gj?oZp^fxcS}Ii^xb4dWVr{A%h7m$g-c1&*uCTZki28!nZ&zB>B~) zA2aWTb|-zL_g~Lcz;o6Jh{}e`SYPtU>0c`U0M9@$zd;C#(`R+w&AW#`@#Y(U_OCq) z0pK|a;Je)baBcuNj0#ZYUyeU|^r=U$y!LzkU@7%dhR6H5Jxmw31q-0-g0miLWlKai zRnwiJs51)6xc<(@rwh#5bGs@!+R3CqD{2Vg#wh=CJ%1(=E|+Iv@y`#FUHjC@ z)4=}9cac_?{NYN%w_b9x0MEZFTQq&ZW1#5sRlb|1?hB$eJa=4g2HZVECRa%Ui^j!D zU!d$V7ov}}EFa8ddDN_zCHS<=1{dRpzF5h6UyKnfu-YpVf5l8^K=7mf=z3E7Rr;vV zN2Jq=>-+h`qo=}o5o1h^zD9T!ud*ts3QY!0v74XR{rjJ>n>T-~lLpplYuoJ$q!1kO z?KuehBYw~-Pf!w>1l94?S$*tj4@0!cs{ z&FjOC?=fYdIDo;(huNU_u1Eju~8W$}t($W(L_r6PEvw1=Xn;(B3~ zBkdSU7$Ho)Kvasoyc+rT;Q@iXLrDRvkrHq-3{S-JW5^imLi}cfqXZ+tQ1C-k!Ppr@ zjHVnpC{v;p)(98qA2!A@XS}X>ysMDydUl%j(>NgSpP;+yYG|T59*P)TYP82yBFThs zVqjiD5R?NUdMI?vqa_hzF`qoY$=q=+Tu=2&WC@`JOQ9w}^SRhpGYIMP=lP)xqz+12 zTSZ}Bk&(DYRD1eEPb%Ku`ksEt#tWX0ztddde0_GgmLCpKjY+4iIX|)rlFe(m zby=FGUR}2oV-hdq+3wb#xpVWc{8IMzXXXE#1n}K#05~@Q*fWL!)I%tMw=)6!b9Fj; zzeN!%Yg!C}aH>-7M#+jl0jg>pj&u1}lm}wa<9M_|&(oMOa>8=oAT$DME%(e2I9>QN zEEg0P~jM3>ezSj--a(Kg1LTWy`IP$EKf+%2Z2413hf8Zse>eOj*WXGK=Phi1NQ9=WxhsSZHak{9X~ue7I6Picn2E54F+#F zBX-v%EI|Ghgn(u%2q?%4yT>0}0L97DQ@daso**3DP6ZB8^~U>=4vJrj(6%fub4r z?tacrPT#caul!&?uvZEro!$mR*f6%hr_nIpct>}HrzozEHFJc8L>2(EI!%#j053ap z5X1)QSXr^+w^&C=+tx`4ONp@ylo{#U+PsrAXIHOPJ8R9JJZO6(R(sS@00Vd<;Xsh+ zfl@;eYPu1Gx3?2k_jHH3Q0NtPCyd!0wdhsO`q!;!4*GUmb@ps+!e|g{I zx8>{s?3LlYp9b2d#qRTPxUVGF2?+|J6g z!xfOj5enz4-DAl)Vv^BALmFu(_92l+wG@o?IeSpC$Oe++isICXv5xkKyT~~go(Sie zO5r)+7ZPI%W8#8pgitTeTTDNNv5~sDzZerRPKL2OC78+A2O^1mQZfQq$`FH0S8GN& zF#gAnKJkCPcjqU*qDudI{Qn{E|2aC~-CzJX&jcXH52pg;^Ww|@+P6>j@(*$sjk^@6 z&H~H1va-geh{uG>S!C0qEsJX{kfkUv2@jl+Ga4vTz?V}fL574kZ{!`7jvM!^;YC}E z`&Q4l#Hgcqu3z7O&q}0JtV+F(3!WzydG5(`XzpCNrUL)V(TS-=epIB z!bJEtdHc*Qdxt4_#ef&%A%jms^B(hz;Q=%yrc~>GRly6$yO5H>8qW1Pwo2-!UO9{` zlAcc=npRO!?u*o)jbFV8oKbiIB7e2Fq4+x+f3Sg%--~-v77az+@CFV-KzKgNR$_NU z`Nty$5+SY!O_Dtd8*B5iJ$(2@yLs!!?BRpY_RIiBN6&YqH9`Yy?`7{<1m?EuA6WbM zzttLJZU}8HG6b}ddd`Ynygb{(ByE56xuFEmO$86E@;F!lCy;gfxe zbux^q5BTODV{xx&Wn4u^rLVXx3`T|1`;Vc+YQoS*c z3u`fobKG0eKETfj^XT<+9^`u%RoI8>Q2GnAl*|@&f(N8-N0nUPuZ28Ry`jO(FCj*X zOj1}pYbSBylW|N^-lBLs$_b98({D ze6(mb2<3^P4>E)OdQGZWqTGprAzzOHj(aXJ%9Hi_{3~Djzy5d6PTn|CMgJYe|DTt01HgGIz~^85XI?ludgb3V zQASMC%yC>chxI(}1uw-i%*wO+g&9z+@>m~%dU9b-<&z=o$~2!n%d9k9tOx<5t_-O4 z9KxPB40N;P*;ivKR$~b@#Ssf`j`P#y*cFfkdIQDFmr~wn-%`UU*)yy4KeJtxQ&VBg z#j_oXvVs?w3mTuEQXn#)h$og*lsZ0n7}UG2>CaZ#{dHamGdDsC#o zEVB29YeR12zLYX5KxwCqkin7LKl!~h^niaO6o9|8;$Yz!j(YpQk{cRN-4-Ll@Pf&? zrHUKk^z<#e|JJYDjhlbpj-R~Iui0*&?@Hy|8Q$k04$RV=&GX(cP;3|Pw7KV$aJ=j8 z7AeT~N}o689Uo=b9(7*6YIbtmQv?!o9HfHY;wD`w-PEn^+OeI^H^qaOWA$AW7LMon z4TE(?Sro!RT#-D;R08dl_;C~XZp29I`g`y0=k08F$6kB=Mc0b&T^ zoQ2%~M%qv2V0ez>cvWry5P0f108$^%5%*&5sWEE&p4YnJNgs^=Rou8LLjWFRf20%{ zdocjBF|9ZZG~_^k%)AzP50=<${CD(YEy0&`(`{RO7B5O>VllOEIY0f z74o|l)?pk^j05{AG8y~#QhYn>#5Ws6I9Ar-^dc|n`#X+Gm!}JL&|@6=OZ$eon&m{1 z3A58i3e!sTdy2M)qaYNsB$^SXSqhJ~==>>sILJ>)9&kVV^4GrbzxuzeD*gO>o&xaP z0C1iPa9#1|y6yJmv#Za2_kUbgh4IlFD8yfna=b302Xqq@xI2qO`i`($a;h^`N&}wB z{R1A#IVldF?-WS2*!;6WLzI0yncn1jmVIDc7@%{%NIY5YuiW>1^05r(qVF^XPbj{! zd;^Lr4IB+ws(y9n`Xyo+kc4KHz|e>1{T zRDm=I>h9kCyq%o9X|KHU1Cm}g`em@M%WqanM_E+T_*v=4rnokU9=ib`^7(^+@`ZQO z7&Ec~P@k&-*>A!sB55ZE?+I?UhU4^FWA}cvF5=CqP>!ZfqJWk4)I)W zf>^@a?Ku~_z?)-iW1gUJ3z}%8>+AC_y!9XnDrx;_Lm}Q@&^!@dL|ZM^2!~IC3Ka|6KPa-IrMjRB7ye&*rz*MHX^v{EnHdd?9(wQ{&B zxt>`2LV3&3cPp7dB;O+r$l~t6(?$8uf>4ILqX7Fs%W^h4n1(fF&u@ls=33QSkAjd2 z;Rx#Qg}hH!{tv-0dk=SWVKL;bWt;QXtFHgf&? z@=jctv_Gzw7(^{97>;(YTgpKWm_XR2C@mk)&PJ{tKvlkS8nHCyIcq}pASP4-^E;zF zv>t(RdJ;_IJmS$wzo`Zqj1dauCFVH1qT#8J^(0UMMU4q6G%Km6&z&&v?%iLpTep77 zj*o9zwc0rYhB06m1m@x4Jqx4&tj|NqC#D>DClL11>w^g!R&fMfg->36elx-WO58^T zrMTwg?CU*l+=>P)G0sI}bo)IsQ?^7=Gd~yEnVWj{3nn4-Auje3MuJ(;Nvt;lsqQHP zJ14-aKdi*8@RqP{VIak@OKj{QP(;?-ZV9FtoCZ;ZA0*{TXMH9uB+yx2apr4M5-?nP z$`|fd3c~QE%gr7=x@!*}+`zLgx$2#|>t+QDSnz&ACdzsP@S`viR8j^Kl8_N;geyfx1lAyv=P)ZMR~W%Gt*V3Y ziR=R!x|j_B`de9(BWlgcu4dig{(7%0OMvK8xyaQS!JJj}Ar*2u$U5hJ)BiX(DR&eM zIO}CS@u7yoxn864%{xg&OrNgGOnT4s9fND~^sg3Tl~lXZ?f1I2yZxFy!F~V;ILX}QtW9bf&$9-x-~u# zJTl(u705YvRZ1?#bHJ=`u11#i4OQ#I=(ENkOMT^FK3kZ-<(f2ywX)3An_+CZD0cvV z=lc{r8-9lz=bh&%ib9#PBA;2B1-ea*lM)CSzj>T%5CT#%%P{xC6DZ5G!nlWqA3tffJ0qSw6q0~0KRWq!JKo*U zz9&3N6fW^{sUpF3gfhuHpt(==-6yOgBVyqMDa@l4x!NP%-|lrJz-@c=)gK{otGhz# zwMq1K#mgUSLnzSOEo<1cnP`i0w8%B?&h{|gDTqAdUBuXe!UCz}Ku`3)Yuzvh-1{7h z%oN^cd;ad^GtZVFa9~pu~O*w7_UXv>>4WdVQ%A3X+$83TpuSyjxr&a|tDybtck#E+{c; z9_f|!xg0Z|2p(4r3T7N%%V?q{r_|MFRQCSE`(f5C*LAbP z`{(`+V`VX_!_xtZQ^rdjPszDn4lDl@`h&Hef>R1^5e(QWA{4EUxc+=x!WXn02s>So zae3EOrju%srSdst-2juzN{Exx!!z20`yc<`-oN`(-&l|T=leMaz_*eC;5-w6J!>Wa zzVmtUy}$MKX>;wLwRMF|m{Q$=nN28cwd2Z#{Gb!V%3$Hdih)7n&h|6)@Jftd!zqov#+2Q9Hlrz_)Fy!&x*Pl zT^y(~O3T88W7EM~AnfP#E%CFpswY2n2ZUU@Db_;06espKv-szYlkb^eQauB5AqzPD?T14jJthIhWhkdLgmbE)6c+q@_FD<&ubA4*)8HUj$X9tZ+` zCrAqygm*HAZ78r;uI)Ji+C0S+66Ni|$tUe>zK7ywcwjKXmIS>A{#{C*tCJE$?)4|F zX9?$$Lw$%JAip~vtZ{rh=6^gp{d4c$m+a)^j=lQo4-dtfu!GICwPJ)tOm&L>0(~)l z^AX2yR=NS8;#nWz8SocO(`K)vPaAXd{+-THk7T4LoF=@|Zbk@6+ke7vz1y8y8~es? z-r4T#)aKop?Vf(-UGPuz&zrZuWM^l`>h)8;2G%DOJkk+HJ4t0J>r;`u|Lnz?EUX6F z#+v2Cx(bEQIrLo=?b`G7llD)%nx#kvl7Y`a*$)nq2q!8Ui>-B<&OOJ$P?a-?Jq-e4 zF=6r(dxQm*knviEq35tjB@~vh$|-X^ z#qk9LT$)QNQxS&`@vLMca^^PtRTV^`wFb#?4j-+MyYem-?(e>q+nFAgKGONe^(NU; zJg+l{@EWvPGgv7-*2$N?_&@pYwX+9jD~kU*{Qo=yz(4Y`v40Tdyzrhp`?-3}YWxeI zW9qQ)0n@QcS67ziegiUt4G?wdHhz5;-RQ8tLg=_o-clevE6oIG}yPdXy$_=My49i zVfXHR>g~^`?aN70PW;effW~Wc4@MK!Nn#~@m5v%4jsff)GG&(!8K=QIiJKo*exR4=RA-4E``Nxer&itn7O$~=7#7mn9F!d^Vi&uz;H z8A;pbh5J%?*CF8J80Tv#@*H9;*XQIIa^twZu~%wRUgogc=}YQ#ne$oBTIVJdX~qd% zTkTJM4kZW7MwQG9l>5)~oXJ#BBiqJ~-clDxAn zSlRAh`keDND?hrxpk!%r@kA6&TqICcP1RQNCM@4k6ayj~jMB|rV5p&3;+=JU4w$*= zpmj^RD6QG{?gHBbj_00Yq}wc932U1dJ)ktQC|dD%YC|6MhW87D4vKN}mOP`0nbv7b zNon%(y}ZrBHm(yvg$UBsE5mj!z|3-YsABA<1dw7{2g$)4v?i$}^%j_~G`BkQh`9ci zAb15t44dx?F>+yEGA0tk?#c6xSCNjJ^1afG;LS3fRk zrJAzTM?d*2Y2W%dwFW?}bhUYpR%&Ox-@F?wc4jT!<2lwdD;COrtZ}<>ACxw@BCCT!P(ZG?u9A)=HLStc-Uwz+%#+c>tHcS2Nn{N%1ZIer)^6qPX_ShAI> z?H466?-8D}kQ7by;;}ZE&DPkX`g@@{^{vg!sX(w0=D}Lwjcamyg4oYyD-!U{bH#Py zNv)GvYsy7X>;4!#4rY>jKaP>9Lo~_+du|{HG?S0+>b!5Nu2L_*frxOAu!G*_TnhDO zM1Z38e3lIZd1>9H8S;)r-#N##(MZugEsVfcx`AvR1(=mM^^WtS%8O;u$)j*qD&U#9 zw}i>nk#2LyR5)3dA?+%g<-Gl?hJVt;rgN7mA2Z5hJ)A=Je7-T4yiqgt0+P@agjzqL zQE@EYH#;QwiIUN)in4Sk4YIVkt@94(FRo}J9j8Td@ZH&iUtDMQU-#snX8?FN834{p z_VTa)&gUQk;Oz0&PrRLcqSTAuXXcqPRy@9Vg^ZG8yE7BM)a5xY8K%^1G0pd96E|W& zS?p@TYvDknhPTQD3c6lAAAnIB53j&0)%shwMs3xrIdHY4^YF%wZpZ5!c~_&3cup~H zG}&~86l#65(tNyGktJ4<{^EeID(z*Ogc)|fLc_WF7L zF<)W>#lwUW>kY`L=SZ7KBWBiSVvm96;(2F_)w!;=n+%pViODwF4vM9;n06{G)gf4q zJ^)TPTkBn2FcgvMP3#aNrC#q7>ax3lA;(d}nF z3xGu&bgTeAqr}OhM9%e%xjSB&hy#~3yhT6DjEZz=twDTfK`iGnC##W>uZ+1s>NeiG zao^6Fa4J?@l+&k1fWO_Z`=0OpFLbZHyUE-N18Qq+#L_$UScI8P9V*+xusQFnSOrFF zr7P-vdWC1-h7uo&&j{u4ZeS|idrv%ni*>2rgDtHkLP<~rX1=f6}&J8Ln{2{aCRtf_(9G0-BcLypV)R7acOyfD@x zGq{3;q%w*R-h~lgF^mUpD7C(?KhQ;1F_IKjCuBYEKO!zk$VXcHA&6B)-Q6_ILe~4p zd=&4n(>Gip;fOJB7#C-0o#%lnBfXs?cft7HFY~;=aL$UMUX0QhnpvCYd0j5i0@Qm! z4bwPv>US^sL@^YYXSAf#W)yPv=6?OmIht}1N7Lc5_Qc$qn0e$pnt`IM&Cusj$h^GY zZga{$#Wg78$d-jLKRIr2(%@mwb3qLba2;6D^taKs=7FMBLoCJyor}wIzZfWDEZ2+6 zygxnu+Akd-i{jsNWWc!r;Jh3_0BG^u?*1<1cw+ocf^?tD;c~hRfXq9q={v8nKmF<3AxA)v3Dro4B zdY}AzA(Uc`i8qr4uAxSRV9wY3W4*J1c;?>X*%vDXo4j6iB^R^_6iz&F?s;a5AQYWR z8#CH~{({)ArSc=$-|FVUF=MEv{rkr7L#4W3KN<5a*RpqI$b# zB?|J8(Az-vMBlZ3uQC)&2mZgmPpPE-^Fo-P1%hS@r53}-@M7ZWCk_>*sDDuOB{XFb ztO%sKO!nx>9ee!t=f=&S@4sHT@~U0C`T@K4+y~$O{ysCm%oq=dd7XC3lTfSqh?gFQ zxc&(aj2B*RcIRfZc6!Oqb`LQQt>dCZ5JXY&ao9mgMC*iAeaVS8uIqg>H2s+WVK~?m z8G>pGq17WVyIJes+fbA?y&VY6ge%xE0=PH(-tYS_E&!^FC@Wz!3FWm$ye`}`UGXlZ z8LDK&0T*lK%o^Mud&9s$2880;N*M%@dxJxBPyzFN7VpZarwyooHw1{cG0*{zAHQi| z|Hh~6_~Zms1j~{T{FMUyH?PdRyJbCfH}h6iDv(SV-SMC=g~tUtK;q85Wbb;F{qRtR znVgq?OpvU`rIN&(m6)SkNd?p4vdkk`) z7V1Z5^&p+fMPji~E~zh`9fgm!0d2&(Yd&Ic$$2hg0vmNhnMpBhg^jZR?p;G@`N{dv zbGfZ-M%^o!(;4AesM9pULz>L8oUR8$4%aF;Q4t2Ry^iC`%1inwULHK_A%Igisl8;k zEiukBe`-A5I6k0|1x2v94evVVcp)~xOS57@kTgTIG#K#Ri+QBuD@vMZ+!+>gXK4wA z73Cq6q98&Xx$*06%&kzS;@N>3_m7{Mdl+c;$}P~(PSQ5od^QNieB8GyqN?;P#BmZs zyuYW5us$QN8!&D6*&aQ*(LZ4jxP18)yL$C~Z~yzcUAgkQ9UVPyWhQN1%-LNjow%+P za@hyr)nprc?!{AkaM$ek?4dQsh(vm}qTC1z1gxdNTF<-=g?|?4bs6#OtQgH;_^*7A z)x;a9f{If&=9Kq?K`n8%HzEYny-#>}qhE*j`14=rMu5%puhu$H15Yzf+_Hy{Zrg+V zH|_D02ezAMNHIH$|0ed*{#~<;1QegO&_f9t?`oh<2xH6ZR-Rnj$d*bP?I#1p5nF%H^c~A3fv8mGasH)hqO(a4_Aa0%`}sh zW3Hj#`3Gf}^SWzMcyr7*vMsoJdSebQC`_d&PY#?I#hmcB6>fbs7D6~YtFlXbB~5X( zvrj<~jy%)o!&s*R8KVXz*+AKEq<~eb*(QW1(0tD?MY6pVioj= zjB8GjpECs^!&I9fK_v`t(fvX{QH>W+2DF)eUJRpHao$K;TGLD@OHL2!hl3>{J_r{y z7X*_#on`EEfvUQ#@k9Jy7rG2fHi1FsrWO%W6Mw)s1*P1nLox~=2;~K^ zP|rBG*uf%b0|W?#mwhpEiX$}GfKZD^8v2z{D}6$2m#?IN6G1Ac zE8;S3wH5kVvkX{3_nqy>^RXNg-*TIPzmhYaz9;QYo}rMH<-6rf0BpLewl!~_=!G4< z7-N3pFW7}9M^j|6C-@!Rh zd>(>SjKQ4VfC-p=iKYSX8C;8<4TW#BZ(pE1qMm|@@{3>md;PcX{_g(_VP07F2CoB(S10=O z*?ewxc2-~0?)qh!wU_Zf2gvXF_v|TOrhjeG?kO@%lB8Z=5IV8%kb`ajzSNu4on% z=6A5J&)`PZ#Sc&i{35@}I?(GTZ6#bE5cXz+bPm+_2z9XxrC(qxFHH;z82va#KgAK| zi6+)@wr`9Ol3q9ZTGwy(N06~p4X zx84+$Ca4i{>d3xIyuZ|}?=wHEv7tn0U@|hThpEDGq_newa2ugdB4<_{Y9s6+-z6Iz zaPpm=|r@GWKlIL`!-%fT3#C-;B(;oOdYZK@Z)H(!!rOP+66ln_U& zS08j<@C4qp5|^77Xi8B9nN1zId1?ixrzI|1PNm(eYX}RUXSP(7Gpk@;xtpy7J+PtE z+wr%gT_gU@&DKZA)UM5Su zBCy^P49?a@I}MXkK+9ddeLmpd#p+(UgK{(stm;K#X_0C-&C;FZEAoE|)LA z_wCPhyKvz>cIEObcIE1;Hr1oShp<-)XYW*pw%N=O(Q&sVtZHgEoiGqsR%BE{1SFjm zQ;E4gvIhieq)fxz--dBu9!h^5@yWz}M+|`5KHXd!Xo@m&%tJ3X-k)Oo>&sIkz>oE_ zf75UJPg)xuuAV)i?F42%&$I1NRBW@Uyzjy2ABuhdkkLjwPzCru=%^Ab{-R)-H{3~HlSw`H4aYyzBo@rG%Tw;x{v`sv?lCG0ymEX9CHi|L5={<4e$iLE3 zKP;K%ImwaC7bWfEcbpeV)}&3o28vxVGJu*{jJKB%(ln~1F7+(e;D}MK2ikf-p4EOX zDD@yARkn~4J>x4zg%mQD(*k9KifjL^$|y*VS#JQ@0+^#au5?~vc$4{Z^7t!1`OXOd z&&#)-0pQ&GU)DYT>nMSFckidC3$K0eLadco%uYGMP6aq z;0H54?HJb?9`5k`Vr24|Q{sJycUiI^c+>Tv)ct$Y5Dzj+!pmf^9q$!H&PkygWivdV z%;Bzd4f3#Y|Dg!&%u5&nVvXt<0)`QQ6l0pc&?QA7@!GtXDz{DIxf7Bx;m4@``;#a4 z?8)(c6#G)_!i5*?;)VCvrOPkd#kap79bLA~=Ca*-^E1{Op5kI#40w`g2}DN{)JZ+s zjQ~xRehn)U2ym?V#oItnjQS#XeAxx`KY;k=v37{J_h?V%XlLeOoM~>=-CBvZ?jmGm zq;~X<1ZeYY-~JuHzqe;sOprjquy5u-_D?}lm0El5eIH6}ko)C4*pbIS3<|{g@9g2j zui2wVZ`zY5Pd)oL`)6(_>0Qw;6|u*rr1)c) z#>Goc`asf!0v91-q{p=JTQ``H*#*O>I2+to)^kWW0d0}v-3q7&!q_}WL2E{?2?~V} z1X7BQFu9R|q!9YVnyuqC_6{YLR2a7>Lk>kV@#HgeDXRt!eaayB)1E%9*&VA$P$qqg;X=um}Z}@EE1$g zNCTm7*N@)@}FP z_cltJtxyK{Z6K5Y!$TMWPEViM+1Z&re0*p)r z(^P#=?nFNP{&BZRD`}9Eojb>|FQw@Ow@iN9K+CdXsq1ubeKNru~;K2eKJy6#q(H0DHp@L z!uPYXFECFpC|;5i+ZQ)xl&WHycR3VNE=)1bM!Uxx#2X&)%|%Q=2S7V6{~nNL$NHxX7~B#LGm|^wPYS-kdfzmXCCvy zAU}(|MtkJWyR9NiUft@Yo;aK{i8 zN!L~iVGV&s>8B?`H{P{4Li{%dgBpAhRtw%lM%(GH;t@|yT0lGM{WNH@tc7Q&ZXgQm zN`33?b&EN=8HT{E6*~zc(+{xa&^=W(S-v7kD?R~%1UVh~VhhanncG&&rHolv+ zQmqU_!91VZlP5Rq^z@NEdiaK&oV?Y&`cIDU*vZKQL2Y!t2n(gY8b%uo*>S$?Iad3} z!n2RjD2y>EPkd(#tg;@8!qdIE7{dBJv?@x`Z?%SkiTB)`Yg;i_Leo%~gTQoz0O64p zg^-N{!M$MKEQO2)S((z{A2l=2g8=MqH z5-6Z>Qs=(tnq9_v85O!1Psz}-A2Z80Na<@0LWpLuKVw*NpCL_54u%SP zBYqQM02G|VJ3}YYM_dx?PvW)7Byg_#eHJwfO0gRuF!Yefg+g{XRwv|zXn(we1~Doa z7a>T*o`eBkj4XuYF`ao{4i-V>VVJ=X8sCCkQ;}s#lGk+JjpGKIMdbyKQA)~^l#CTQ zjRE&URxa7d-h36KoaK5GIhjW6PVc@`_&>+b2>`!^3;^ed0MG8&XC%zcwFO&b$F$T<4O9csHkO3?}S?GHz->2%%V}epc2s_BJE; z$={E~nse7f=vHF(N4&h5dglscK+NrIP%25l_6Q%*lzOEW-Y=cLgs77a3&ca*e<+LO z*XO+!;?7d(3(Zp;Y8;a!p!h`@=8H|R&a1aYp|nB@i6ViiX2P&gLJ@|Y+N_50Xb`W> zhJdsgle)#$gooaDz>bw$+e*0*1cO7FZFqj&Q@>SGBW5JjP$Xk+a&Fc4*+{cCnDoW- z8RuavFvCCQ=5 zqm94!DIYx$0zUQU4`I+tv$Nf?eenx_*}nRfzhUor&v)5#&wtb|Uw+vxTzIM9`}Fjl zou1sYhxNE;W!UYG?eXKAc6Ro}o;TA`yObxQlak9LpufO;LNW@=ItR-!n=NC6Wde zI2&YMX~|>Y&2w9k3ElNFh=Rokz;)^%fR{lH2IRs|s0|65FqGZ#J$OvK5Q;F0gv6|p zMA8oS>|wx&^^m^zwY1jAfk__M;z`!U0PUOr@LS0Ma1Q%FRQwPZ!0E}2zjx`%+XevOc{vJSycb2+RAo8^vXpMg0#_^- zM^S;$W(69jo^e8SJ^rDPqnhGtZ9&1sfRK4v#A>;cli3pxI>X+8ZR>~>^13gDKaii1bfa{%tJ$PqbnVIPRWr9`T3H;suYINdB0+_r?K=gEWPVFctyc;Ls(2g z6mjpo-$;(z?i38(;&RFC>UN+Y_pDucuEoO#qcubceNVx9mfB% z4}U13{D-p2NHT}ieXj)%GlhA8&Y*lXTBl~zNpn)Ym>B*F`+aMiQ)D;Lp`k4++%Bf6 z{@g)U48&08LQ?~X#I28Oi$a|XibKi7W_ZCtz%1?cf*8x|0_X7FWUb5e8Wcl0lg4mf zE_jtSwQQk!$pVu}8b!@a?WbgVL;X#{<8oPz_g0vBCbrAQj&-0aVKmP8Q4`QOy)BwM@43z+A%Uyy&R4(mfBcnI1z-O@oc8>@ysHcV=LUhpM1Y4=W@z!Z zZvD-_fBDKs<9arrkwf3{IE}xt1S-^fC~O|el@Rbm#1Dc947%g|4gpk!mI9p4u*|z> zf;c7iD;!Txo;MTn(@Lyfm?av}xH>i{(!S-zP8YZQebI%h1T6wBcayYKTB`CZKw?(0 z;FBjf-}L|^zIr0!3PBFk8qE3wW*+Id+M2ar&>cR=78S;>{lv3t%N;_$+;=KeMJR-a zUJ3lR6cY&kI2&|8BIeyDFZ{%A_Sub z(bSycMyu5h)9%Mn#N6=SIq!tW+!w*C;Ux*OA_%HdTxbJ+8L{Db*D=145(s4)azDus z0FfQ&fc-PX^{(1W+4K}`)^-8&C-;B+6|wkf3GK=&n9wu^m2{RpwPCb%ON_q|!Lk^3E5H zHzza)1vLo$`aDTqd^j3{{XYy5yuZS-A1DosLd+LJLhxEjiFAxYSiBVHsJ~O_LCz;Nt3$LV`o9+L!Pt=DMKAD{6|IEO;Afs;MNYE_ z4Fl z)4x{i=)1>~Dv%>92P}G;!Xp#QWUJ^ncUHjx;e^}f6g#S1aa@)Em2OE#Q1TVVqyhwO zLiyAD?Rd)6BCj`7Yq2W+YJhN#3AB7Q;;E_UA{FP}i4<|Qut~BB(GPUD5Pi9j3~8QS zohQr<&R=Uj2l0mI=L2q<2$M2zK+d`aG+)}IUh){<4AF=GaYK$&A+3fGBST`+nzWjL4AZ$QSaOkx~XuX%)}OpyJ> zT(ix%mVS&Z02~iR6XQo8DdkZNgEc%SiMKQnzLmT_E``k%&d+JxObxEs z(F2YfZ4^(cWa?3ahI-2dieGe1)b)${GxAN8X?NB>+50FO^B>>Tn9S-)sFtGgO5P7L zYG^%$-zq%=aB!`Fi~tT&Oy_AFFP5J}kdk4{h_f2BoaK`XW&gBj8NJ0SFQda zZCXm-i=4H9{Kr_dR&_CA-=#k@N}#Oew+7+p8C`%;gn4k7l?xsjkgGDbv=3B=a4|vn zr*p7mI6rlJDMG^N7);dolfqto$eP8A%BrrIoH$0w>yP%{Qg)O4nz6^5oj(4`-(6Ac zvv7Y7@jo{JoRa`9p#t84^~}#sZ~g6~qi_3eLm^U6#=1op@2y&0fo!ehp;hleRoJi< z1W-H?>&hz{l`5Akthx27Dx%EGbBQqnYqAI>6ygvUQq_nTIW>A!$vv>4BQ&t1Qx@8{ z)D!x9Vm=mPLGrqCeUr++V#| zQP#aIxny?c)gr=_&PfVCC>lTvRyp_qgBL=C5Gx@#mFUNz*pZ0>BtdICuCY)ai+Ik+ zU0l?7QGoqRth-ftzEH~P|B^eEb;BK>q!{V~rQ^)If+sQ<`$ZWqPW!F@E}R`U@IR$& zMsxvO2k1uOwUO_l%=5g&mG7_To>sEgB|a`F33a2mKw`6!vWUAbc!aZet&|DkE=`LD z0N*3WbA+OVN11oBx2x^O+F8u!y^`!@Ac)FrHjpR~Znn|44MI&Xl3vmLY$+ieW9tnx z8}U0LkP^ZY85kz&&nfY|*Db`3+9;Y$!4g7y(Mdrcje`!Hvjfu&Aj zykmY#hw8lImxJ);qHUrqo)3e1(|1>8FJXZXb(N!iZ^x`f8{JiCjg4CkzMKP6~JDJS1};}8AV zAqVCG9Y@QdXKE<;aIGCQ-vLT~fJETP`n<8_OIk-rO z#QRs)Lo8|IIzvKcNzN4vgBSf&5JC!>3@qAQC#z*zk9+RdBQ$cUtR0+JOyatW#BVzS zDfI#y9p^_{H|}5Z!ifjId$K1Q6<({d(Uexy75C^$U!W)uj3PV%gM*bKZ1WK=!C3Tf z0W}g%OJJ zg*TvX_8}W3)~gW?Q1{PKd;L1rf$FHE?Yu+Gv0Ap=S5iLKk=s45SB^p@a9qL;q`33G z0qfPZnekQ!Kq-7Is%Om$LqE|vMLf4P)|R|xGVdW{n*>!MY0*5#BgY(0o^19ITZQtM z%FK`NF{ZMJMqQw5s4>YZQ7;G^h4r(>V@&P+Em?T7TpT_sFXwKL)?lVXw<MJGp5kE*hy2HoYAX~s;I+PriP)sb({F3gKWRS`Os(r&xWdf{F`<^Ce1RH*lhYB=(*mov z+&9_|9#`Yh5ypV3_j4j;9;qGs91^9fNWNiY>?WQ(7dR%+(Gp*Qo+j@~Ce6fvrAEny z*F6pepGff(x^QQk-)sNVnLv?XyvP->pJ7-GbO zOm24OXEIlk?ZHZ&(;D4^QJzrN8x7i~+s^dj8C!RP`4tj9V3~JHR z`gP2dZOlERk8-F0V-WpKh(YAG555N{Qv&vK#$p(HFqCdUc#QKHM*}&7@~uu-T^p6~ zE1tJkB;N8&be;)A7uK&L<0|a^@$oXkHVO*q8sCU$^!%%w3(RG5emClo7^qC_TUPUx zQ!gbWpwxFzq&7ldnsBYGB8lBIa&O&N!+Ud3SW}`ly?4PdkW+%Ldh@j~PZSx$Z@T}h z{Gi3xsdckVl&{j6#E~K*lCiVKIOiR5J+3^HGQ*?6J0V@pagcFi3_zrLR-tpZuY`*r z3#QxZc?SW6q$Jfn+f>fXX(Te1>2q7`fKIrwf|DKL-URFFB?X+JlzfPQZ5qP@9?a?Q3JpeR#a9Q+o zoTK!TFm>k?i>|5na#0Uo7qX+T2*ofL5wNO|DSHYAnD8huKS;l1)TgsSS@WAdqaYdF zlMq-&zdFj&JV)(f$SB$7z@Pf;ks8N!GmUYt2$={j240zwzI?O1anzQ67I zx%7Qn6-(4##26r17{a4%1!Yi_I3Mx%qVO|E#JPAy$rKRj5lg`!7r_xuh)o|yCv~is@vMXl zh~iqz;R^BCBiU2EWEC3tI9w5#*v^$8uq+eYmPlFHfXri5>1`oI&d3|ze{~QpiQk5` zT&(0(j2mvP*B^wvv9i4Bj#hZ z8t;1$M3>C}fB`4lffo1w;+i71gs~6#O~QIwi?u6dxFX%5yK)RskiRmSC-1Ep^TEz; ze)W(3&40l0|8D2yU2Xt4FYDBv2T=g}`~5pV@t0nH?f3kb)a$5_SzV~Im0K+LlJnkO zL-5GsOIh*ncV$$<5{OTfG-4QjV^d?($FNy~#7dM;AviMxErbe(Q9DGQ@EBvMUnEUg z5FOMj$}>S^wi?e_)Cw;tXH==CCE3zkB(J=%H@l-y=v|e_E-Q|%#vNP8eJjPYAKU|S zMy_U^1nFXg283@nZx~&2`}26@7Lei$VFDqKYf;!Vi-AFcgIYYixJv^k~vWIo`HErwD7qC-q4ktUGThJ%UT2D3Kc8u2`m zr4EFQ@%oie?6lV;NAm_WV4*(|(lTUqLz+T!CA$j9b~a|m@9M$)rwI9dmky<{je z=O+{ZApoS9i}{EUTujmkX4Ho(w3jxy%WvW3MX6fybHzN2>=p2`L~JW-A#mny^uP!} z+bx9fAPe1emj8ecpn$c}CtCx>v9sb47aq#7kBkinl12DPiat{)?N);ifb0V{u_u&G z#Pxy{K*>=faNXA#;R&R=^WJ_JXnOQFu>Sr#9(a;u%KxjC9FOlU3>5HalrS*L>Y|)9 zx>g7@-Gw7S-bd!Cn<|FfMfX(pG!4INKJ0khhgPQ8{ldLs9tz-Y?8O|6_fcuoF_5sT zV=>_nM!A4UGtO(k#dXhc3Y8iCT)3SmdKkCmV*)!Cehq&R8nfF(C<~WgNq}wMw>_1$*YG z8kEQyL)fb~seygmC9FzP7S6i|KmOpsFWpyt<(pN9i{ix~jU5dr-DjEYmk(OoLnkvuka*gJrEh;puTHlK*rrB5_8zmlXP$e-bbJECCGmJ1#D6OBgYuZ-P znK3poHoO-oiB1o?z%cP_);0_mh+zo4&D;@!JkM&hbmlzrJ6r;3nMr;mVWM|JXgIGx zni;5q$R`g$8p^to?zt%W4$Hm}Y_Zk^BpoviUR=nWEYJ?>4Z|lDMgu||7FhhNc(+ja zNf$Q+Do_!{ML-MBw3oC#LoBE%;t-;H(R5g(G(~9kZ-xW6WJH7E8bzxc0h(kp>3xR~ z92q4r44We)^Bl=wRuJ}m#xrgWVn3OuHNmMjT`7{H<`4Wo zkPIL>s@2GB0nsCTA*8h|0+dbrrxF#GfilCZ*UZHvm9Fy~`jS1{a-4pYcg6y5M4fqo zB88ZYJ*6-*@q1FTK)D#+;U->m=_hq%qWn`2UUI??jC#D~XV)-`937_>cpsr|wO}OS4n37f@V{5IHnc+3pvN^Md?aFE~(8;*AiF6%#y7WL=Y;nb`K# z>Hf9g}`uwxR z1G1jLK%aNzCm)J^Hk33mnn1?5Vzh~G$e%c|o;>;TU&&to_0K_N{yaOtxdGsu2jHD# z12{c?<1ZgwdjEGRlwB?C2$m2AE~YTth=v8qGzHj@nE3{00C%`Chz{D&J&Y1AU3G07H4M zqs^_&It~>ADFe1oKLTCH%VaKsQ?-e)@BJ9ps{kr-`O!aZroV%+eZcn#+pl<=L%?^& zdl+*%Ag>WViMTb~F{wwfsS3hdqHc=D@hDRqTwp%eKb zM8$NNwuw=>LKRcx-pBYYAk#FBIB=iEQ&U2LppP+LcrlCs zMa^kt?uvbcW)L=Y7Dg>nR7Q9~AUCY0TE$$mDnGhKcV!W6;++TP=n1k#G?0=cW(!)>!l1$eAD1qc+ ze*z_El@Nna7E2XmT>l6fXuyPHk6QJQ`y)&MV7uVzk{ zS;jMCi~$)`N~8x)d$E!rWey$2?38ZUn}0KlyjII%*;x+c+Ap|p{U9}6M((hv+; zOUKxd9uh8!2q%eo0i3@dCzYde5`eox@rRosr6|Mrz+S=oH`YLjm9G{G*Z-HjzYMqJ zI`+K5jJ1zSXqlRsnVFfphk54fe0dp~nOt-018b+_$;+ zXl;~Y1sHYR$+Lk8fvIQ-$~+WP?ZV^DjQ6;N5P&q&C#n)M#;9n;Pe8x{nO4}?z};T+ z+my5lQETeLF<}WhIXLD6NHd%OoAsCPK(9HA6wn=wV}z$b8g?!7b(WlG$`j8?G%~p0 zx&6B=yWBgtxpI*whY?AzG2r4Avc!c@hvG}3oG14+T&_B!KbP+ru8=+dR)}M2LU>9c zxg0(jr>a-&;mw|^P|mn|!sWf!{>jaoAHTWlv^?Vf4+FqaRC;CKTF4HZ1_prVzvQ{^ z{{#OY-x{Y!eY%9vHhu=%6u@*}mW?)5~QeBha7Z~uubofa*+9-OEHDQUW%G} zQVI{#Ju>BA<|PhnTwh?8u|a|17dzbkZf$>yTIHlkzYxyV6dAD zF|^*BSU3!XzjrE~kj{d@g;kDEL?57BmzMcwcdl1$iTst1G^SnMjHO z_V5WFd}3(pJMIBYMF=v%jCvf%3Oguo84W{|HEVo%mkiW?1B1xN32uO>;sueZyAU z(ZP3mk0Mfn-}M2i=4wz;Dq*at4DF+_oa2IX57!0uf2GTuJ)SbF5hP$`#5zLA;P0~L0I0vMTLG#jg99;jp>_je)LQ37qPsX=l_WRKMVkeQD6s3qrY$6 zdiL)=`uLCenW4>}F?{q&E9r=)8LW+n`6MJe=WT^8Sj8rd3*EC3!`@I^kGb#{VT_yD z###}XkXELWpltU>DRMdGWp-Xq6!u|~WtRsYS!Ch0g<@AQ-YHhAH13A;Vwl0Vlr>tU zxjEA_PI=%t8D4`0)*Znd!Zxz3-oA7kVXrVV_S9DQo-A zV#r`A<^@U5n5*0Eabb!*D!=d$BU42N=QhALGnBrniM{&sI`?S!VEl9HkTRgCFz&tH z3B?HT_`1gil!A}ryAuwPV+wef3UWRobR6u$k}na1Y4T38|0^3u(Br=-*AYC>u8ML7 z`VYoD!cOOyqFC><2;kZn!R$3K z24N4aJ$opd1WDAGu5h-}QzWK9OG&Ad0#i3z5iJFo>uuu?_mO7?L%fX~K|`;1d}KV* z*+Ysf(g>n&_X=U&b^egoU;P7_!A~<&&RI*uc#t06k&-a;8_TC#9Qb z5*EkN2lw479Ifm)g34HG-UOn4e4eZmPtcg~Rx}DjIT0R__QER$o*9B-j46qFutboX zX`Yq!RdpA8|2keXc%7C|w>gWjTIa*;r4k{dG2rcBpsSn}K!G)BHS3<4VuUhZf9@0i z<5%^{{jNcvO}}v{{9o(_fP)8M+QuR8Pp;2=`cHkykNmNJb<%$p=fUAA;$Xvi3~$Q?U4_jxk@Pjgp>Wfw;c0~&DucWo5I%X=nfk;S*dSK=;%s; zUtwpDLF_f^k8wr^IrqN2!Y~l1EtM`V(;cZKzgBx~h8i3!=x{kDWX&xP63Gcs5W!~- zN-rUZWVWZ)8%z8S;=jx$J42;O8yuG@A&;y>1Qc9A=!0WM9f=I-b=azdS5PfG zVJ#aV>@Si!JTwBRqahY_$kLcN^8np!&>)q2zomqw*OW5bg{+YA9k6$BiXb&yJq>LA z@FYgyaf^~o#8VbjpY5(th#7{lGS&=uktsN3z6>{BN>8vii?thHi_! z3`xnhdLBH0_n)>45EcW4M}v9w6|?I^ zGORF}vJY#IhduhdH1&S2i`=h7Vq0^u`mZf`M&0T88@_=xBvEBF50@MOiDx54wN@Mo zB+y+^JJ`=H-*Vn~-es--Xrj_MvhbUPBFzVEg$np@6s3>0QLCX%=f-uu7T zqd_Xf4VSToDM&eD%B6I{!tR#o5MlB zT#aokMgRosml319Ro`2;dA+JLrH)A{ZZ zE5TxL9D)ii(>Fq$fU&3PGbnSYrwl`5Tu>q7NUhHwK;?*+g<9>1v5bw3VnXKy>jN}E z$=f_#QeGPUBam~SUjKPXPf8-(x*sV4bGB~kf3O!;G}nAt^^jM-WofVekWn&>UgF*0 z{DA>Hm}ivx7rm@-T8tCY{IQgG)(L!BcOXIL`K|pQQ2H^J$fzJA1?Ern$uw5aHx$t< zvd-!=5X+j~KI$z=FL-Jmh=?#ZPO2`klSrSZ6mrAx-wSfaW`|N(xwln_B=_hn0_Jh5 z^OlgPQHT>fzosgbWJP+iWbLCh-n6V#-+Hajq=%h-3gZ`0sAZ%WV;T&9Ed(Fo|MfiL zf3W9pz7@jN1~2YLG?GxRUw!$X{PkWBIpe!8h;o$E^mdk`g#T{-8~yj^e;#@GhrasA zV?Xl$pdF5Bz6e`2+X`0>>m=$F(Dh8F=+R1hyZEKrcNEk*N1|3pqC@{H8F5rVjumrqg<~ zo*l%ep^6qLcr_u9mFvDXXc=ml8K9X?kZZk81@8^TUjt^BsV+~td*~5>2Xt=aakCP3 zPO(lcCj$#+3iKq07wFA(ph}LQi$ zLQKmcG6Ps=GepoovVKhEB84p~9!#)f#D|Imkcuk^xA;O>?6Y6L9I^o5Rdle{Zf}wY zr`?6b$v0r7a5w0Qr!!pF7yRG6g39IhV;<$!Z>}=Dvs{*|kld3KjSB8ek8-qVCJ^q4 ztPP6bT=Q`nH1e*V;E8ko@@ni$d0%)TvWzs&^nUpei5AfZ+vW6k{Tj zex=u(g5O!8i@FEt@dDouLj7gPoL9pT`#C2lgcv7~VJe0KhjgcG6$vZl6ov$>kAmqo z#%8gDOS-5~^Lt*gw0;IEiO9I2kf^Z2i*ZZ=_soA$&#G&bubh3m6)r&-Gi!h=Rd%*d z*wTTT1eAZx#1kCL3R7TLNRbWIjNe1ijwCvh=nEv`ONJN17_cyFl;_(7kJvcn$bnbc zT_!_QYRuEd%8hf3_r}24;_9AQg3)G$Kv_nwu2r~{i=0mBoctoJoVpan*k4*B2KYUy zzI>;{d>V3OwJ`?P3yc@ei0#rJFviKb({puXiqw{2ppAt>`|j-lB!rTqjph!b1$dlDxTvXU%&G({mFV*L~uU;Vp2BTltuVSZ3)8l3ax#a}*G&s&X> zV;ajCjB%pkWe?a}(O;;4?L*^q!7-zu4`>psO(lj2mJUcM0a808@yZSh$#F~ONje8z`ryLQf1S2@k1#{I}4v5k2xju#X zcRPA)Yh_jk7w@|++*kXvg~Lbr%wQ-3d)+4wdBji5vV!vxo|#pzfqeqnbp$i->_P^} zt&>EjB=&f$`E@>t7<6g@#_#L~91BP~=-4ytERo{I*dT;R&ofZ za>osAKUcraOY9e~qcYSLUdKM^OR$f*iJZU6qj4sm!VXB;0r}b)(wRMkpWpW$2NUQd z(mCcn2JGFg8^;eg?+rRG)g^@-C%qTZb4u%CIyTp0DJp?gfb;M+XMq z7FRq;0p-0IN9<|o#lptFB!!+nHLpn=nlLOObtg;qFmZ1#*dy_wF@R%gIgOIjY}_7U zLnn(_?OWnGlh=Ar-YHGJ-?}Ge`+TnR9TBqolBC5!Blq%2cT7(dXD*O_Fln^&Bi%vV z@A?v5f3>pd(xn*A2lt4I@wv2Tb48pY{x0)!`%uFBosZbdq+G6y;|ryF<7Uo9F$X-t zxnVwR7Qaf@mGiui8x`|*ldaW7knLVkw7O86JPsa!I=WzCj=E1odYSTQ>1GqvCE~CeUIT5$ zVxX?ysOa?9_k|GOw5^GU&K3@qGFD(b0Sm@KBiQWyLeiS{fS>K2-g70^Cx+T-SYQAR z9Qcq>O9d4%dc}u1)|uDwUbd8SR-1asKE%BaPwL~8=L+)<@)Lv_!oU8 zUu$h?Gib0%cmw3mxIFxm%jRa;dd<^cy0}h#;q&_LbYGlp!in?Dm5vSDCK41|JBpQY z!OT^Bzw6KYTqmW#(x$%G>duqO-}VYOO_$JEI(%cRX6`2wKJ^7k2ceX^V&^g49hvTt zR*oqX^?eacRg^6|A$x;c8!8e$(ns*W%9S0quHX+aV|B|5Z;T>voEVRfYl?6Tbed5I z&7d)Wf3q!UrrQ|;U?*A0A^(GI!{6-hV885V!Vsi76KD_jd02k-OISq-)I~~B49;^J zyON?K<+kAd?jsvNMS4hRKjZrOU7VKifzc}v6JqGc3HM!)q_O5sDNKa%GZwn8w+tf; zgoR#Iq6l=5LRJIJ4}pnH#AW3_&L49Ub~X@waSF6XP4WS?ioU{sqFYE~2ki@4DEZ%) z=lmCot%Q=TN;hb8uCs0UBkz&CY$(*K}=b&+VX+ z{qOyT^7o@02!JCU;DbvE_~_$5=9Oy?|KQ(@xwTq}9SbxOQY1?|t4_<|PIOdRtPKz6 zb@k3r>MD477;?aBsE%7DL|m5G0P(jTUb)l>-6zRScTaS~5V4=84ql3Giybi{Ey&_CZ zA11>L2Wu)nAVR{-=pp4DihFSt1P}uCZ+f{+ya(e0wJWLu@{pUc!<-WV_V|R+5Ni*| zvsWa*fssn#Sj7faLg54drs9;Q=xfnsQSap5UhrgwLkt*HAdmeVqTG#zfN|7pZqbI8 zYwkf?4h7F;rw1O8pIUS3-_lLEpS62D9#3Lbc(-S8nv7{g!1GShUX~i~zU4BC%%YG_IbSyQZ_iQq9(Bfk2OcQzg(Be0 zB5|$p^}OPK2D6aQ0wptjrxX3tmI(I!KA)7Xad^m9iE2-G_CkA;XtQq4x>|YfQy>1o zZ~qUjV)>@rCjdMj{{O{*4mdafj<|mRogL|WbIo4LjoH_J>SO=aSO3JH@h3kK@z9T$ zQ0Dov7PiJ~$qgVFN{+>No{|TwVh(2mGN*aE1lu(>6E(E%WjvV=&@M?O&fqVF4Wt)U z)kUzq>LYy@P<}O_W=5xCcNOn1E(F2Z7*{8vp+G%bMU%vctq_rpy|J`IIfZus&kw{z zE`5wtJ{crPVhnq~v8P)Xl7qy7r@U8AiL>$+AU<{ti@>;|rR*cd&@vrnGTl?V>6&10 z=6mRECEI%9poGyuL^>6CdZIuaXU~9O{^&2st*{4p9)GL%*I^pZ%KC1e0Vh^6Gmk5v z`;G$7pgNV1DV}!|=f4o~WsqGN>(mn08DVB?A()(Msr~m_x49~XabjPE+Kzcyfbi;a z1k#iduC4?5fSW?YS zfoHQ{XJ#&;92>2H-?-l(D9m9#^|4Z-okF95Y2lTFoh|wxR2JpE_V6W2%vd}!ykEE zm~PpHE9ne*mEKi*I+|HBhBjk=pH^~EW-yCeE{a_{i?arV5y&^D)w0p!p#09HBDFo zHvec|;6q2BDceMp+TLxT$NTX;4vm36fI+1le~&>G%YHbGmOS9->-ryHrX0Bl%oEKJ z$Q2I}F!;=f?NqOixccw_O7`}Q@>CFlE%^_kh9{yjA_iJEkmDRJ4#16@nBRyf$T__4 z1G4JzD1{aU7qYJ*wUWPjw_}`Hn)G5c4)pI=zIE0@r9pi}jq@lO3RGXQ_#b4no?qz#tMAC?LGOMlVcUw{*kBl;T$FslYdr;Iq|(S5oJZ6e zy!7OaN&Gv4GOO`l5gjt*)iW4-F4V+uDnNV?mJ^(#5%o{BH`?gr>=^h__kj?mm?%Tk z>RWMc6y(%9(d(nQVYKGq8Y2H{k~oh7iE#Upb*ttT$%=t!K&_<~;@$QB+~)mfn;DTg=z`QBM59@!T4yoMv4JV1N!T!G!N@1OnuC`SmuPC3E=hQIT@w{AT3(f53xU-N&Mrg)48oEY3HtU!j8ZY~ma z8`Iao8>MGiv67bt9+nn%To6-PDFqK5fPOKKUEa3x+14-hjPxQ&YpFJS60I1Tl_aOJ zf=)={7^sgtKs?v0?wNDJWodwMTBnYcmKzDYkI|%>32yv-PHLs&Bl#RRXm9Na$T!gf zEuU@^f`EcU1^SOQCY-ZmmG*aB54bJme*GPDqE(`**}Ou^?}$<^@!e?{qe7*o$QYMH z^{K|>dr9ZUSe1;#e=FyIG|<@ej0|U|FeCNwzY98E<7i zDX28y@Pvp{*_f+7mHLqBFKCz{(Ob>WMG>s`oB(wO6o0JOjB!!(3PX=r^q2sbpCI2S zk!*@MOeN7SjVu_td-#3z9p_j<(`$a(3l(vpv03|p@%EFUHzAmxA!MS%v{YyvM2H*)dmgiom|7eDQ&GD1}kToLBGZr2PbtdZ3j6;Wv!< zq=P#M%FNrq`4`dbx7rJV+H)vYP6@HYI4_KF@33aaa*ZNz1LiH4!42inonJ{XEjcnnSRR%v81Ta=WWdzPo7@xF!JJXU&;y+fv z^K+_?x&6F;zepHThso}?X?)rLF-opR$p)@r0huPD|5ehN6zoo@5CDcM(CAeyB||^P zO9;dY*K<#Cexq<55)uN}hK$xD>THeYvmcR_hU>m%A0cdM$4$u_O{IZMC_qHszWv;v z`W{ZPujyg``_TUb{(po39Oe8lfQGL9{Ljss&)m9m^V5I++M_?@Ut`*vF86Eejiz!H zIP`?m63x69$`dv;rh&n4F@a$<@eFyjGI~AzyD@l~Gl}h8Ik6<0OD43Y3QyUlW#`a|hW&3ac6uJeS5Y85NlKxY9ON z8mH(aolsS7{7fl{v4aQ3ha%+(=Ye$QJGB;!YJD62;u7_2$U5f?>ky71=HW0^54Hja zcDzaH;D8<&=!H}O2j!YZ0-`uZNG=23;8aS@E{#(x;Za<7vs>}&y~GtLPc&0u#am?{ zD(po^$SnC^WTxt#r2vZ1FPTPhg3)i#i?kXcYuvVSn%7!OBgdL{4|Z)85umQmGv7e; zp#wB%P&Oi2FBmIi4re%&41}HH6E-qxfH5pW>CDP3(4;tnQm6QEOf~hZYL`*#Q%UBD zfEDwA$ZNUoJutR#KUv$4ba&Vd@ptfkgd^FHUt?U{Ati?)r5=HR=~GSUKL}C5HyH?D zsTL(yKH05Rs8}b?S&2Os##Jb0p?)FqVUVdE`(e#J%)=Jp(!utlBZ9S*%4y@Xm;1F3 z|Et23cdjQ>R`UkmKUdgfYuZj8$&@`wXR_3q-;zE$uV1}`bIt2-@cue;)7W7sE8qLh>D(*+FX<#4rEU7#-sK$4y08%!|J_3 z8cN&tetB23f!ijmr5&sbMX|RV{nErAJKj1blgYSWk={;Amu|qqX{=zO!k5%}Ygw746yu!RFLc%^y zdz&nasoZer)VxGK?^SlH>*djb3@wyqN(yqHnv+ZoNaD~Cfn-EwYq%^A6YC4bZ>)xbFub|40+bAC4N%Iu zDj@O`N`T=N)kjV-=uobKM_c(1fW=9grP%-QahmmJ@N%*plnKWABZLNfT+pyOp+1BC zhENEMKNYMogbEqVpg`YoJ)4HMlI_K#30aV_0*H(s%1Ao0E)SY_oD>% zK%W-SpR1lYv;MfpXgE9#C6Ee}`!lZ`h4i43AC*)8()=#CCs==cx3;G{&k-e+>GZhf zEFPQBp}a=PtF_O5Z0mZjK^|wwo6jwnYpPoZ&9D7dbFd64*h8X7vGE2V;k zrrtdu<1RMn_zEM#_Nxc-Yrd1{Ldi$w;>3^j9rx}${}-Qo=8ylQ9k2hq6Ao~n@c*M6 z27n_Bz~7&p72wXD7w)|GeZTn8)5ViNo39-aK2Q;sTMN7(jjhM8wGg|&tIs$|SZB%h zKK4`Id85;&(;GNkJ!KOB1g!FU%UbOtJ;A$#!uHB;xB}tBqwjZ1Y1QpF5qfuG5|eT& zvp}L1xz)EgxuaxxAu9_2`U!AJu-C&_Ab&Iz>%<-s2&xamBOMb;wqq9Ogsy`CA>B@4 zrKdPBAbMjG?l%Gj*HeL^Za{=b`a0a~I)jmUAe3Q!2(oJct-(G~hJ=F6uMD6gYP^^0 z78$Y}*4z!QFJ9inHvvUuegA-Gv~sYo^Qd6aHCGtJ&o)<%`hXW32Ty)r{DIdyAUjmF zI~<7Nqm~ZmVDI@Td=FKYhRNRRiCEfU1i_jDsCiF(=g_a>Ot4ok>RAymU<`yWK?xg- z)yJNLIfax5%lca>0tf!CbqsKIXY>=GT$xAEri)U5(JVfw^X`|t73rkSTVq{ABG>h6 ztnH%l*)kPDZzxKpjfYqDH_S=32^for#v6dsSBOypIVW~|$SDnEqVXis#U@S}3uvUv z-k6OQaa77s7-tzLjW#v=eIWL~&89#Z!N1Wu)qNtu!b+Tt^DR1(C??eddGa>_qIiOy ztGAi`Kd{%BIC;{ZUHh_4XC&_&;ACN;^T=%ZG)Grv6_$h%9OQip)8sjAkYl3uLbjt# ztLxB3jv_GHqp)N)|_9a@vT{<0DSfAY#%t1K;@oVJm=brgvzw`FZ zr*GVc0btrk1|0DJFFq2$qon`#_h&+A{F%GIpL_a`|GAvzH*|Bm=+>Pi;_LI}ntaB! z;W7{CsTSgj<(nDTjcedNF!0H!1w7lEp?*Vq>-k%Dna%%+7-}01iuXHytrVQ#2AX=4 z;z-r|g7P7qm#po$3k8SG;SuE_ssCRYaF(%4j_HhEwL&Uyt-S)4e#&Kf^Rhh6GhzcZ zw#mI#C9ChyE^{Y1XM41pIpjl1U(3$E8lqM22&cn_#T!Lh2ISxy&Od+qPwju+0!@A`-yQ@RjLM0X!_|^C--CQJr z)yf*1LJ}~|Uy(9a-0(1u17lV>ck13clNP|_&$Tm?M&4TQ@2%r zxQfHzZT7^R*qJWg_c9$fTu+&?=EnvJ>|7th^|{9KIphLj9gNb@YY=*cj51!jIF~sV zxELX9mAZH^ zZ&Lj3Y+d|H{~wm^2I)0nMfA{wDf9=(m{`rg6CC+F3Ka}|c0dSNDPX@UEnN}3Q z^LM`o1@Oo?z3XS=rBJNssUVyz2y3GeZ$=I9cg2qu1im%_9NNSZuato!G{QlGEybg)2kELeJr*s~1P@Hf zJ_2mkV2-W+@2uU}>zmDmXI{cy^~?|pdB}sGyhlocOD_PN97Ol|X90wC+QDDiopQA`=kUN_`X}%v-n?Oh{SPtwU>!frECX zAAlCz;VZ=qrZ1w9E#5c)TfVKdQF$Rh52#2M27XPlbfCi&nmAzU^kXubKdQ9#R zBs=k*1Q)~An3ilQ3q!8teo!cARIbm60nb`Gb4Q&!fl31zD&!@`3CM$sX#^nm$T~4% zRClA!*zX3{_96@%LS1BeA8h~^YD9`ptsgvagWRt6j_MQce=$z1FFlt;5!Kp&{Qv~E zoHs&bDFX|_jhJD@@%jk^hsf6(LlnO<;Vw}DF?szT@MP1l`qnU`?1*|(VN5u`1h12! zVa|C9ppnSI(i?iQr=TCS_X}+RC2z@6*KDjAKTI!g3l4q*Fn2r(STKBX5l=m1# zHaZ}q%D`cwU8JWBtXZ9(3M9prAx$`lCy*H8w5oT1kN5vievkM6lW+8S{{8n^+yIum zqZ|f+!vN580NlS3z|VT~*2kZE@B4nm|8bh)5gzKP_&Q$7in~3psE#3cV6tw!wv0Wj zXQi3>SqSS@9Z+y+S5eBYVNIN#Tqv306hU3Q9Y~#m@bKL5v=hdy&7Wi}RswsG;Xw&) z>Ql~Ce{)D#ugp{|G&g#tpG$+11w%CyIrM&SID?>SL1}3;YJqW7Zf)V;<`+c!_dP}; z2n%^^(T>K;MJS@fU~I5bG50}ssfHYCfd2DgJI%Ft=@qQ`dC}#E! zhaM%+KAG?;&NeE%6#x^8qda_mka4^n3j;>E4s?`MG&<@!nrpiCOM%5z}B4nG5Ni!LII%WlIDfl@yHm0~CJl6zAbzQ$@vd`SN zZf9Hc98}cu%t`lbZqJPj8C z&+cS+Jv^D5D_-IXsUUu7D*%qI?F+q=GF^a73-Z}zGW4P&C6X}p>Th{g0P_Yo=&HYl z_$T`J(o4g+GC1Tm{vov$l@2qWi@Yi?OU|tL;2?^6GUk@DNRrSox_sgaYpNSKiwOU3 zBo-V1015ypKt;=_osgF~n4PAFD<=;4*jh&2?>QMXwrL-s0z&|?l|1Lor}7+#zeefM zw>C|5Wgy1Cyf5+JTb`nbJ# zX!IPFXcrq>G!0ycSin8zQ!=0&h&->EE?(1c4&WSf%z7hls8cqtVVj%M{+{(U`cjBI zq@r!C{6v{aMzYx}Ra{s1CS|Q^Mx&{(_n6jsO-380R`pt7*|Ap7Y%~!HA%TNLvLT~^ z&rf>Q_7J}L%%}c`-+t%Tvp4rV|N3tp(Eq~#aFn;146y&**Y$tD@#=Rx_1^FMYkzZ0 z<6Y^6q@bZ(JfgGitJ3gbWX`?!!W#{}VGb+cM%7yq`oh#|TNYd$H^&GnAKv&}cbnXC z4>ytiyS~6{TaZuQ8-#C1K@@ARE~5wzQKfVR#&Q5oHgwSHNRdb!M9-wr4nYzidboB9z_)uvh!}_Ubi;ipzNvnr#jEf~M-u*?7 z9oNbd8+e@2e!`kF;jyf7WhM$3a7P4Fh-vMsw!%l0im$JC113%gE)hMFi6QcuL6Uq48QenEZ*8DQfn7r{mO42PUB~CM(3Ru zFAn!hu0_lo%C$;q4D~M<<52%G211MowOQ6P>@&SmzJ+Nnp(H+>y{r&kv3{o;XMN)#`f(QoRHBoR;|WG>oGIe9Qe zzvKk6J}S@KP1l$kijETIsx=`JOSIDO?T^ph^*AK&L!Y?F!>KHjo9TD5KOo>>goBsA zLnuJca<1 zzqm+94j3ZY3mRX--c_IK2fC`B0R@yO8qC4vZyZ7?xJBTktQk3#*B_Y_XlOA}pa?ik ztjL0#@KCG(0Tq&gka`<1q9cTjA}L*rrHg{kbc!M(jgt4USG+!4ubZIOg1IxsAK47D zu4{Mzs*Md)2yN;Fn3rpnOI{7MBwZq$(H>qp>pG*+!i8dzcKSJ`otuYsFc7 z0af6Ex930^Lbwhkx~e%h@H_Y0w$Vf>Woe(Wp`=s3u%GhnGzd8)&c)D1o(ZxE_d3VF z!#-<5nJ_1sXP}MQA_nk+He0|fqDS=`&qMx z`JevGzAkZgy#Jx#9|(Y>JQy;-^N|4j_bab`{YSs=_xnx1dYVq}yRSJJ#<+7qPG5^$ z{wRX6cO?emRNHCACbChf>hdYM zI^+an)4*MIf2MPV!*f${jyX^4e|o1v(@3OmYQZ zR;PVqiT;nhm~46j;k|6(=B?a7@!t-qONPIRv=?i0L+(3qT@QJ-71lBqgHzVN9z)r7 zjIy&=m1D5e_NjYS|!g6qR%^ayO%CiM-H1A1td!&SiLk~1h3+}bi zEBnX>pIQ4%MoqZnTjzbHVP)Hke7Me0x8D57fAzwr|I#<_d;a%{{|@rM!vJuUe^D~P z{onV)1bqE`Hy?ZKM?Zh<;UD}Prt?c1XGP}S&MayVy$Rx`;9`f39?a=#s+34stX*uJ zq!LWXB$Uy?104&l=@1Cs>^d6cTF5n|+5mSp7v#aUN6oD5XoQU-$`j1XtyuIMAQ`^d z4;)KJk@S7e9yLN!$-;kX!b*@woCO|YjG$ul!&wDDL6W>3)C`WRr(>f6ztVdgFcDCb1lnRw-T0bdn;u%m+=}LuO5ER zd#2|LQ{y)tLz}iiI0s^^(E53 z!d;?Pf~vRbmki&+`@+zMw`NI6$gcNVm!vFi?`Kr5Xc+g}3jQ1Hw>7Vn=YD8E@z^@+ z1eIVpNMt;R64&gDk>bX2>G$&i;}65^O0Hb&fCv)#8nH(9`v{_`8ov=jkz$|+oc+gg zxgpqam}psR3tmEFIAp}~6cl`qx1U;l2DHSs^K1;CkzJzZ4Z_)av@9fyXJ70Ycc1>m zhko}TndjU0&V>ITW4yP|s zusR_`Rr&x*hKb-k*<2q2$hNRR-IG-Qr2JE_?%~AhU>q8D~rv>`kB27;hu7F>bd5S>@hYPruCMBbW8D#h68UEk_R@q+z~dElBJzV@3e9_Xm5Ln zm7g84rJ=Y~9;N~xv;$vu#v9isLD6ZYCTHIc9=PSSNT^_ziBS+ zluR4-Hm}=4acK>O(LX{)v>rBZXm%eTXk?+n5gSR_Dd5#{={XBrG*U=$=N5k2%M@^nyXANa7l|JJ#;?UH1`|Mj^Dh7*eagAB4+X#mpa51 zE9(e)7^#$|t+e%SrhFm18rO&s*w$Qnik$*)3}yiUREto-d4n}P?Fr%YxxwhL?|XVH zw3(ROlZM+>Uf7(OVyiP+=pJpt^|R{VAxc^q6RdFk7*gKG%&I>@z=kn~N3%9$8C`-N zS6bgUh?s_;nAv;n@a{1H^%LU4`I53Zh^)l~c~`#|xn7h!20g}#%Y`*b^I1yusV8Wh zW}S$mKH@0>-h1cP!(YtmmczmYgrn>><{E{9QTl z#|7r`iERClyAXk1A$O|IqxOvI^MdeebR1r$u%JlHf23UuK=GFkyq!cIe4AozYevd$f4wy+o-gA9`xP?B|uyPpE*= zeVX%VdrUN>TXy}-_1W`Ee9OJgCHnM%Mn?ANj*P5@jSyUI&>+OK5~z(haG+wH9FN5f%XRg&f|fbgyF0r{#TJaQbsTRPqFZhYwkR1-Qk9Z zGAJ@q*gIk)Z1x2crjo0%7Zlw6?hQITBl~dxQJu*wojY9d{OX4@`FPMr^vEQ*D^{M4 z&jD$nC%-WwhGe)QQ8V;LTM9x$zBTYT_F%2wPXV`l@S&X8fxL}S?!!*Q&tDta$chNa{KGHxnC zE2Hh$yFb(S^oh$oM|Q9M9${0x!F@N>;m!-s{k4Dl=8f;ZzN7g2zeno7!vOFF zU5=E1J1BrVcRur7?|#oO{NF{K9;u(r(wvz^(j&)TC^prH7Zdu?0vb4&j45QfHB&It z4sp|U8Y~vX)YsGMTE`)}>&-R{eR)=j8g4*lGxkExlDIFr#odH#96ngBnTH++6MCFj zk3G0N#l%ovD;DT`D4sGv_+aFs-82>H4o#C_XP#PnE>N39r2q)^-R1n8MNpOmDgpToTD@WjZ7HwFzPjUL+pWWg!X z9M+0)zrJU^gU^iYy-f7_LP-q3=F8i7MF=>U=8og&?yR-OecipgJq(lRuKI4XSlO?> zA5pMpBLvp#_AYGc1D{9uV^JXmk>fCDqDh#(JCO2RJ}%+u&asZCThPBqWN)F_R$gvdSO^Q_`Z_(>DFubcD!M~86OARuT-uQYJoCm=a zz8$hoaq1=a==02(+&pZL+g>uy{g>sC3+yLds>)#9 zn|VxRp4!Rx_vxBlY&5O2NPA}VTC#Q0v%IBc-Rss4vZg1iPOr(xp~J%W-?{VLpLzBZ z|HW7AEB^cO|L3RvJIY}II7)1OXN~LuqX6#Qe(v_;@BZnpKXmc_Uj}%g&#Is84h_N<(;S4(TiclBXnFYBD*I2 zWj`NI80)$Y-dB7#z@$v*XU1E-DkIi><239f;F(B|sq=%=MWc;SLXeRFYnF^m6AURt zs_<_f%!=njt1bEm2F%kk?y~z$IDQBN@|+7P9es%r7>|Gq(wIr*0O>tBwyXo-;>!=>c1f>qAjMQh z6cnTagy(sMwnu<@ANQAHS@(E8pueilAWXILqxD75bSrTzLM5&J41-&@f1_7m^)(< zE`>vPUwHb%|Nif~cklY`2ju-fGxgtL5I9Hxk8-~gqr~c^Qn&+F} zQEZ}61+*a$*b3!Si#@z9+!RZ(+k$fOJLLEdv=a40vjvyuGkT)BeWEm_%L}Ep3K;^R zbR~PK_o^eWIf(jZZkp;}bPrpd*}SVq`KL&0OiaU@24Y`%kA3-kcS2f7&d6&8xmKT8 zVXl5t3o{HM$6;4>hH`5dCl5fDXUgk7#a=VcK{~9U{IQWX8dRS(gC{SNibH91+@QSm zilk6KRIdy_MWi>{MsE}ibT9-`t>gFdTaZ}Jo>8teo+YWUnHZsZi|LwmC%`+LgPsJ1 z({jM&T&>T5V(7q`wO__-a(V{_-h)f>HlQnm~Q9PG=^S7RnF2#Q0z2RuF?a!Y zmWmUn2UxkX3% zy1?@YpFtEKmf~zF^K{PQZ@nU|Hfi6a2&>LoKedMvt;V)VnmXCj++4{+g)5;PqV%kY z>j!czeh!XdpB+MbOU?f`Z+zgtx^?SgH>}*B6YuXO@JRjl_Llv|R z1$VbzZKc;5*mS{QzqHr!(aE}@?X<|+qw4m6y>-nu3v1P9la*bC3wWgaH1&jdu^YFK5E zZR0rA!2tb&duYEHLRu*OWAq0#6z3IQz0WZqzdE>Q8WjN90tB8)H6RKe^N7Gag|`cF z(V?)6YNH{OEF(@!J7&a=FaHJ{UP$DOf}D5Ch+N zy`l^n@c?5KkQpwKHTNnsqzqdQO{je@r};{jl{bC}4$u2LG==ul=wZxU51rj+zeJ%S zdRbSWvLBnGG3peixy*?r|7OS6`-M>Bewj@QP1j8IF5QOb7Zcf5KeK;fT+y2RRuqOy z0QI$3BIGn^##Xzz`lh{Kdwqz$Me~$i_mX>sEo5HS^*~~b#$LN*kzv39igdl!{U1Qsjp|F!4_@yuP>!KJKTNkst{Kc1 z&PO8#UC+z;&9D8~2Y>5-DJB1W@4prPBlX|gUk(Gn!3EG?*MIgx0dBnho!6gy?=QM` zx_H;m+CFf|z=xdfMG6KO{7yr&Avm!@+#k$(hT*_4j6w5NkX|yIwzycRqy(wI1n(l7#oHaP>Hth%A{CcbHb_vl#kHzNq8j> zo@OR+NC#Qtdt_Bx07W;PbvR^-V*r_=QdZy3O43&cOd3=+I1jx21lB6APyM>heE`Oy zQ-$?NIg=p|2Y43pp~zHB&I)EZAyF;7u%WcCTq21+;~N-qroAvK?q5J>v|+%gsdW@< zjdHNFmEGPXXn&j&jJRO{h{pKoy%ucrB`=cwCNu`)6wRI(f|j47R2!A!_X`z=HI{d$ z_EeEJ%6ZT!XWnbrXQ~Hfpp-z>$>)}6e>3@Gz-U`_;GVE=5`-*xos6L41>bYO4D_N1 zM^Og$Y__w$ahQs)t7kLrp9l-1LO@Q7F<{|2Z!lCsh7ijS>yFZsPGkOae~^rZN2It% zHn~d5D0%bwXaCHGzP6+A8wqfD|Bn!WqkJAi1o{cM5B=W_1$g?y|G^*2y!#11 zos7o%$;Ek7xiESeZQ<08mCByc9nbT%3Ce*D(lf_yH9nHkEDm`_Sk;6 zSvRvcbfoh?*iRc0lFnT{yA3jSsnLEX$Ig8a!yX@Ax3*uLNdssJ7?$cLpe=w(;5)OQ zuPUs5o8r(wN|Tk5yP^y>P4I&N+Lq^(Om%s+IsfCWVKw|f_BdZC+Xzp(I$-7EB~B;+ zG$5)uov76P`X9YNutyep?dg#5;Tt_dG{&?Ef~?w+t#K&r`qP`#`;0 zqi!-B$n_X&y_Hcxdkgp}>K>9s((8s$f$;jB;I61SmD(G;h8X(Z%RBnO1$BqCWAF9k6~AR@t%iv$W< zm0kxGn!->9hkK;zwQc$Bk##;bRFll<R! z?OUJtAFjXn<j&{|^MfQQp}ofc`o9Zk}(<4?pz%KK$_GKk~OgGs1zBp6rk( z(z)5olNYjx(gIv1NfJ{Y5L&+Jl_7&WJ%~^?6Yf5$rZZy31_|KImnKp!H!|0Kx;Ys~O`bzo?t zj7bM$97u5ucqHKcSlW>@V(EJVA44aR{(&))_8@Zn`5O-k)sKb~3zogy<1xsTw+aS) z@f3`LhN{yd(s-=9_v3zqkw~s_ z9GvU@CZQu5YbAY*!LcLcT};W=m^5Ka}CC#6Ic3_Y-ZQ@M{s zh(IQg3g*A&es!86YV9!!2LEL!)KoC~%PDI9WpeFxk`eTm&`2b?!qMntwS< zPHMbCy6W|d|IL)qC0nxAYO^vqymh~Em>s{%n#*GpYkZAotygtUKyGu)86aAkc=uYj z(0>)@PQuIv#@;yZYu}!8=6qA=3Hl~Y5PnIfy7CkAE5| zTFW!nkC;LRvxdr_?_Jhk0ild~^nAfRkO|!yo=_ z|L!#3yF4HIe|Y~71He1199#f9%0K@5`fK0x{CnQ_E8aiGV?PPXuwd-+d@&{p3N;n1 z4ChT(iH5ul@9~1HLvDQs1<{aMy-I0nR>jzKV~qlDD=d^SBW2CfpB>|U{~eWz9yft| z5__)pUwhv0LofuR>hFg$m$Wu|^|3c87AcZ_<^w3SW;h1RFTTLDAXofOWNG#9lx*mr zhk>4T__3j@8EZUi;DB7?%A5Es4XiKi`pek^dIBr{o=W{y^#%hyu7%glks_2Idfi!Q zd!caHqesSm^butt*d{!fh8&9=!!GE7p?(fS2Z-Zo#Iy&;t{*cOV(kJ$bEXXfHatQm z#8^!Z4jga=L2u?``sCUZM@1KNOc^fEF!lppPvu5)BM|@1F^b`AE*yIl8lyCydD!<_ zmCY{7bppz)=6}f-#u4`;Ev(bBp&F7G5kaEA>K|wy(|dLvclImh%JBSl008GT+k^J) zq^sOgYD~>Z(zilFp;JHS2?q-3PD)u8wQZlYrRwI$74(Jsq0(-SDI#h3G=fP+(WZp< zQ|uieb<|iR%^=23MgcYg?@yh(BT*Ca5*|6$j)K?d-7Jf_@4l48o(|7j58t7*kI`0|AztKon8(u0Nm66_s!Qo{H^bP|1bTG5vL~rQ(rBm6-zbO+8}mY zY$jCp^(u78C3YQlo~Y32OQ*s=R3zBftd+&s=%u_lz(3F%!-eV z5sf!~O$C$HAA!D&mdM9NMi-0)4=MC;&|?B6tUt5_$EoqaCOr*;Dx+Rd6hV|Hg2J$3 zi(LPcPzQ4Zf315aAT?%s$>q(+^{^g>q=PXR*Tf>%l8W+(qDpucLO!Jb!u#PvXZCsh z`5bud;HeN{B~Sna=`!6=T%|p-%x0$Ms>5p1?L)!3kp1#T;=>UqD%fZ%Nb15+S+n0$ z=|TUEfw3TCe>&V@>I>$t+HVptdwN)UZj?022;S-;GRWdopg|=tM~#B<`xM)(lKw8L z|1Oq3VhUB{uO}FIfPgV4wuQ0x^u`y77Zhvs;ZUE-%PWKhPzgk$AO$B!JC^mKAtJ@v z&Ud2CkUbgmD4Rr`j4^r{Lf=)Y*z(G5dYMO$lFL2|IXM=b9*^XyPDv>44Je__TBT5J zAsvQ)cONh|Tzy?iPwk7?+c>@syHoqEn7JZ-kvBbR&0?K{X=M>IhxhYYVQ3V@QaIdG z2M8X_m)O;T0u;75(7Khgi?ppATxPT8HkmI7GAJn&z%YH zkRma_?wiGoyt%2QeUgqUG?ADUcQV~N{b0exRhemPr=$Hh=#J9t^rMPKImL0i#%arMSxjmSV0*_7fB(AZP#u2P_i@FDo5mfMj!t zM>d%u4R-=Z87F5Y)9yzzZ#szG)9FrRA`J?x-sW1SSBB! z)A)2+iR7;OYzZSV5f$qnOfelT5+U0qnQ~sT*Fj_P@2K&-5OE#aj<&MDiIy>h9xg28 zx9``G9akyj(gsG>EvC@HmWbt2PUrcuJfC&HpD)>RsEjggfWRw=D9V}ZJPyr6UNWzn zLxzaZHTAA(8mH6uj+;Y?3|}9dKU5q!mzj1gZQQ6|aXsb}lx-9Iv2C@#DoX<)*>iUb zIVZ|6VS_TqgMC0WM6vsG|3S8t#-LCv@aW5+m)4;TFwTOzMrq}$|B&s#d#dNGr+B-E zP=sq7M%9@{gd(vm*)850I70l;aOj8Te8_OGkhdvuR>A!hVu14KxOuv7(h~tQTsSD6y5I<|e0SlH$|CLQRgN zeh)$Ag1Ngz+?E|t-*RpBU4=wgIaeF1U5z-XNAOdv_GzUVQoVzV7b z>u^SUJ%?x1SIpOXH(!F!Do=gMmPJ|Bk!*NYq7C-6%X5~z%bjLqQEYvv#F}tSau*60 zP`-NakO?r0IN^P0FxTQJkXcl=r1be=(E3O3?`1w7D+r^bOGL5p~6Gygt8jlQVa_7BL zGVR%rP5M?=xGmW8TjP>sBi$c~XYBatP(>H7`MD$in7dZ=Sue^C<+^^m`{Ij#?+<;C zbMN*8Q2y~by#EIR;3!|HC;(i0J`&*d8{hsd?|c8R`W0h5^xjOa2YVG~oGUj-9)g1# zAe9BhjiSo*^ybVsska`Khf{M2PUZN&Dauo{dL=ukCjSNr;5^SQ40r~)j(dn+0dDLr zb_&q5oRNDXAuBrw7R%brluWeHBWG_1MH@_Il~#xY)^Z&abj=u@ht63KW^K6Jdky0y zBNl?Tsmo6u`YS9eRA*Xc!h1CpvhpNk(jyu0NQTq+xzJO=OMFjxbZRUhQx0cgAG!p+ z?Dh!J1Ihj+$zlKO-ph=BavfySLp{(AdTLtPBI1UJX3kNfdBkG3&Wo|WC!#!jHYAih zSPpzHDsR6J)3e4v?EMj3>uj)aI8(D0$*3r~KaC3_kXTO^kPHUu+r@Cl8Ug?=0>-vm zR!B}51LFZD&7Mj^@FNhORVhR3V819r2sx>j0)0_8j_JfT33l9eor+b_dTZhMVEh2r zZ&CzSn;^p?`}CY?LXkSpc6lo8$;GDW%m`HUY@D^ca;~-WeUyxKM6g&hi@>vFk?V_} zE1H+oOtIz0uR98RTMbslB(?hykW zQ#`nTs7_b=lA)Hos_l1#p|pmPz-MmAd7E=ZgtFkQ?YH3`rDTxug6B8tjq=Bk46)u@ z+%D-eE!rBv@#1`^(U>wkV?J^%p+Us^ojFFb9Sf2KPi|Ahbm!(L{+nk%@n8O<`%wO8 ze0pX7@cthLfG>!0Z~=s`-TrO=zw^D<=jrsww>|dwkNe-oG@Tgh?SmT+qli)Drmy=Z z5wnD2D9s40LjdD;9XChFV(voQc(_6wl@4$&g5yfzZVyEjr@p7iHW2=<--)K^<59;} z{C*L?adTxgmuI10rF?KxHz8FZh95Ue*;kA|-!G^v3=xD#?-)GPcY>@s6&eaXY$ap< zA6{>dR49~TcG@zAeTeLF>9@o?3sSinZ`2P`V(S?iQ@-Dx1vAfsqe8vx5^^%|YRgOd zUdm`Gi@r+FbewV`l%z}A6pTTKl2~ifTn9`zP#A|QQpBoXuucJ3D%vc#sz|xi4<{Vr zq#)IIbr0Lk%qLn$<*l`$Lp>{HO&;;3G0yr@7DYT#YYygk;9Xmr79qBgHY{L2$b@pA z3@_s?^ojz#Pzou>j7BXea#bIA*irbtq z<1>U2^S<4=jLqj`gPROaF06-3QigbzfMH+p?&3i}y$FBTnar77)PH8q-LFPdF{xrvip!!|Re>UzfiAT?sGjySeE_pfp(_fCXL9 z$yqSB`aZqEgf?Eqs~QeyIJ`fu*V1qOh8OA=AHWP5za)1kv=*5!1mD+h_21B3H>s-YDPiVw ziAn(BWvr>u*f+s7oF%EX!;9J$z}M^0-%m9t&_W;0>s($wKyll+%yESx&X6r5CV(G@ z{9CagyJv($Q=A*zN);22kbHAz8h010N{8z1_cL4yJ-!)o(7?NGbZ0Saz20vxGsA1j ztGyp4rnhvwKe(6j6h;ip9lV-Q`aXYOvj$j4Qn4Wc;cKco93;-GpSkNclH!)JfXgRkxd*((<(jn(SufrB&(VZ@f3nz02+w%t46c%w5)8Q{-y>gt^-XC7=fL4#5V;!AW(EOnwn0T0{N8X2t4l%wjCO!)|i?S${hydCxk zJDUdAG4=?@oAU&pW=+U0hB^sjZqWjn>wB=@EhJv9KWYi&gT4L?uOcB_Unp}zL7|-> z{B%4t&Shgctw z5_17J%+>cc!mQr|sa5qy_=~tI%#V~ZL=N{I)=Ajjw=+m1b5eHP5HV}H#g}n=d2RPKJO{F8NIa|ZxgQXo0^$9~b!EW!c3_}nJ=i{|@V>B_}K1heW zQ_a0-zX*O5gJ%W5ic`xuM{qc-!TssOjpW%z5+B-nFHujjrDt{Uc@xXI^lit1TEY|C zhY|IvLwk%)(XcwMxqDMMFTDHEELw@leQ1)fNwMZKH_mT%~aeEEaCd1Z*UJ3k<^|+ zs5R@ny`=MAy&wO~a4R-5>gfBvjoW<$BTH|%=fC|sWkc&{FAtf#Be60OC59Oou}j3u zyznl8I?6GxurI4fET6<*dJ!T!n~o^HetLv0XV!WPjG+jxORAj)_14Q_94 zD8+tjx1X_ZJinrYpzr6zZ`Tdq--lv&?^GY)rqDV|!@EnUEj>AIA7&Dg+pP5kdrN;F zq|CUDHahw{Ka1&@@7?|EU--m_{@`Eg)BE{zpK1S44)6bA065C$4+XHF7SQDkUVyu| zKXd2WL+}6KqmTWl-x$+0N`X!>;KPN?4ay2E53;s)mX}ED4Isxj*jj1KZjs*MQOvyD zR{raLo+#j*!NVfz? zHob|yKXw@Jk?>d$>`jA#`otTgWXU`Oj?b=AN)zIP8N)wf(BmV+Rj#q+s+(k?QZUIA zfV4yyCG%i(5Kc?E`o?VjAV#v%fkSVNF>(lV)|_p%1fkv(jBj>4CS$o#2m`IEV_l)g zw>&Z<_?@5XIldI<&+ehV>VKwmq&&?UBlkM~JB<3F5(V14?6aj{KBMkq3a&M{+J>i) zlG}2Zh=df%C1gyH-%NRqXCk)veo_fAcAj%{=j~f zihe(o{lfrol+OM0YVoT)@JP=fr5a16<39VOahd-oH(T<_0!8sL1qv zGGD>&X((Wfu-+-JW>`MlYp4&>wl8qzY*7f=v~g^cZ4ug!>=q^S?yFK5-PS~iM*t!( zg9+xQOY)z{b?z8r9E?H0ro7DvDX2K%BGH?>@eq4G6*DiZ>}Orpglx3rR)!5@=2brt znUfBKAjBR9r5MPl5nD?-l%6Sh2xe^U3Ptrg?Divne$^$$$wYQ_=v62~wg+4r6wq+< zHn7mR0S=7XFZQ=g!n`-OJ*`I}(mtnumFGBke|q*yzG`0AxF*KJMoTN|R_;{au4FQV z3MdE>AR@bmh-Edl;ZLpoMv6fpLdKZ6M-jmAQfs3_0&86;xf9G<^AYnHWQj5cd;Ux? z=ATv>N844j$7^qoCgotmGa{L^V%}v~0SYgsBnj#^LeYjDhr#(Q<9%Po;SFQVSA=0xUE7csweiPfMznVi#8EdjZ$qW5S)fPTSErV$`Y8+uS?9%IM?07~*G2cO- zc9VuFav4PCG*9y@&wu(){Ht%i@qt(Flkq>#=K^qfAm0C@93+58IRk351KDXm^Er&5 zAA9N#{)Kz@UipVq61Pa8vL!p=wL@&|!w^z4fFlhc8{R5N?ZKYEpH;FqET90g8t^0{ zroX&UiTFWe`I}F`6WfpdJRVbv7vtf4cvnH&Sg=9ru;dtdLcU#c zKL*^gVPrfx7uxvn`kq8uNwO%tH>bc$qE}+t5$=`xXR_xgL2Gh&1(lVN3p@`tb( z@HRxrA?1qXL;Dzk@)moHIK&~~>eh0VP2BP(dosq@@9(@#_OhcN94AxtT2qH@KD$Rf zv3&!%b_NGLAM(Qz=WkVgxC`4N^hU{9Kz=rhmC$-_WA{c; zTf6H(v`GoXl`eV@xE!>eYpv%z+Y8}w=?$PXG*0Z}MrM=uIh?8gh?k?jGXWvnry>ui zN(>m|HFtp2k>0Y?Wg(?IAcs`m`hK00@4>p^aqG$W9QxsEIxVf=F9U<>(U2`-{i7s` z;It2;a2@+Fj=4W)akWXGrQ8OuQ!HdZC?hfC3+FBv>~`jj_Mp|6DkDsHwo)c0?s3ie zgQ!S{b8YuuwteaEaP55K`q%uy>o5Mp=gvRhpRep6-v0vuaFj1R6u{O!flvSUZb-m9 z-FWkZU;E^{f8j5kV!V&uhZJNo>!4n6bR0f|u)t|ra{rdK8RT&jJ^qX(?qW)Zq3uY! zMqe1Ah&(L2$eWwVzevJU?_msRZ3H9}Yht_c%q;JJuaF}w0)35~{K*=G;^VN*tMlk3 zo(S=eu!o?$#2E8Sj!|6&D?8G31-v_q5ncLko-e1I^sa?CjMnZR<1j#B7abX6LBb&e zfqc)WMJBG~b}V5iI7WBD6d;d+(m{g9x~oXt;#9xGqpCP?c%Gna@JV$&<50`XRb+70 zrZU(R30L)Zg?k`BdyOFsARLVt)n2%;XMCm=Mj1x3SlQ`3BC)(bH^Ld_p7|T~XTdiJ z-;hB{p3t1kA7an<#Bd3tc@$ILu~y7=9mZR0=2B|>a-LU(AI3w^)fz$j-?=KJjEwwu zGL09=^2$3*1ufb4$l_261;@Nr{=j$DPf6JHxPT{mjXRFGKtFMGXSC8DX@=R9-10Df z!Z?OfgpD%rX}!32f!4b5>s=WhO$M zmTA0XH)#9=HA>@(G#C4r;L`Yl`&F_pquyYs?~Km%art2y@OtC%$;Dyld2=u?m!)kEQ})Ln}))*=0e2?vL42euXj4g$5vEG$s(hp*RubhOd?GhuO-x-N8Dbvq9=G$UxRhH(lzqfMJDEnbBHkZ zHIxB;3;7Amg_fiVM!VqnGoyy?^g+qSjAboAi|LITgOGF{jv2z>|M75ycv>n_lKSd3 z#=MhYvuen2Agp#$+?*LZJx(xgLK$#BqpNBT#3WA=4u@_#3NWrtk>~IadeO5!s0`kh zOFd+GRX->{zGJpwV8}vL0MeU*w@SrPk%4$8Cc26;a(Ep`tAaKVb$_A*0YU!FlxcXmQmvg&XgJBA!AaT z_Hi=?zc@D<6Uw0C=zVQ_j{w6!Jin?Qrs8Kg@f^dN*Gr^h|HHG9BVT=0#u@InwDweM5KewZO#qIo+XL$-=iC2y1e)5mw)twzwJLmF5f-x ze{UezM-d-He}@6!9b67Vz-f;!VDGz|H$L+ELy!D`ryhCahyU7$aY9O}K1XkP4WR(e zc@J^3JbNOK^|D}=C&17yLAAHyPvOOyM6?w2#IBy}WJqP<5y9)_xrMQ-6yq93#UyqIJ;%029XKolpckhR ziw6hxflG{SZhKGaj#fcoe=Xa>Cw8cl%dc?D1%tLiBaj*Y0n{~?z6Ir z>^MMEW3MNgp?x1wDC8oEv8E;t-WzDMQC~HFQ0B_h4R5Z{cz9m*mW|{%M<6|AjZ^Zf zY%|AHd$qY?U-kKg^U~ycIENu~f!0kJGO*rQ{kL<#?;j*yCX#|EPR#F|ow0+W9vm+i zRbc?b?>v8&tdM_d5>lm*e;5N8cLzgPMeN9VEslbS&Dr9;Kj+&YeEK7Q_;=pBcm2+; za(_Pbzqg(q-v7e@@Xjv>A>jUXy+PoOSHJPok3aF#UOzqb-e0`J)Gww{1Sdrfmt$FA z374Ej`#en%*o1J1fLB6s=jlToA@s;0e*tEE05hY@xytv8u{N?S&r{BqWr%2iq zujr+BYOu`%QXKfCa>ys3$teXNNdQL#A*Gq*H9>9%Qv?kJG!AqMOwgl?%mFdgA$FCUNQyT@VM* zV9>iKt}sA=A)j8mf~oCdF~C6yEvR~CE@NElNg%X^`RGsN8^NBp^d)3DFE6={l#y;N zygZ*hk3C{&B+o&Z`7p*chi zajndp>)NU(o`Fz;%qxSq!t1J3+w1H)dB08)fYx}R0Odl&UuDf9;)Q$(B-xDCrcrYg z*d2b4{X24g>C?~rPyfoBZ+z$VPPsqB)8CO1q(4X6zrz4BrOo~PlQd#fgCI&`o=HX9`K7HrP1J3t{b18G2 z!a00B4h*kQnnK(YRb{pT#l(u(pwp?-NM?oh78AeGlZG(TgAc<2?x`vdo>TE|!kt@D zg7QWWxkjG(i>nSika^ZYo{4s_q*(^*X#76pkdhoDs(kiZBV%JvqPi&73*Fuw^ z(va%X=MxV!3tr-cvVxGK4&fES{QM}m$0UFbSEBrckQz|N7GuNE+|m9P!%%$oGp`lO ziF1>Odxk%y8`!V6P?l=EB|mLiG|CXegS_`>W92%kKGm9*H29}HyP@qTfw+UAQ6zwb z+f0&8Pw{97h1piiPJ_Tw|55I3>?W#=@&4`|^_lh*^jT-<^_pI5K4lC0P&?k*KWv;( zO$*iIxq`_vl>S&DM16`x7M=^zO!#B%&&fk)BY*OIArx5CW*9awx-e3}DWd}8zotmZ zaqZ)&`BdW4pz1Uf)yL^Qh|2#o-M#+em;W0tzwoy|X2pL$=D+>Dh5sMKe}@6!DDOxV z!1s;W{d>O8ulniZ^vDmI0%=gV0G8#No3R$aB2_D|E)TPH2V%-9#!?ES z&huuL&+L_d7ai^h=WK^T&fT)G7r9-fIEqPL7kKhSuC!YG5%Wn;D+=QD&4mCcJNwP3 z0w0nIA9Xxq@^H}`0Ae6FdX+>-f%3XXfFyD8%ngt*G3F1cc|dqqL{E&{OZAhK#YVxX z$^;G&_3mDgj8=rC*&!9xiH8(;i{=_b)|6gH9drX889`s?%Zl-@OA*hnT-Q+0aX^BM zkgSL$2orCW4jNE0odqG9gSGe-{GGyIg7WJFHRgR+Zxz0S2&TFe`KuFQEX-98k7tut z3d$VhnX(yFnfrq$m3qNd8>aB}+WOroF-`2*#jID`1$V#lc#hSBj`;8^c^lH312X8j zm>L^~#nd`1McZrE6>_DXS;VVXYj4#1OSWkc%To5yz7vV&q+C!@D#elBeSU|m0k}tu z__A*)JI1&ugD#=QwaQZVs?Ws$FXV*KMq}A~%!6-(5$7uV43&qhujnzh5#+x$dD(Yf zV%wbj5%j8pKrXYN;+eRgaCE<@E!vCOcTl{EAeb*1LIY!+T^+%ipdZu#6D{VYiH~u8 z@?O_jaP=h0Y~zp_wHE{BR%@P&GeLO_wZBTLPHJCooLb~48L|OI-fIDk_`&(Ppxnap zLX=pYW+>y%q@#ItVP_MJUX&HCPqR@te6G)GmO*Cj$TwgAjz9Y8r~m7(+E@Jhf1d&W zKZySh1He1D9E5;-p#UuB@B&Qpd@FCf`Q88Y-S7E@za--HzNszgjmywKZC22c)*G#a zVhIP6bnws~QpN=RL{nTM?1@|tSs)#QSA)>gP^vQIe@|@w#W#Rh$q}__X^Zlj3?_Jb z`xwI+WV)|B*r`-}4H;cso6}6ovzdkK`93z-P{H^`#_0R-XA}s$9(WGYuJsAI*jG5z zPg!};yZsEz=d-yi$Q)i?er25i>S07bK`R7vj3VcQS3eiMAf2Zj{f`4xwDI&vGi2pT zFlc)g8xM3664U1>X8H;jDh*3pc^DET)0r+{fT`;(Pm(ztRvT}Y;`+DBAPm$C(X=X- zBu$%(T|H;;6G+AqhjJ+B^>ivIL9|ihb?ju9apsvbUfC*EeagSa06M^)kNF09)O^r7 zm9ZZBF&1LP2Yf#&)T%;ZqLqhWdT+>lT~|bS;VAQ(|DKWFcoMRwx*6D%dVY6j2H3y#ASGTq)V=h!ZtGiH2~yBPtt<>*saW zijns$a7_TwF#sJO(UQb@Eoc!+FnkG(D1{6LW$i4y?~QKM2iJ>!tTYXTZ91>y!WECqD8A|B97SF~MHrMWj)vuTz+2(gsA6R$1oP)MTE{@VpBb%EKuH^y=Gf(83Rtas6GQ|D(O8X8nJEHK%0F9_p``E zUv8D4(&w1)el0cn!V05({+L%yrVWfQFXr0GJ@RCf0z-QCoxx3*1g;Z$MD?3Mx|_4l zQ!qG1*9*lUm?9-~XujxaqfL!I1q?rUmb12*DqdDP4RpsSO=lB?x@# zekmV>#tH%`C>)A&es8x~2M~C^cvK|XKx5K)8-cVC$zQ|(F;>v)iM;&?7g$gUSKkc_ zmER9b`pMo)8CQByXLv^S9lb3HVjpoWG%>RUB^3O(^)8hX$}pHH0yx3FX#`6`YB!Ys z7H-(4FRKqa0$w6OT;&b(e6feWJk^ZjF0#6a1+tC$R_F{5ff_H$4?R$jqmxiEEeERR zkmKIbw^zSmJgEHi%P8WnF;0+|1-TO~j5O3*p>+T)&9tFH@aO2yZ0@t@jTS|)$xx~l zGT#%Sh+$uR6@w@+Dmt?bv>X?&zka14&!bX`6+()0*2?GAI$VZWc#c_%y4Gv+KuSZx zn)e~+v^Nc`sWT8zTHk9;1&=~b%+HP(aiDEjXSTfwnX=+4?=WOg>w}eltizo_srVLZ zyinIpRt0P)V2^nT=Q8tuL?3gHM|TiHk2UkY;V+OKZcwFtqfoe z(=%?T$2^m0m1~mF7d|g>JhDA6p@2ZO#zSFWsXx$aek;wJDo$O_ON+g z(V-t#bzWU=&{m$P%&o}Pv zd;I-f@A>~?g#I7pFaR8(0Q_y=sIdF|H{bZc_0#FGZ+Ya=AN}j1=wU@3=D5Brk1Aq5 z2eJrM9I*+G!T@MN{GeR&(Cnf-)$8?)cU5yuWT?Z1yqZne!$fEvJY;phk}2Xa7W5V~ z4x3&oJdf?LH+*{|BNUv9$YA`loTTbqE8IeT%vQwt?)5$$789WX(ZP{fvWLFA-DX%0 zV?XS93x}n6pY*c`OrH(*0?1avB(g?Ydya7_O9Vq4f*J7gD?-^ zod3PT<25ltbrUJ1r<&)gJGUp#BeKW$L!OcxFD!-1FoC!h5k(;{)Ws%G*LD7OE^l~Mt1xXAVZAQsq+aPF)Ohy& zE}K!6)>6s7<|MWyOX{xlS^^O<5ywfY$rNsRdm~ z(g%6YoRm^2F}Cjea9djQ*~Yt}?Bym?H2c@E)$+VD1`}B-pq!~TKES6_d=9pdrRQ*H zq2BJM(PX+9m_Mh5LSwd95^{t!1Kv*;qAchsa-nQiT(UVaVp|z!iYjGY2wiJrjl&sc zW-bxL)=+Z3AKh&wD-%XW88w-b)BP99P!LgR1Pe8Sy(P*sEF)QxN5zDFxJE|X6@Co9 z0cDpdqhz#eiO@%V&ij77mpoqg?YPmrgJ@f;z<0$oCBV*C-0jN{ezeP-1j`}k}x}P=v8Vh>NV?sUpv5#T&LvI+2 z7~TG+zJ`LIMmx#KW2zmj`>37^Tt!JJs@S@-?O7xj-3C80pKX_ z{7``NQv;rl1gOjaU-$GQkN@aruU-58zg(w9rG=7`25-$+00dczai(ErZR{OA?Lh)6 zv0sZZyM@d{=|D5#G2$UUNq|!uN07*_Lbgn3hkVH-GaNqb7dant;szj}gxayYm`)G( ztUl)k63TX(!Q3pjE{ToN%S(+-qi7;)L#s$Al%n6atN@*0OH#}r#xdv0tGwo1_u+w1 zM8boamXMI`-{8s%cM7q5OUVdP;_%_ z*yG5OK5X(ALXrJ^#K5X(yr+f(q}I;#`=MCcKZ;!Qt4BBnmC04k-QI8tz{EO7uOa1_ zUcgz8!#FbRh5Ja(JLgy_I#$;b$yx11MTwr14hR} zv~t~0){ik{7*A)+VVc!t-0nQ{+^7DVf8~u=zwsq1`8%K7`K|l(i-P}sl*0gUly_Vx zz&Ww~{(k@Yy8gB6U-hvkp7=>GJap~*{*rhreUn+LO&Vv5EW-Jk7`2{_70>1M-1PZd zl?`ig3!QFBjdYzi4q2}4k!GEB=#YAb7*9f*Q`dA*%Ve#QNdS3o6Q~72v&r7?4Y`jW zWzz_=)#0hzT(QBj4*RK`?14;+ceR^_7`72HicLXX`5u@0J`LgB-eVDeY<6jv9?jC1 zAPS`Axp1CrGdK6dMlCmnJRr^jVd#7m;npVEoE19UD|j|Scm^TuX&5R0LJsxI^1Psj zD$cazf$`;#mzXft*^LhmxV)w@$jlrMp`yfo-KWv(461Hr0e4I<};0vJN_(aVF^Xn$igm)cSF?b6kTJj*2Mq7fQ3BRQjt%;^;5y*2o^Ia;VunxD>{AAnN&OxD`r~Vo3Qpt zp4~3@yy{GNABc;_l6sI{!^x2C$z@i1Q52^hZGCX48SpAP9H2JyA$E84!*&V3zlZmM zf5I^J{v93Mvj3}y&dAVh*>j;{5n+-cDS3^-q%Ub>8<;Rj0Ckca$%v%y*-tSIj8j4* zu9R8~?MNGW%9Crz9q&V`PeZY z7eFxxZ7|;IIjcPHnF%9P!W(5yZ+ibvWw@yK*f`h7_RKVKMqhR5B2m(;cO>0ZidM@% zu2*=4;K(a{Irg{BXumhIC-JjjyObx?HDCZJ^bOV-6v-&$C_Bh8*Yo;-#UBbho;_3M z%yNFlxgKl5z-&Q*e)|`EA)RS&4L=dgHDU zh=s`E9AymC2oo^kI>@j_#Yl(65iy|qjedQxJkhAphiJ9oM%{cEL?G5X!RTM}kKvP4 zmP`J|X^7l1(1#$Hl+k2!?nbSN_3t%*e;0n{eba~rr(osUB4ce&Ks!z=e^gx$*+c40 z@##3D#=YlXc<#^ttJh!rif5_VKLF4FwsGKK{XYxE zt{#k_SE~0IGd2|v2`Klmb;t$j(V&C0poU`CS5ZIHYVKmB1^bPqRkp>Pj(bX3rM&cG z>ca<0mRwYPc^j)Y>M#7xel0`7D1AW(weo&N(>C@Cl|}cTV;Q#;uE0+n=_DY@%>hKQ z!u?q67#&*v0ruAj{LK51o1EPsV#EHqXoIs}WY&R{1|P`2JXCr;pVt>2?34YB`#DZT zRIvVjSCF||^B>RU*vSjpg+yc!yUgl+okwG!NPT)Zy2sbv-t7{rVb(F! zohj5F(AKzvy-#v^|NLV^hI046=8wX|Ja3Fx$cfX@94TCfjA~Rn3=t2ndBJlI+raUZ zfx~6kvy48eXBs`Yo!-~yi>|H;?^K8r5h2a1bbr0I*`u|7*Lck^OapZ=fTWVV8ga_= z1F{Dw6b!`(f*@(NQGDTh>b+?9{K`w8{VV_KOV9tkPn@gtThZT9{8hA=9OW6}Pp0Ns~Tgo9czZ5pAzBzNLQ+}GcSRzrCmpVx0WJT%lnoEGw>T00Q`HWn}K zwZl1l^eo`*8^j_#R%kHcg1yp7dD`FSLQv2OrYKJ;VrT{blOcu&er)^Ri@|83m#v z4TS!tR#wh?%3V`7j0z;ooA!zs_6cYAnIc(NAPc=V2mI?v6iX@Y>u=1M)D~rsqFff* z0de(hL7k%R@pB@C0?h6i*0(Jk-lHVn^Ys@$^EdwWm!AKdAJyRVlV=(Ra`$7wJfQ!F z0pKX_oN|5|zx`PL`!zWDIoDtM2jBL@yMFqc7uVkR^J0qV+4bihp4Fv3Yc{SBEDkaJ z9YI{5!=4^Oh8twcqk&B(D(8G6RAJdbD%G3Fab(FiSHOBr8aB;#iFtQ{diEghxG0!r$iH6)OY{} z?=(EC7aVCRl?OXWMzpC?qwt`h;0K^3YMglB%cNWo{*08Fga9Yt;zjy)DLM4}LDdPLr*O_Y? zV~zPyxmtrohBzbddq--@egF}rWiRJG+=wKofi!C9+nS46d)YECn>3Xe;j21l`dp8~ zcW;E+u=Gj^<`TxZ2?3Il>M`3an)}{YvMl9ATxfX{WZMWT*Mi>Q>SxF^3F4@{a(xx* z3dD^?j;Z&197CkIy^+I?fxcfE-at70c8PEb<^V#iFJ8klwNHV^U<~e?-d}MpQ$HdV zEEMbt{Ua|xU|nITgPkqRx$lx#hXalTgdmIxw^*K2=z-ufh6xHyz^L@m(HOfXrf~)e zaenco&;F%i0)U5`EflV84g=>5O& z%7>HNO%Ri{Kze%oFiS0S-t6h z6nA|K#q8m?JOvd~_d15k1+BJt`0R1A!Wbkb!#%TW!eW{9VZ~}K<&Sx-DKFh&4@z{h zVZH1pVdH1SBu97Muoss9kFhmrqoLq^Y;a%LE@leh9GqIHFN&V;xYT)n`S*I+FW2m+ zy=UQhecwmZCQK9EPot>&!1eFLPlnKw{dy?SjnNI>eLWj( z<+~Y%5(9=9KHEDaxj+Ij;E~>I`}r67xe5Ac=wyFe3VklH@~`Vh&4Xmii}^oT8yCor zdayYwWP^q>@`q30JPs0rDmK2(X}KFtv!+xoq$bBBlZJvdDj9R`4-9DD$~*T;SG1ArLt zpFH*0V?XvY7uUYuFO8@$UHK|vA(x4jbsh_^8MP7DI8TwWoaqLv`K$M5jJkG`<(#>j zV>4w=FeP7Y7R}~Yr4-tE*_1*XduHRQPACc$S3Ky=heB#ya|(QixViWoDYu&VKyTKD zd=t$VsB2~$Vu3sCGp^qqilFx+Jr?Dm&<;g-7D@Q^sBW~U5{&1{l?@zu$pgokhM^iO z9)PMIb!ZQuP;^)->p%K5rx62kQTMO1jD!1{`jPDZc{ylG3b;-siL;}BY(qzpsZvht9a>y3e~QP#t;;2?Bc??#{Ued>ED_yHb}OotkI{WcmH z$Qku7m1O)6C@GOXqCR7tOLJZ>b*L1OmGz;G3%1&YwT{RI{*G)cV+bMC=aWZ@Qt*-B z$ED3h9$5Pr75l9HZW=UtuLf0xfdo>WW+ti*4|s_glp4+m8zPzdEOL>{Ql8-D%_*gB zW~aziI#?*oB%xd5uT$uaXyX1wim7moSPey2G9p`Zn6B682h22_2`UbL%Y??&l=C-E zmJ{D^+z|RAtN*>f8DWLJJu!wX+!tg_p;NSx|#|Uu$??id+m4E(`#~%ODPhWfZ2maEW;)L@6 z$;%4n4Tj+C*uS|g5Kia~rM@GypPp4543X0s2k@L>EI@8vz$2aN3{1=o2U0ZYt*E>T zZBXySjbP?YKC6^vkm_9G&}_)O4uROrmp(*4oYsDb2JXr8M+P@b9{6y-o{$M;9RzXX zVm(+T-tTzBU|-G^a!_3)1%XB)q(n?A*dz&zrv9<^U)G^X&xs0;xD??P3_@w;hvHd^ zc_K8Ug+GGaY3@1PSm2qX=RTv9ceNS+!85zFh3F$@AZ=Lmw+w6L^($EpLa|$iA06$> zfpA_FKh?kHMtOVc9)>+o=?do5j|&)&jBMasDh2ei4elpu8}BsP!<86A&aw z5vw)5V&5@#>_6@ucwR7s(CY{HyJh{ofZSxQ4={{((IogKXM}5bjqCftE`!gn{-hDH5EWP}63?u1q;6+#&P8p zjUW*o6e#BIt-=kNo+GD58`w~-Ez#QUT*%-CfS#$M!vcTcAwHDBAe2gH_Q8+`LXlk! z5&Jb>C>}x(BZ_uP5yyLR_%Bb1`2>Qg4GEbfDNsy1q+EDc!!#eEYy(nK`iN90>ZmXP zrPNgsKg(HS59XA7-|I5=f=0ZgU@I_kGrIrIBSKt&{%DYq?I+ zT3Ln*wu$2xY&+x#^$i)TF@HH;Jy|R6XEK|NYF8UEWFtQeZCqQpXF%zaF%MSxwH?_Q z<+bK<{<_b?%Mtb{DERGp$0$4_gj}!%V;9_C??*W`G$!h7^i!1a0$fMKMl|;Y_b;SW zX7(cN@6Gm##l^8Sl?A1ZHu;F+iYtAFFh ztKazIeR=rx_xZ$sN6Np$0B{tPgA{P*+StF=-}mM9*S_JIi_>G@^vL5s=9foYJQ85J z2DGoSpqJ-cBwtiIvyZ2&>j_LBt49O}mf5k+yG@K3R=BV!;LxBFb$_Kvx+%+cqA9O%9++0BC5D{B8x%GhmdojNXmeP{;jrAp5{VPA*4o3 z`S5IGokl+_q?GA8^;syqzM&+IWLyWVzbGtY=6a1im22Qiuqa!o_)Lhhp`mAB4?mSL z4bP!Ry228MjN|>$jA(!4G-Ckul>|V{@`!D zdE-N`Jph=0`n$78{|*KJFaR9o-~-r+^N%yG^}E_~pDe~dtYA|*1 zpy<=U4r6I_1hwFgA!~f#9*&)h)_kHXTtHmNJ^_CV+K+-~(xBZP9C{;#GaN1N)LfXb zKglqyFueZE0%aTjg;7zj^BE@9cbjK4@;t=QTm^y;4xQ@n%X_bU$tOPaJO1FEJ1^dP z`(pmjC;dCT|AztKD2EZ?L2&~3zP8+a^CPd_y!ENCeAl~v`p=Ab==+ue;mvtZNreKU zL#UbAUARaaq8uA}pINL(kX&m_^c3aJa99Zqd7*`DkjI3Cc6=rk<=VK;D^C5Fo7ZIr z%_Hn^Nk6v%B{vIlb?JzP-+>DE>@hZs-5kXYRB&r()Q`cRl%WA(6p$&SNB|ATfl4q{ z$#36Z?Bl-uGysrH(cT-~?;QTgcccEUTm1%7M=HQhv1kv}#uCFwmrN=0c1XKl;?UtcQDUMK_g zl?=i5y^Dw!uV5iD>mnQMkFXx?;(gYJntpAMaN1o!?0Kz`JOgKqOQpATD2#YvglHPv zFN*I5$RnJIaQ{}Ur4@IDTQNMwYaPebXN*jzh-~L4Q7T*qWhBhoH$V9&KK|72_%G)9 z);%ln_Z|O#zT$r<|3^6t0EYqKU&sg$56TE|_x7i6z4`jrfB6&d{`o&V;^7~ug_9m( zTBrFtat6MHJ)8O>w0j=HC@m=8&aK6vN6Wkv+K7Q%X^6Yn8JnwD7KVw7WYHoY*1sk-v2pM z(a964l$V`6P|mN$e6D)7arIUJKv+lS^YXrPtbJHxp~rW21_RWG(0z`3DY+z}!hsM3 zn;XSE7z|sYp~TLZHrM`$6W&F15D(Bncr@m8tdQ|`tol+_FOsn=0?rzH$`A|t5AbTk z{ar>u%&`v0JLkS#5hwG#4I^2n{8uqzCuvAn>YP1%S#@CskWC0TP(S^=jcy2JjKKRF zc5>CT#!T`RtR2?2iS4R;>;1@}0I0N-*ccZw)b1LAWR#r!8yVjD-HtJy#=4X|NCq&5 zG-}3J^WRsP1=m0nDcNzA^(V7MwaxS8@*aGY9uI>+Z@b>#qJfBV0s74{e*!^b?+o_X zkQ^Cp0!j0XvGKke2%kX8V9N*Bs`d zzwl-M_{sPDyo=MtlRv$#=b={C)PhhWK3Y)WecA$9 zlv?*b=A-4#drS0Dax1c-Gi2-s!N@bo)TG|S_-?<4*#>GquzYzwxPaFn(=y6bvs{xb z?=Du5-LFvGEdwzPAp=;VH9X917*#~G)^p$GQ+MZXT5e=4Ro`p>>ifoXj-hkRY|wH6 zsb7`$QQR;h5;TEizYn0F5PAtRSR(1dMpe!g%7f>t+hvrFGoN(E4TT+ap=$0Yj+;Xp zS$Fp}`|sY%*I)Xw-}c-m|C@hSiuwca{_{E8%Kt(7ca*aY07p4U0r#I156TEYPxUJ= zeZ@CF`oxcZ_MvOv{})ZuI0Zatgn5!EiV)h~tGHEFD+}}*`oJ44tgc98GLaf7S~#sf zVbCm&hUl0+cRZ^49TsckSk!9 zR>+Fpi8e}he1Msyk-1H8qxqn)?<@gbK%XKg=ndg zmNBhUm(6{ZRu=KnSV!e8Z^we|rGiK97#A9n`_dca5=sS8B!S2{7hYfOH17uyIx*Ea zX-{ag#+(?Rub6O-Gs7mR9BmqG^`16NneqZs!hS9E7#cxPeZpMtHbA8I0&x`e@6$aZ z~=b(0rqzM+QNN5_teR>^5VYr*xU2iGq-QIIXf>s z`%s6tuHGiIO3pdXky50(gOiMPKCFg+H=8~U8B!X9_o?oFzhT&6S@iXF$YY-Go%iUl z3WUqfJ$$pqCd5KM1Ma^y=DF3k%^Y1x4s){Cb)J&ma)uQ(sYaVV?#RF}7fvupRL*Q7 ztUALN=Mv`=$*ltg=3>pL025U#?tzg0>tH`;%EEfvzFF#sgDeiGJq z8h;rkM!BqU0)sz7(wL3mQqXdTVTIFOqK~}Z(5uY-7SC4348Y8VYH>e?S8%RhtBm9f zl-_6*W$Z_gL5^#!c|8|E_MU6^on^#(DALM}v4qS)wy}@MfOpC7{cJlih~jWD3ZwcG zdH#cP#kmUPAB?98K~&@e!YxBGk^T9BMc0AzhU165XVzTynBZ*5JuoN9V{rcbf64y- z;WHom!@uLrH@@=~dFt<9`9GiSKM&Hsquh@H;3x+vU_2P3!TIIQ8{hfj-J2i#ipSse zGk($(5B-2OcGe#F==12ZfH4Y5jO_*ICIH1Z34esApX|Yc(m9URN|<5oysl5PwMFoz zbbs2gq(~T|?7pz4lp7lRhzAlf9fv&5+s`o}RLjEbO3yE{Sc_fpuGjnV^ds80>KVX?l#UlyA;E)niBD)qLF4 zlcEi0iD=vJv}F>hu?xd@C5+Y}tC;66Lm-6Z?w8uS6ZPaFG;A0v*sr~no^w{f18svW z*$COMuLJ!cdG81wW0!J9snHU=sm_|wKNoe}lb!XSgCRhuQzk+Z=#6CidV^Ld*SzOx zUw_GeOf3w)F*x-wWQOLXsTrFvv`mdZ72@bnXfhu1-|MG->p4iGZHEyEP2n8ilG+fi z@kfrG*Ntt%YD6>V(O*0l5We2l-^xGx2W{4f;U_`cbuSDsJ}0TpV>`qy`7DpsHcBJ+ zh>?5Lfzur7`p|hrebNJGv~JUY*Pp=@@<_ubkcq9+hyLvpyo!d-=S((wTg_!9H?!|*h#+xL#KklQ4+Zq%)_bnTvFcG{Oy!F0wa2JOSV%4+ z>RxzqWz-=FpYnK5^1A3iuXxB-Hi|s3gJGlZ@!SRq?tZb1ksvAPk&1>~vk5H$f576M zNhprF&H=|l+h8Kn$8oxVH?cT2XBykoOckd-MDh5!&Hb~6A7Mj-zAv&ej-jdPSHeq^ zr8#|G1QlR}GI_iyV^nsR=V!JaD)S_)vWG)=wady-1nY!$6iQ}L@d#%CwW#H zQ_OF#$-P1^nDJJT5*P3YBO6GywfYv^59K|c^#SBM0d^GjRctIJNe4XU-69( zJ^F*6diaqa`13QTha;GuJT|?A&XC7Ag&nW6`kjyK{CWdD56pw&edjxsBBDAdkQ^6+u0gB zgnNW-Vd2(d+<)rTc{F!$WWcvsGC;aAC}@>=Z&n!05DtFmwnL>cUT2k{hF^E^M3!FqTmU z=gm-xrs~MnK>m|U@ymqP8t>ySsf)mMfOM`o-~6`EJo7*Qn=d{8*Po)2e)i{lvcE(5 zKg!#0065B#9`Nlq0`!a08?S!-r!H^5^ba0=*H8Q@V_f@wIw)gfpb|Ws9&7tR6@C73 zLMi(M)`l4OEhw*`FCf7oieU97v6=1hQp}|{OKx^)FRx1AP;M)G@44f9ses(~J`?H_ zRB||dPtNiz%`4g75Ep~u)u%PTFBHOX!ex^0@!*6KJ7k1NWoL-+l&yh4Mh-*e`?JZm z1%UVPdy-3SD=#dun^i;K_K%=(tuMiB4P=tG7l`wv;!HX5817e^}Mys627 zKJU>8Ra*Ta+qtjMA`|ISSq>OnP?{_#lRVcjlDp3o&a+`W^7zW5Tki!*W>dzT(z7cv z!q+?0ZK1hi<4PZnWbga-*#*BV1cmdxN06=KkvaQ4{^-?gC$H;^$B~W)!)qMgfW{hS z$JEUb3-TFVwrg}(wY@bbBJPt9$%CYbZoPAAqGI? zK=z@fPiKdM!w=!21;>w^0+2YsRF^haT~|18A{Q+;_H%|M=h+qvLd&VlQXG22#1Lvy#B;B3%THpo(OyWlm>NBNpW$T6c6mSm23?vrVgi2s6d{( z4-We(VIm>uDpgdLoxqv5Uz66wDhM_hZ!zTWXd$tKp z2gV4`${CpsvI!ZRJ41+!iJjaz3&0xUXAeKu{#;<;*|5_i%nL?1p9y4W;NH^Y(C_xU zl#J0(T1s$mToqO@#*mS^OSdY+{prFAJHi6cK93X5JRAdbjMEJ^;Z-8&tp1L-u5Z=m zG}5CLY(HQ7@^tyyYp;IoA9&`Y|L$L(=R21jWdHM^{JWh0{ZS4Bz)=n(z}u7V@BAQr zgapj5y!7Q?f9>J#_g&W>{=q*x;`G>+S7j?#coV0GSoK~B89q4f7VBSqXo8=&-y^)& zP|4^=u7`%R^o-%E%mCm(AHVKBQqBNH`!uFhBeL=nqE0_xp@xyQr*B)y+?&w1(DeAdd%oNQKHK`be*h;aR>4D@ZNkCqz~o1&S6 zN{(f{cOFrJZ%`$cafc!~42N=SL!o1V`+2#ObwT4Tg{OtATjA=%bG(+MLNNNV*(`*j zd){@1iLhr{MmotuOtDoj$>rR1rLT$2J`c~u1o$PIF$@l;SlsuWl#C4eyqt5}V^tnLw$lS*uj3U$M(dpVGod#G zWArtb=BqKnHKj1n+sQf`ZPT<(_jiWJ?tVdcB?hG277POBE z|2)0*9fyIj#iKgA>ZfN0&=^xJ%UIoeqH&6Kq5+_Ul<$XEyX*70`kVO%+q8AD1FsIs zYVMeKj9N(6Yt(m@rjwx*G7i&5J<$1(Vda2_!i#4rv{3R+l455Iuw3A#rv2TuJeMFe zhlX*U0GOH;g=?F+H0t?{heKo64OzRa{oqvnD#%z z|K-2?&+avTPW+G`^*ew4<4^qb-0%mJ%wDaS?95Jn6plRT0kiiMC0$1=3w)oE6;v)g2f*lk4`X-9;jjXNF=0(+qCEE7 z?*R&OZ&2ne?%APDJdR+g@AFSu_G64%+xX0qC)M9^&pP!s$_L&mcJ40qU1~}M)|7ke zoO`)07oN8{0NSl)$RDWxfMW;O(mzWG^d%oZ5@@|^i{!?+qW zdX~qTeM|yE&S@G&wcgV};Z%x~)=#aE(T^GXzp0ayb*-7WKe$RodQVe!5$%iZp176D z&3#Sz@tV}%uX?od*Jq@dG=DGqy^8XP_hFn$_8=pa&l>Movj0HY-X z{*C94}J)8UB4rJAveNd@V z4B^}&QvKSsw(K#^u@0!9-td`*hP1hl`ckuE{zQZ0k;;SCxvn`-;k2rx=)xoN{7=)4a1y z+Dq&CAE*~oGwMB&@Zb(BZZZPTRPv#e*Aq-)3B!k#(io0(a>0B=fBSd$b6p==Pugq; z-haiB(ZSb+zR@*!heBfT#O&G;!S&aD;#NBIjMg7bN%gVM6A?zDrOy{O?BKt2Q0yb> zUD%ddjO`Hc`Nbt-?cc_?unJb+lGTnGZ+c=?4d`SM4f_;D{h z^w9f%`ZUGE>jpV+FvlV<#nE0j9$35U3Ram}5Z_gS^TA9_FfkQ~>rw|Z9S)>0PC<73 zKEE>4=P~Rp0M91JK!kRY{XN46NkM?^DP+5le>xKgiWwGq_L;rlV21|8zv|5Xh;aep zMD{UZoUrpcvymPSc1{aQ}5|La_RAY7rM`hsewVGE6uu$UVn}`k-T5567hU z^KtW8Cn#sZwFlp`P(c@>28UEBwYXMUH~@`jQCL6`eSrilU_e+iWrPmvVD<-OTI(dQ zjf-`CD|UL+g9N9Qk)yoK96uK-70Au4H7ho~_9KS8=A0LnjSxZF#WL@b`#^m}RvL|w z=xULjm4`gW)q0py3Q`@_FGd@zaz9Q$wp0nXLo#}~8obaZ?kC>EA`%F#*>U$){&hdH zGPLPs`;ZT=9mtNuJ!IW)PY)qQ?5XZ+4btiN*a@JNx-QWwmq`z@%X(JOd$nIFq%26F zS!j{vgEihGU-JuyIu{hh!IQLcmH}XMJu}%M$FtpI_*`U*Xz$T&48PDh0DYln7}dpd z1NVU1d*{6Kp;un`d%x?`PyZKRx)A-{zvsXIJrCvoDDQLwz)=n(z=Pxx$n$@jr`N9k zvk%?9`Kd2`?8%?_1IBpdN5aFb4Z)8|DdLmX;k^<&1XHo#!YU8*X!|yl(_RJ73KBIDd4c}uyq~j_ z0$gVW3(A-Ju-;CnJNloVN5}~dmuzSBIk%x;DV`mLGU$3z0XD1>k)oRi_ncCPL?~w^ zcV%>F>zA#Zu&z;krVvdT_RuFtDG1qee{g;>q{ZKZeH?W{pF$>K+(Rllj58ShLkNN< zO8{8HTSj)(SL=`J=X~2?ule#@_Oes$<1-{fT5H7N(WjrpL3|HfX5NuZBELk8rV^B^ z?6)cUF@y6k7CA4L{CY>d+v7BhIvaVM^BOB;}L25(}!UzaD+S-G`0TggDXQLpdCvNEib|L zv!8j0R)6*i(-`GJD;WEFFwS|l=dDpbd7z%Q(u2{fjC*@SPDBfbXTQo*skBovjK~q= zG+ITVrx^1~jw$5`av6lkTB+te=1H{2TMEF{xYoZ}SHs<-4*1P@%Q$58MC;Wfs*3Nd zHJM6gKZnCIw-sLu6bQ%2_hSq+2VA&LP@ddp^xSf6sl@jsYCTu3{9fsdkdjy0W0%*Y=Iv*?I8s zY(A6gIM)aldDubdX>*XPi$ExK9SifBQWTKl~$~_(4D7U;TeSbnOTHA2ai0IB}(-nX?p~ zl_j|dv+5ob?re9FH)`6o8CG!Fc~JY9Qmd$ZHV#Vjd{6iHaOWb-3J}#5w6(mmu5*=^ zcR=lSOlR`>Q(nE>&vg4){62qgPl)T)DiYB>(}#IL2ru0iYrf>Eem)+8AM? zCZ{_Lc&?`EzYZb`;=_A}BRijdy%F+o54o;q9gvHpjOdBzh}z`2zo@wc8O0d~#beW# z^!2Lq>^>G@C&m<^5A;^&d`W^d!mwE__NNIPou<+CQypdS(65q5%B;qz^At+{^eoGg zjV88A(jc}ECw2xF5=1U*0oE*p<#Y`0xR%~P*9T*TbrJ?0j0Z9!fb_8pp)guV*$tDT z+6Yv+@bRAQ`Ckg44Gfe`#@djHoxfKdsZejNlht<|d&0_9N6UDHjns8DHfu(a`Lfm) z$$dwZmsX2Ac{TZ`>S;8^aQgr;pBMey$doEwW|zw@ZDjZULc~B;(O$`k=C6?j7!Q+8 zmJIfLT$Qn8fVsT9R|X-<72a*5e$=&I?|bwIvkpv&eS(Cax$zt+@lE?$M@!p&kIQyS z-=5oLe);B&5B?9I`uKnRCDU}T_xPXh{XgI9e<=S)IS>Hv&~hLF;y$07{`<^p(%)TP zzH;}Km%jY#=IQo7dGygA^~1+_C>0!9yowCs%Y z)OplIsFmV8SdlJJz<6kd*K){Pw$(SI4ZFxqx$0o`Rvddzo1xrRHY`U9k5NihqA@Jj z98bdMqxutE6@%+96pEBb*eu{ZbAM~>R5~_gXepJ7$`I!g-YsLmPFPs-U%jUprYVIx zDXtvKN~1Jc3A60%g<;9~g}Oqnj9`EvD!C@D&zUw-pVHgQwYjgO)?#~VJ<=>yZ?~sI zwZsn3#N_596x)46@)iN}bYa!g9 zE;fF+uOzR%bA$capE*#UwN3Z#-TBO4`OMS*$sc&}`M>>L(=<~tzhCcvo?!qul>eg~ z27se{kr)BaCk8z0ZAgybE{r1eh)XtB#{-Fz+F}J;p>0qdmNj?YjBluuU(!AI^ z_$qv0@IJ_%(?6}SR`w$a;HWrru58W(v?G|JZ961^vH>_O=jRM2Xq?I9QH+M4Lkfh9 z4RfQYORi0x1#f9z9NV8HW#fG`{$N~~tsyv<(f#0}@C`2Bi_S zyeUq)){+=Q@MM=ZW*MO^mwh-4?kOV5uu)|{QcckIK6I!bUSCIf?DrteL9A1uYokFI z4Dnsa+pMwmx|Q=fwvJ0GCDkD>I}L~NdFdnUcZI|vktJ6N&iC3|Iahb*lZz1o3<1dW zSaE((rd%A-!2g;Aua{+hZ?1&S&`GBN!#tWYY%q?|hZZUgsO1%DYTNJ*_q{U$kaR?I;6 zerkHm*kxNrhgc3U;4au?rJikcP@eKI43oL!38^%ruHS7qtqzui*Y)hzG9aMso4{v| zhvDmnURufQLN@#kLc(!Pf3WVMpTTQ!fb%m@dftG7Yd80W@`TTn0mm@%@YU5BF~TP* zc?i!S)VVc!iT@!fkDXUc@cghhoKSAw!!RB9J9D%w=JT_Aw6jW4fI0;ypv; zg^7CD8y^gJagUcmMP*x1=1$>@KE$G*Tkd0f)0pepbHYsV9LG`wt1SIcAtOthGK*Y` z|1KzShXm5Q5^~`Fb^EqF{-G=gAx|P?0C$y)CFZW=+hekWEE;vcLoAy0WmCZ>{QQh} zhvT*3wZFP`PSt6rKP@{7<9WX3 z#r=Z)XIZs@s_)zC;UJ#|=V)J#evjJQOVQ8mnZlV|EQ`KJX;VY>?&rK^K(YCt%C!u* z@BTmRorRAi*RtkMoT}~~H2b12J!WQR<|mex;jdU0EQpptu*}TN@R<3%i)MQ~_85A) zr)Mhj>{wl)Pe0}7v*}Weyw=T-N|lvanR)V1o{0G3i_xw{di_CjXeq62o<4r%=l|lf zPyU(z=@s*xz5ny!{{DAal>bE*1i&J1e`W}qw zbF(_=oxtyES;LLYiSN^M5*lkfdu^An1$k9;DLfCQbr!wR=Qs$&d7|ejFTFRs(95gm z%}pt#X@uf4Rm8E+Ndd_<3q4>y$G)jRC|~NcXgi%xfx#^b*vBD+55LjEv0|UgTG1?V zn(h&W3Vhcak7r_kb3~gwL=sD0(~cGJn;0=lj8`s*XEcOB7=MY zLe7MJR!S%>Aq_%CnI%FE${qGeCZRb*hqcy!Zq_-%GAMli`99X|$Bum;eOtnJ=6oKN z0I{t^h#b~->&yuaM8;Cwy9i0#*GY|)9+!1!>tH_ARH;Ryv>T_lKJr(d{Ky~u6R*Df zKR!k!+$;S1SN!`a{(8{=Mfv~#S{4JqB8w5A^V^!H@1-H2lP%!n#`S;yW2M~rA4eD7 z|43b3c)!YmQ;_?cFEPy3@M@EENl;@+klmgnP*UD3#H|3v8if5RpEkT7xPL+{VR@$~ zawxM!tl>zvmmWeI7Z9hu%@ZydWQSY{$3uC%g=FyX#9h04#wycT6U7o7Qt{iNt8)63xg44-$QwQ>O+w9O>%d)rhY@OKG@-TPcQ0w#w*75 z0a+K?8XiEYQRuT8D_jis`YuCP)L$kdIKQr2SN^)>icC&1zuFoTp@HWM%^A ztn#FcR8P5v%?TPVr2fiL~Dgud&Cs?A-tv~XN}EDle=lAa0yR-Z+ z%KsvZ0br50zY$>np($;r?aoAcdh@qlzH$BE{MT5mf8yxyz3+;8_+Az#Y&?HY`5{Q1 zFjB~T?g49s?-Sn9TQfN?CS?NTTEsmoEZ%$4@%*yhJnEq1HKezf1c744W-O%c-9vPE zVJ`UA=AiK^V;OlZdh&TLg*mTz&-R7{ceX#!!&nSFI`5@;^_Z#zV3cPYg&&^kH@G&X zF;995Q=y^$T)nCfL?0q;A;wjRYD1W z$Ft5=uz6iRZ%_PlGCgBw?76(*6pSSq%#?!8+(Z4}jVF4L6_T=t*H}Ya8tHnCY3M`Q zI9G^xluC6!)XJ1wbARs1?N)pmPkdBH9iJs_N*1Vc!}}jhi)hhs=o=>8YHvAHW0gja zG}_G=A0v(EhQQNG2e!`{o%P^D)3n`CXGpTt9P5DXUQ%^+8>+3SShLg(U zd5%E|;+l8v+KKtaK|AW_`{d+$?46oew5PW{$d&Pez<-b2cEV9UA09fQLHv;f? zyoH8<&Nt=c_*bvrxc;yIFi;`XpUT^Wxc6?NdKgPVy zv#%eOr~{Azg+5)04g}u8mrY&af8%sN7as6V88Jqk=qh18C$<+7YB{F=CN^t4qZ8&^ z_jS@&ddW^t5nPpIO{^0t@;Y8!4s^3f2YH)YpiNO7WLWoJNgxtr0Lr%aea#S?wb8UW zVZ~V@@@^Q2K0N1cVLB(%2j>#inH@he)}TEcM@NGphH%)J?VY5Tx3Zl$C(Q6N*A7%z za}CNKyca`>s9*saZWP-ZE0ENAJc%>9%%A(_)Ptl?_!c;da)G(MNdd(*yW$lmD9eJ- z^P0O$zb-vD1=ByWe*HWR=sUgC{4b?Lz$ocK^GzSdJm=~g!u?X3qZ)GDG3$p}EvO_< zc|E)#S|Nr)Aa+ByeE5P5F<>gqG)@vh(C=eWZ677?PvrKU;m-5_>bYnB+8?_1^k4h0 zcWz%hr6Rsp<-hms?&qy$i}Jt7LISwR+eBgqBY?i=f9j0y{=VKfw)J`Foj>{+4?OfO zzhkw!|LfA77lcXQ(_v_ciLfXU3-ajCjg<)Fb}CoP>%gk7G7S>~x2{lu5D}H`wS#zt z+UMP#Po&j1q%_oR6hOy?p2)??I5KWuvG$&T?e+cgP@Axk3c^0tU*ALJ;h)S~4+v|l zMefvw`E>>oHKV`^zO8$L{Ti7Qz|KGQG?|oqApC2rd)_CXbNgH4iuF*DKRU~lp22K$ zM>sXN&S*RWe13w2@Z&nrkEZR#octaZ?l~M9_uIZiB2Idt{WE()Pn1LTBJw)?r-zUt z&qeEz&$WhYJ914w|NHr*TNc&<>L2$+RB$`NA57oM`Nx#GKS$&J-7rt4fm`)H zxp&@=MiCRz)!23l9RP7~bMGI_+0(U3|2;&6VN6h8GvfK(Iooi8@z$N&&;6Shp8rRG z_xRdRGxqH|!Jgjy^%rkk|F{1&%I*Jo zc=VpDwO;(7FB4^7nGoqsAl#R*#vW5{>)ns`kG>)fCa)ypRM{U$YS$Om6D7>^aY4ycb1`GM_dRT^ zSGHBJuLc&z{<(?Lp>pWhqj7z;uMZC(9GUzQdJ1i!KCOW9+C$hqkzgL}r;6lp#sE`c zX5CiPo>b_1639GY|KSWg9ck>F^S$kSW}hyN=~A1!npf)0evnC8(f$1SMnURJ#<|l# z$T8A2*x=qw3(ITanK8yNP8>oAb?$l#=T!Yq%4P?R!ny{JjPFOidiC!r_E~?HBR!m_ z!-bzC>BT4SLGZp3)(?(y{Prcn6CLu7?01+)J=%-X*jIg6V?WD91c@L^IX=7n#6NiM zslWWkUU=qj{kPk8 zk9_Zs9vr^&drOH+?s27H&@-;IYgL}X-02wiwO=0R^Z?k|zj249&qgXIz0N}(opIc3 z%hg%n%Oj-JW@Dxi)M8pIYLYQgqMOD`q(~A9(pyQGe#uq!C zdOF^aw#cuu(A1JPZD)Tz#X~wL&u<~ZCat5$@5YU;5n;*JIpwak(6pe(i5{+}-{@cHHyv8v27*31y%nxstN!t*O zHsjSC1)GPb(x?1lN>TP9NK;=TnVFNo^Ps3*w*K(_tqrA(BSQsjPJDPaiSFT8wlU#I zpVxS=49CcKfXZK~D3?IjrZ;`)&cBX$ZWuPDa30G|w4Mu}$$Jt)Euu=cBj+9>Y4kkV z9zqbE7YSnx0zQZHc_`9w6F`(qCHwoB^Qls8zWz}^GDt3u%xL3~n<`TmJprx+qEz~+ zB7v?_^xc{yzgF@&F}JCY8YDuZqm+_(PIOY6fhOemZ#oA^roKe>AC3#$Gnk4pQ@{>q z&z;`-_&>h()Zh5mXJ?Z+Y(v1&rBA*3 zuJ`=*?_VFi|A%T>zq^F-aN%V}T?XHRNlWKK>^T?1xyaGJ0J#t0y)1_o)()D=I7%Ki z&4;p7?DHO$M))S51&i>^BhF(bMT!?4%x=MEp`iNBYiI-c@m0TLL(F_XzcrOsZuZ*W z8VORVkkTPzj27Zj^s%5Up_tKJY)%a>D1U}EAoRn+^;sRPkMr|7#Q1@fg&@MD2CZwc zYVq4B*4Wnb0#@z3XB@)LBadsjaTT6XzXjSO>v8JE%`qSaBa7x2@GOrGLm9-FNChd! z{ls{qG#%E8rQ^2lrq7V`OK2WQ#0GPICxSrd)4)(cG}owH`yhtN^xRN>JsP3usv|f5 z1$Q&-#e{DK^m9LcT4%11L|p-z&;Ap5HjZg+YwppUknH*6sB7j)=XFs_ReQ;ZDO8CJ z0KUKQnZkuNZJ^}l_|41*Q7ahz9kiV&l&1bH^cjbg;)6V@s2X+y09UHx~7ilUaC_+f1rq+um*nuc`0xRyapiM-U*wa;0IeVl6a`ljLJ@>%TOh~mnFj~Mc#-83lAG?`e8?y-GVm|-p*EV*61VX>O zZ2nE&jB(Jj=6wJy)Z#6403>CtO*~4tMr-G>?+H_s5?tgJ7>B$cm0T@!RDPr`Be5D5 zSglcE(n1T*+CaJ{g*M&8(uu551u6fS!f~M9h4fzO0XmP5gjcV~GnR{egGit!S}Cov zZs9Q`V}Bp$-3Pfae|T$7KL~ds4NfJT5=JA6{ZoXoZ;CfM2@6KQ@?FZsrFj3xvQ>fUB7<)?XlwfIE@0?-+?|rg9BkS zAiZUQ(P5b6Xcr&Gn93cDEnZphHYbgB^N$97G|Pl~R)rv+I_iFlZvh!&&cjg;0L|C= zBd&we;XTnb&a3LdjP>l-SsE#fDYE0A~+a^4C!IqlBP@xge;zeQUWu*J3pP2W7kshG=Oe&lY2) zp?ub;Wo)CAVafLVflE6n=UBX`0-%r?g%6ryo)2XV|B*Lm;_*!bfEKiZgolp9^Bf_F zFTDQp)}ejcRzc#@Hn8X>=!4!J&-z}p_-%QHIX?|0rv9m{Hh89_FpN-tYOPz;z=jG2 zpuU19H*w>8yh9!rEOnw(NKulv8_}FaiY1kHrhil)!GMx-psQ(nVxS0d2jm##{YZ30 zBbvPF6Q!-{Nm`pOj8&F|&O&Kw@a!9<;QTj~dDSmU3?oIYt8)_0bLcdj>`_-hcqGg` zpSO)0ODPsqK(f6jV;S{bR}*xO&e2t+@MYh%MlR^+La+!uqfnxzI+bF~GM0w^oBcw$ z_SX}l$2JbOJ+lwgdZ7Eu8^7&u!$d(O-$W1&FYm49S&TW}znOG#DK4A{#vP(&P=99p zcluv5J{uGQ4_ZJRWE)*^Uv- z=^MWPVZ70JtFbD>c>2+^(~tkFYtQ}N|9E!t(c`|-KTq-Bo6_(1l?C#@$YKCkWa(S` z2?;2D<-czX*yY#`hJdnOUpaX6-M{OrFJAeq?_SlbUscLBfIPO!0KFtq(6!L99s~$; zBOBPW6x)dh72yIPd&0uddh^H}?#t^_py9vHQu#@tl0{~JdDM#Ov(0l1B-FhQ=Ed`I3r;`Wt_GQr_9P5 zMA}#xs!17`p1&!wwh@ZiIbe*ZTg9(~|j>*~_yZQr!hI97V_SQ&!WcUjVFiQ1@0adCnLA{RFznk!B0&LkaT{ZUcj z!jEuUAOzs0l@f_^f>^J$d=}K77U%L5G9@1Cwyq8k;vj`5rm%96RkA-gud$vDN;jV= z>pR0U2ni=7SBgokYxWa)YOQihX+CzeG}Lb{JdI4D>&=g{khdCW9UH@MSk+l4Qg|U4 zi|Y5;av;2G1&TsN`4V!z5_g5tF}lzp)MIH<<{Op7Y?~|#=9TocMpU3_NYE%qw_?6E z=H}%|I5otcxzxoqW38KHxFkS#P zWsP;_o+YNJ9~o>k26T?dRM9@cxu%u#zKpC6nA0$iXbvWNLEiMu#7GR|3f|p9&WViF z&9g$o}44dPhymj(h|LNMbzxRJOx1K-Sr{ceZ5rEs~UOVRX zU*i22SquP+P?nH@{qqC(?fr)W@WZlRx&Le4`S7FP@hu03k9}*@3!edY=r&T(#>=*K z^Xg)YiZMunYA*7|fv{%7s4W38(8Ms23Z@9kdW`*M@t5b*8>WRyYtDN*QDE#n=}nVY z5QdZGW@`m}m%QR$oC6;BUBHL$T_c6YC9+eJblmNsP zZKyVaZ;)Po)G?J6Q%=)waUXe^jNywA-jq6pq2s%QJefe7MQA6U42k-b6s{58Fx73K zzZrf5cq*z}nNp+8qetjzmrMETu3%`hh-ps2JvW!|#t`CsBa!5Gtfbvwq;&oC@#>>zm# z-}`(Hjenbsh3`G4CKc~MY~lFButBH-`WT@7RW+(Hq|i4FUU2GAXk^TOo!6qoHoE5c z=g)3E@!wv0{-6K%lapV6l}hw{rGIxLz&-Q$n+~_c`@i+F$hzEnS!9vc#R6K6{2i_* z8KHnn)3)2X9=6Rs{GJ^WklS}*Y|q{O{Jm1j-+S!`@jAh502jXb+umltP(W> zi|FllDueVixJ55wPm6x%nR!&m11<&`K&u~ye=Uz2PY+`JnVEpXvz=gJZRq9n2`s$* z!hCv9AHzDQqlHbc7Lm`IS8ihL52g&m0BBmE8?;7tQ$rXj3BrM;OsYT4@Y3-^{)3c8 z0!yGMbPP(Ux(0}!^p92o9y78mQrg>!QH=GPAqdvCZBRv)RWNgP*8+0wk zK&#DZq{23oNTd8?E=bY2I#17uLoz;b{1iv5hw%zHuNkX=+fVv+LYVa*#S^=FW*7|~ zKuT>YSu}X*V@EUyO;IJ*_!bzP8lQd+HNryyb1#4hq_phB;psArc7n501Rn zl!<};3q`@x>wcjO36^VsAwN!9?^o;i@O6XImmnevhC&(F^p;RRD}k}A`P~@)0{yKR z6bkBJ|A#>ubC%9Gg89sFCv-X~URi1zK0nt5`lqkkrsET|Ub(&5y!x}Z&z}6T8`uBs z|2%o^mu~1bJAcN#Z~J|V*ME0eWI+Hdvh=t8qXG7a3gC9<_YR-JFo7?6=%Gix>uU}# zy#H%gtE*qyT01Hd7cUFA-#P4V1es7W^BToCm6z=~4M>ALj&t(AJ~ltk;bK^2gJNeq z+THxjP%;TaPpI((TI*t0VHA*8Pv`=p40u-K29J=uF7jRy9YM7_s>&utaxqSeA$k!f3(qV)sr05*=D?S?`;AZ?hG?$X7+a7bN?E0l#oM#io49NUApC zk8qrRd!SsJe40I_mv}62rJQ}U} zu!EYh?QO8xWfao2q1PJ{G)Typ;tjbr6G5=o^^M#3?fS+Hqaof=9_3_m_LEaboIu-q z5-Sp%OFSvAc^-r`$uLH}b-wDrx*W#wj%Fy+WO&$t=Bc-)eSI01O3y!km8byB`pTh>mcRQ z#p?+=996%kcLobP@Hi~4z$`rdJ}hQ(A0g;m5apef|2oTw4hZjQ=rUx5_D=^z z6|hJH-a55R3IOeM!fwLLZ__nx!1Cd05#Nz)J7Ok1<|C8BF5@_r*3eXJF5xlHDPkgh56; z83nmZJs{q|ppEj6{eqNZ_ELLCs4h64*?!aToQ&PCb*<~D|FCWwVd^L&0rWz8Yo0{$ z^laz*MOeprYe<~h>r3yP?wkAx{G1`NG(UjQ`cY)~{UR0qLHmLI`k@shh$m4{==02S# zk^t2?Vd{X_%fmN1VndK$i8)<$W)w}-x$;ZTgQN#lVZx3xHa?Xvc~?EkgoqDor0Pop z%$*X*`%ZE|p)shu+OY@Naw-6Rt_W9m^!Sb&J7#?dO|rTV=;I_agJBEL2{C!Ym2PoR z+aW}NXV-)!`xIm-6U2rHj6if`KL?b2Ckdrf>@e`#V{AVtd}5+Ub{W)JM3uK59{xsD zgRVJI2vJWiBt^=u`ovbDSKHIW&D{Ewnq;lU1J3`gw@UN~#(FoFvL6`se1j~|+{rmF za83%svc^rHw3Yo0ZIK~Be8*)-#b~&3bZ?)P!ja-yL)m5=PCh&gH-7o-^r>Hd`Q`uf ztEaF1+>3n$`QCc|cl^)JaR0^YfA3|H#Q?C#VhD&GI0AO(3D{ln=ds;G0`N?3-=#~R zb>G#8zUlKWT=>A}t_~jfjMeJ$XGB>&ke+5NP9ThRq%hugK9LfL$Y!tvhlP@ZP$~?3 z(&ST?!iLyYC{2ue?}sfgAHF=jT*O($$SnLu){P+3NZZv4(>e9?%Om$X@?n#(i%Llv z0CEvrwhEbfA}BH$iw!n(QCv?#6)5ziO4$E&3>|7>NSXoJHW1|2|9_K@FJq=tAvsr& zuvj+mZuEDxhaXG5GT}OYv>|;XVr~_BU}KWeN5yB+(3corZ4Cmtm-?;7N`pZReNbW$ zNT@p)I$!`a1d$lGV;g1Y&=_L)4dC*P;}hq^j1VH6gYlc!g)-Y0KATBn%Iol%Bq$Zy zGl-;^8sO3(1Y=wb+os<6M77>P}~La3LWkn?ysZ%l;Z5$K#!6$MN{X!=0CZ^Yrw?zkK7x zfBzesvukI%z+M#p@+Ot_arb@uXZC+9y#9+UvKRmsS0ukkjgJ*I9vulhYgoLB*vok}t6QKN&p6_7 zY4{L1ojg~F=ckudDZfS#_DQS~3K%&(zG_!qi_-U$X(+0moYq~~WG?bhZbA^x|?0dTlkpV#7Oc6kk);9!72{&HLW)xJ!7X!IM(l>c3MP~PgDfeL5 zlP8n?I@JFr2C=9sq+M$*>t=(o%6R<%gt|ZXz24X1J#}>p5&)x&qSQlhn??}5aEZ!<9fP(rzbW5xXPC2&54MUOyCDHhX; z*`^1FOxuL3O4^)7nqks!I3FGtcr?MvzAZ2OIas{Iv({N?OW8#Eji-}Bg>E$L`Ws8g zM-T9Pk0p^B+EzInNtib*$@rr-p|l;R7hfhS!}JmpGTskU=__}wr_4j~o+pt8Jt1Aj z0SXsM1f^IIH89MHi3j4~ygueZ(j@`1QXOsH`)J2NzwLt|8`E}T9|ht@M4JQZ4T|Ob z`C|*=AG#q6_&-l+pZeIr!K3e9ukU+owYv14QZKwa;^1AS#E~8k%ABO}NJL@o-%`tL!NSWO#)1`I zKb#mbe*xEJDQ_f)VwDrhs(a))X`?{hk&rKS=B}dMVPs~L_s$OvgDfAs|ESx@{QR>8 z^8Np&M1L)=!^aL)_d&h5*Y$~@5k^1S>X&00+-kR&4uHHxeZIoK9Kbv zTHA7;y92z2B6A~&p%*HtG*nean!2ZJn{AW)o0e3b6%y{PR zK5mm^;PCRNJ#uvP*gMw;4?enDU4691#dp-z;R6u|4@Df_{nYzg2^tey+iiBC>&eEU z`!sN)h>~j$y4qK+pa`bI0hl_7%YX&|316(pwA{vXZ)X?=b0G<45UGcQ<0Ef+*4+%a zs9^4Gqx*--e_o$&0{YrQOxE55onZ&=WYhkuglzlp4KBJ_hs<;_6G0H@zw^D>OdQh2 z4Pf~Rc~2Ja^w3i(+3Cq4KqJ^B^hDmzWU1HrtVhA=2ZW(y8z;(!-t@X1e@%pbK>|77 zermUI9I1GEWls`>Z|Ct*5dQ5oTi?!NiQ*Lc!hWIgsL&Ilix>`s`3Eu8jAtKGsYD7x zlDR1bTYvtHjv}mMrvD-)Txb)Kh-&O8HlLwX7O62s4YjsKqySX}VWyc#L{g^oM5c%o z7qdQp{-k6d&ie~p*3f_Z;UHoAU(|I8gt5>YPvLuj?QiZUxl`Ka#@(-%hVjmgHlE!W zhnv^N;ni!K+c&P?I(zEc>G7{Te|GlNsdtFXRQe_Dtylj2XaC;*#p}PwVgOiVk+-`M zVArex{C)2}<*hdY*nMy6_ob_!|G?qldmlJBe8&Up^_43T7q8USg)6mQyc+fJN|b{u zQP)=@&OaqqS0YwdOR1OqyOH_hpCQv&t)2s+9SJqiV6( zNgt(k1{dgoB4>FajsKOisLt_{FX)u+nDIvG(Z?+iC2`ENn%87{fzd*Sj;i!>x$#wj2}nTnUHNF zJ=BLo3VLY%U`CA;aSixTgXHp9o$ptjsSQNRi2A3oZv^=%ltspO!{9&#ydnGu)rPiX zBWb>)fyU3-1Ll{~Mx7c)aaRZtp1-lol<;JsDJ(1IewM$r{`}Dfja!_LYZ>DLQtsh> z7V!9w$735_D`mL*y*V!9o#XcUcR4$5cejV(?)PwVyctek8{4bLch0WAe3yXlrL&Wd z9lr@>w7pG~|MGTK{P(B)FS5uI0!`A+pTo&KT%*rp6D3yTQ1_$ zbINv?GjJc}?c@7TVzav~g4$rY9GM09QS2>$wi~ zGb>qlAlw@Yjc=8GP*#nEowE$C{Yuql8)dX;L)M-=!*yMvZH-o;^UycaNC^91h7FA> ze>4>t_m5In2c?bbANi*i3JKteG@7v8TlRT!V3ejlnD?fzFEw&S!1Ep2-tO%K^#W2PBpAZFf9LL@)P-M+687Hsc-iqb8AQ zMaTfWA;l664E{-Sm?kG2v90~%cz5hjf)Ek(HQoY+)oby8h zO_Z4zEUNmp`7kt|;J!Tk_pbS0gQ0)5g8v0){lNqP{$7g{0gqk0RA9x&J%W!33~vAY z`h)KuJO4Mdjsfu1PT_g&@SlzP7q0am3jZq)|F;K!PyoRG@Ub0yazJ{Cz-o>C=TiXr zB>~~r^F@F)4S>5te?R!okNiJQ^`rmt#~*R(&yvdKX zP67z=eP@33e}3#gF96~~V1?h8g1>QpgeUl^A|MFN`GmmZ{WHa#|4$or7{{MGdOg-* zu8e8jXxfr#1w~rM4|agul{s}|-x9x$p_D90V%3(Ri;OS5Yqv~;lLU#vGVGRx83`cK z$f9=bwV70h+D)fm8rK7A#w0+;O(^|x=kAoqKv8_R`MaVC0W zcDZdRdtN2G-r-*y!Ps#<8mp!&{LOPy0`3dD+=61+9y(burN~~l-@C|?AVRmlwQcbt* zRHxtFssQ8t%3kE>o^#sNGsn^)m*Y+=*b|`vpIT?Y65Syw<#-@7=qruXgTM0oQY&*8 zouc>ceOXkIJ&}iez07}cYp0hZ_yXMXw0x58w1jpFcl*Q=G5m5KVya*HDrDIbGu`fG z*=907VMgF89b8h$ct&(3Pp7jiHu_bGl*FS!y)R7th~{E2#?OV@Hr%Z_alK67o6;i; zoHe-RebYvJ$xj>8=Kvb1nN3b+d&B%*QFHja;eM#@i1fR2`V9D+Jb+6H0xy=#O*(td>RG_T>Q%fLQ@KNMVbd045c`x1BiZaFTN zr6JnxWZMrJYmY%DtQNN%5Y6yr^rxwd%5#-$? zId!E~wo9F0vfg~wq>7vS!1Li6FT0@?kEP)H7A`Sl4sxZeVz$||8Bjz zo9NB>y?K7k*FQ<1|Jxbp2y_GnZ8xAPis-Q*ACt%M`AV+H_cUGkw-S)RTk>fIEEZo9 z{y@)^h!m|$K%4PtFEzo@67m;X8@D1L!87vE@|jb9CO=5((RfW}k{EcT{bc#Hv(=gK z9B@g#vXHpta=2z-1t;fvPH3al)?^$&q^ok zWMF}5ZxS3xY~c0Gg#ga@-eZ7vm<1OCW3$`Kgrz161x!ceE5&)>Z%=>vG8#2p7qy5v zLIQb3qoK-u;AAXaRWyWN%KOk1A%T`9>y<^@T@CkEOyG!WS8_TVE_OJ5Y|)ZqU&KU# zOrDU02nl8(YeGUWc!4R7t(!2lh|Yz}A$8L>w`e-!)_|+KJqsPPX8;p@L4Xy~fO>xK zE1Fdaj)m%M?43dL&FTB$6M-5MuBLCvtI_?LjBl!ge<5JhYYU8h)pDr+g6Uq-q)G`u o?Pfy6S9q?Hi6ZE%#NGuM01hvxrlqvwIRF3v07*qoM6N<$f?|{LTmS$7 diff --git a/web/public/MediaWiki.svg b/web/public/MediaWiki.svg deleted file mode 100644 index 3c4ed90755c..00000000000 --- a/web/public/MediaWiki.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/public/Mixedbread.png b/web/public/Mixedbread.png deleted file mode 100644 index 5a2f720ca2762cfa9f92909ed4a9e568969dd6c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136773 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 diff --git a/web/public/Notion.png b/web/public/Notion.png deleted file mode 100644 index 391051679c8cc33e7e52891593147283bf93dcb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11406 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 \ No newline at end of file diff --git a/web/public/OpenSource.png b/web/public/OpenSource.png deleted file mode 100644 index 9490ffc6d2e158b266f719d58365905c1c5060cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8640 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 diff --git a/web/public/Openai.svg b/web/public/Openai.svg deleted file mode 100644 index c0bcb8bc125..00000000000 --- a/web/public/Openai.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/web/public/Productboard.webp b/web/public/Productboard.webp deleted file mode 100644 index 2f19fdd7661ab2818279b4c4f37d329a5486d427..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 718 zcmV;<0x|tkNk&G-0ssJ4MM6+kP&iDw0ssInv%oA6U*a~lZKYZ_!XdNXNU7C2`PP8m zHrz&% zCS*xvNss^m-#>u{7B2XI`B|2sjbGPN3m^f{qS+N|`<^5D!>>@c&fIOCn!S1D?`;<1 zEzhdHM#Gsm`7ij)bNRnp77PqzV89~jwC6P)XU7X z%*@OTy~qFm&|r6^M;zZp^dFKVw_zl?8W5n_-Q(;VGRyD3|MMGmy1IKLU)#h<=!_2y z3@W~{)0=OR(>dI$`1+5oUhh6d>KN%0?C|aN!PtR?r_R(X>^8EQei+tN>n&od#fwR0Zw|D+QvWG8`%~Te46}>%vj%%e_ zrH;{-Yc=p%6DC`%)s)Y@1(&tt@mhexy%2vD>>0EqJ9md{qI;H8 z2(=wUD-fkCip~zB5{||dNa0%-iGPKnU$NL%F!B|R8yxkD$J_-Flfe6{B51n|RzgTu zDV$vlB{__%pzx+E6+JuW@1bRzvy)STZ+`#c;cFH+tLtl$Z|mSPv;6-1Kfl!{11Gdx A=l}o! diff --git a/web/public/RequestTracker.png b/web/public/RequestTracker.png deleted file mode 100644 index 95d6680e95cbc007dbc3609eb752d3ef9fd4febd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17302 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 W=l5kWRnPkj68CiVb6Mw<&;$U_ig}0t diff --git a/web/public/S3.png b/web/public/S3.png deleted file mode 100644 index 75eb1f19b34f06687b4ae6f6c2ae5104af7da9c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52426 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ʰ_6tsQkJUg{LbZqUwAF^u$CP4Zy@ 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 diff --git a/web/public/Slack.png b/web/public/Slack.png deleted file mode 100644 index e425969bcb06f3c965bc191cc0593147e5a051a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5204 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 CVvLOd diff --git a/web/public/Teams.png b/web/public/Teams.png deleted file mode 100644 index c8479db6933c6693817c2a91ce63bb03894ab197..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 126479 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­f#&HzIayF+s(+V7}HrmgnxALhM9jTa9E8|9l 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 diff --git a/web/public/Wikipedia.svg b/web/public/Wikipedia.svg deleted file mode 100644 index dc32f9848f4..00000000000 --- a/web/public/Wikipedia.svg +++ /dev/null @@ -1 +0,0 @@ -]>Wikipedia logo version 2 \ No newline at end of file diff --git a/web/public/Zendesk.svg b/web/public/Zendesk.svg deleted file mode 100644 index cc7edc68ce2..00000000000 --- a/web/public/Zendesk.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/web/public/Zulip.png b/web/public/Zulip.png deleted file mode 100644 index d38b5e804d57dfe31aafad04ed1c6147958aaeab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3237 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�K5D!ySp>alx=HqfHAR8FnTo_Ki&yY2cq_sWYiLpG?b z0Dg;*V;BKW`XC!w6ym1dP&{+#A?dAJx7UxUbG|D&&}Gc>r4;3Cqh1POF`MhxNz8f0m=BK3BW?j_QAoj5u_;-{gK+ zzraL$QYBAgSVQ->3Px5Q%DE!y$XZkEvoWH3Tekl1=~EU>#VnHTx^O4NMS^k*K)ZV@ zD@p}g=U>qg5(>&8E|HCG$GlA3_+W{EGdI{I@TKx!77;)}x`l#b^8ctqzPIgNGER77 zVtQ=n?MYbH#vZXA%Dpy~i@(%*yubF)EwU0Sq=#H2XJ@>lw*f{p9bIS)0wl;E%=Rz5 zzOWd$$il^!PfaN&d2v>F@ywr%lgs;Miw!BPIX8ZmftHi&I(1NRiTLmMbxL6r4z;$VSU&?b$Rk^Z6QQ+yEm5^Z?`WzSt?5hq?3FUoib7CBe zJ(n)Im#G-fQ=j*wB>@zdUHontb?+ifFO6*rRN^X-e8|gd>#2EE{Y@0cF%Hq=Nlb@2 zCUdjbjVLcKhYebGxZi>bDtV`}iOMKl_%FZQ3DtY}{HV06PAP}!03Uxu%5UsKG~exv zX0}-1^SlfF;#H|WNL^i5VW-@0RbQTy?R0jmjQXqAzRqbnXL7;{F;RZ7E`loC4OH^8 zjK`>aFW^2TebZ;&90k{P^ulj#Is#5+J|}xCeUwVcSZnoelFpND0T@36>E7s}}wHBEs*O zcOg#=Gq&T~1sJ>SO{C5jlk&*A5B3&V*LkR35@VnXe+ao*+t$ySD|7+27r%5ZQ#XSn zDhrLdi?@_CsIixH#NU|5owbfCoE(>^fc(ZP^Bzns)F=WL-mv;3RyH=~j5P&pZr52j z6t&+hHT2&9^Ph%{d-&v|)~ST@*}9E4jf-LyPc#qcQKz+WxS<*W?_bbBJ6aA1zDn%Y z)(dM@^ECKs31%BfsB&^}aLA4z6gNOhBYYgIvji&%CuLZrZD4L=^~EpP7bH{aSz>|p zh%dFx|Cm+zH?zKpjQ)Gxi28^+)kV)l48YrHXOa4dpVGtuq=v0Lac|_uGe=VdV`mee<@t)iO(;E09=W zQ|r95Q1pVidkepj!C9RgZR+xEM9}zq{(?0;-v5in?TN7uwijUL74I(_!<>jktLJiG z4iZi6JymQHVg+yGbZvQq{EIEYF5=WbqYIyH!zpSOT2&0veYaKY80Xe;!`BlDuZv_h z8@XCa-Zts}XgzMB6}28~5&?6Z<}~{*wzrqxso=fkc05$6B=BXPw#{Go)BW^9+r=

    - ); -} - -function getCategoryDescription(category: SourceCategory): string { - switch (category) { - case SourceCategory.Messaging: - return "Integrate with messaging and communication platforms."; - case SourceCategory.ProjectManagement: - return "Link to project management and task tracking tools."; - case SourceCategory.CustomerSupport: - return "Connect to customer support and helpdesk systems."; - case SourceCategory.CodeRepository: - return "Integrate with code repositories and version control systems."; - case SourceCategory.Storage: - return "Connect to cloud storage and file hosting services."; - case SourceCategory.Wiki: - return "Link to wiki and knowledge base platforms."; - case SourceCategory.Other: - return "Connect to other miscellaneous knowledge sources."; - default: - return "Connect to various knowledge sources."; - } -} diff --git a/web/src/app/admin/assistants/AssistantEditor.tsx b/web/src/app/admin/assistants/AssistantEditor.tsx deleted file mode 100644 index d478922e516..00000000000 --- a/web/src/app/admin/assistants/AssistantEditor.tsx +++ /dev/null @@ -1,1221 +0,0 @@ -"use client"; - -import { generateRandomIconShape, createSVG } from "@/lib/assistantIconUtils"; - -import { CCPairBasicInfo, DocumentSet, User, UserRole } from "@/lib/types"; -import { Button, Divider, Italic } from "@tremor/react"; -import { IsPublicGroupSelector } from "@/components/IsPublicGroupSelector"; -import { - ArrayHelpers, - ErrorMessage, - Field, - FieldArray, - Form, - Formik, - FormikProps, -} from "formik"; - -import { - BooleanFormField, - Label, - SelectorFormField, - TextFormField, -} from "@/components/admin/connectors/Field"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { getDisplayNameForModel } from "@/lib/hooks"; -import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable"; -import { Option } from "@/components/Dropdown"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import { addAssistantToList } from "@/lib/assistants/updateAssistantPreferences"; -import { useUserGroups } from "@/lib/hooks"; -import { checkLLMSupportsImageInput, destructureValue } from "@/lib/llm/utils"; -import { ToolSnapshot } from "@/lib/tools/interfaces"; -import { checkUserIsNoAuthUser } from "@/lib/user"; - -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@radix-ui/react-tooltip"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { FiInfo, FiPlus, FiX } from "react-icons/fi"; -import * as Yup from "yup"; -import { FullLLMProvider } from "../configuration/llm/interfaces"; -import CollapsibleSection from "./CollapsibleSection"; -import { SuccessfulPersonaUpdateRedirectType } from "./enums"; -import { Persona, StarterMessage } from "./interfaces"; -import { buildFinalPrompt, createPersona, updatePersona } from "./lib"; -import { Popover } from "@/components/popover/Popover"; -import { - CameraIcon, - NewChatIcon, - SwapIcon, - TrashIcon, -} from "@/components/icons/icons"; -import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; -import { buildImgUrl } from "@/app/chat/files/images/utils"; -import { LlmList } from "@/components/llm/LLMList"; - -function findSearchTool(tools: ToolSnapshot[]) { - return tools.find((tool) => tool.in_code_tool_id === "SearchTool"); -} - -function findImageGenerationTool(tools: ToolSnapshot[]) { - return tools.find((tool) => tool.in_code_tool_id === "ImageGenerationTool"); -} - -function findInternetSearchTool(tools: ToolSnapshot[]) { - return tools.find((tool) => tool.in_code_tool_id === "InternetSearchTool"); -} - -function SubLabel({ children }: { children: string | JSX.Element }) { - return ( -
    - {children} -
    - ); -} - -export function AssistantEditor({ - existingPersona, - ccPairs, - documentSets, - user, - defaultPublic, - redirectType, - llmProviders, - tools, - shouldAddAssistantToUserPreferences, - admin, -}: { - existingPersona?: Persona | null; - ccPairs: CCPairBasicInfo[]; - documentSets: DocumentSet[]; - user: User | null; - defaultPublic: boolean; - redirectType: SuccessfulPersonaUpdateRedirectType; - llmProviders: FullLLMProvider[]; - tools: ToolSnapshot[]; - shouldAddAssistantToUserPreferences?: boolean; - admin?: boolean; -}) { - const router = useRouter(); - const { popup, setPopup } = usePopup(); - - const colorOptions = [ - "#FF6FBF", - "#6FB1FF", - "#B76FFF", - "#FFB56F", - "#6FFF8D", - "#FF6F6F", - "#6FFFFF", - ]; - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - // state to persist across formik reformatting - const [defautIconColor, _setDeafultIconColor] = useState( - colorOptions[Math.floor(Math.random() * colorOptions.length)] - ); - - const [defaultIconShape, setDefaultIconShape] = useState(null); - - useEffect(() => { - if (defaultIconShape === null) { - setDefaultIconShape(generateRandomIconShape().encodedGrid); - } - }, []); - - const [isIconDropdownOpen, setIsIconDropdownOpen] = useState(false); - - const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); - - // EE only - const { data: userGroups, isLoading: userGroupsIsLoading } = useUserGroups(); - - const [finalPrompt, setFinalPrompt] = useState(""); - const [finalPromptError, setFinalPromptError] = useState(""); - const [removePersonaImage, setRemovePersonaImage] = useState(false); - - const triggerFinalPromptUpdate = async ( - systemPrompt: string, - taskPrompt: string, - retrievalDisabled: boolean - ) => { - const response = await buildFinalPrompt( - systemPrompt, - taskPrompt, - retrievalDisabled - ); - if (response.ok) { - setFinalPrompt((await response.json()).final_prompt_template); - } - }; - - const isUpdate = existingPersona !== undefined && existingPersona !== null; - const existingPrompt = existingPersona?.prompts[0] ?? null; - - useEffect(() => { - if (isUpdate && existingPrompt) { - triggerFinalPromptUpdate( - existingPrompt.system_prompt, - existingPrompt.task_prompt, - existingPersona.num_chunks === 0 - ); - } - }, []); - - const defaultProvider = llmProviders.find( - (llmProvider) => llmProvider.is_default_provider - ); - const defaultProviderName = defaultProvider?.provider; - const defaultModelName = defaultProvider?.default_model_name; - const providerDisplayNameToProviderName = new Map(); - llmProviders.forEach((llmProvider) => { - providerDisplayNameToProviderName.set( - llmProvider.name, - llmProvider.provider - ); - }); - - const modelOptionsByProvider = new Map[]>(); - llmProviders.forEach((llmProvider) => { - const providerOptions = llmProvider.model_names.map((modelName) => { - return { - name: getDisplayNameForModel(modelName), - value: modelName, - }; - }); - modelOptionsByProvider.set(llmProvider.name, providerOptions); - }); - const providerSupportingImageGenerationExists = llmProviders.some( - (provider) => provider.provider === "openai" - ); - - const personaCurrentToolIds = - existingPersona?.tools.map((tool) => tool.id) || []; - const searchTool = findSearchTool(tools); - const imageGenerationTool = providerSupportingImageGenerationExists - ? findImageGenerationTool(tools) - : undefined; - const internetSearchTool = findInternetSearchTool(tools); - - const customTools = tools.filter( - (tool) => - tool.in_code_tool_id !== searchTool?.in_code_tool_id && - tool.in_code_tool_id !== imageGenerationTool?.in_code_tool_id && - tool.in_code_tool_id !== internetSearchTool?.in_code_tool_id - ); - - const availableTools = [ - ...customTools, - ...(searchTool ? [searchTool] : []), - ...(imageGenerationTool ? [imageGenerationTool] : []), - ...(internetSearchTool ? [internetSearchTool] : []), - ]; - const enabledToolsMap: { [key: number]: boolean } = {}; - availableTools.forEach((tool) => { - enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id); - }); - - const initialValues = { - name: existingPersona?.name ?? "", - description: existingPersona?.description ?? "", - system_prompt: existingPrompt?.system_prompt ?? "", - task_prompt: existingPrompt?.task_prompt ?? "", - is_public: existingPersona?.is_public ?? defaultPublic, - document_set_ids: - existingPersona?.document_sets?.map((documentSet) => documentSet.id) ?? - ([] as number[]), - num_chunks: existingPersona?.num_chunks ?? null, - include_citations: existingPersona?.prompts[0]?.include_citations ?? true, - llm_relevance_filter: existingPersona?.llm_relevance_filter ?? false, - llm_model_provider_override: - existingPersona?.llm_model_provider_override ?? null, - llm_model_version_override: - existingPersona?.llm_model_version_override ?? null, - starter_messages: existingPersona?.starter_messages ?? [], - enabled_tools_map: enabledToolsMap, - icon_color: existingPersona?.icon_color ?? defautIconColor, - icon_shape: existingPersona?.icon_shape ?? defaultIconShape, - uploaded_image: null, - - // EE Only - groups: existingPersona?.groups ?? [], - }; - - const [isRequestSuccessful, setIsRequestSuccessful] = useState(false); - - return ( -
    - {popup} - 0; - const taskPromptSpecified = - values.task_prompt && values.task_prompt.trim().length > 0; - - if (systemPromptSpecified || taskPromptSpecified) { - return true; - } - - return this.createError({ - path: "system_prompt", - message: - "Must provide either Instructions or Reminders (Advanced)", - }); - } - )} - onSubmit={async (values, formikHelpers) => { - if (finalPromptError) { - setPopup({ - type: "error", - message: "Cannot submit while there are errors in the form", - }); - return; - } - - if ( - values.llm_model_provider_override && - !values.llm_model_version_override - ) { - setPopup({ - type: "error", - message: - "Must select a model if a non-default LLM provider is chosen.", - }); - return; - } - - formikHelpers.setSubmitting(true); - let enabledTools = Object.keys(values.enabled_tools_map) - .map((toolId) => Number(toolId)) - .filter((toolId) => values.enabled_tools_map[toolId]); - const searchToolEnabled = searchTool - ? enabledTools.includes(searchTool.id) - : false; - const imageGenerationToolEnabled = imageGenerationTool - ? enabledTools.includes(imageGenerationTool.id) - : false; - - if (imageGenerationToolEnabled) { - if ( - !checkLLMSupportsImageInput( - providerDisplayNameToProviderName.get( - values.llm_model_provider_override || "" - ) || - defaultProviderName || - "", - values.llm_model_version_override || defaultModelName || "" - ) - ) { - enabledTools = enabledTools.filter( - (toolId) => toolId !== imageGenerationTool!.id - ); - } - } - - // if disable_retrieval is set, set num_chunks to 0 - // to tell the backend to not fetch any documents - const numChunks = searchToolEnabled ? values.num_chunks || 10 : 0; - - // don't set groups if marked as public - const groups = values.is_public ? [] : values.groups; - - let promptResponse; - let personaResponse; - if (isUpdate) { - [promptResponse, personaResponse] = await updatePersona({ - id: existingPersona.id, - existingPromptId: existingPrompt?.id, - ...values, - num_chunks: numChunks, - users: - user && !checkUserIsNoAuthUser(user.id) ? [user.id] : undefined, - groups, - tool_ids: enabledTools, - remove_image: removePersonaImage, - }); - } else { - [promptResponse, personaResponse] = await createPersona({ - ...values, - num_chunks: numChunks, - users: - user && !checkUserIsNoAuthUser(user.id) ? [user.id] : undefined, - groups, - tool_ids: enabledTools, - }); - } - - let error = null; - if (!promptResponse.ok) { - error = await promptResponse.text(); - } - if (!personaResponse) { - error = "Failed to create Assistant - no response received"; - } else if (!personaResponse.ok) { - error = await personaResponse.text(); - } - - if (error || !personaResponse) { - setPopup({ - type: "error", - message: `Failed to create Assistant - ${error}`, - }); - formikHelpers.setSubmitting(false); - } else { - const assistant = await personaResponse.json(); - const assistantId = assistant.id; - if ( - shouldAddAssistantToUserPreferences && - user?.preferences?.chosen_assistants - ) { - const success = await addAssistantToList( - assistantId, - user.preferences.chosen_assistants - ); - if (success) { - setPopup({ - message: `"${assistant.name}" has been added to your list.`, - type: "success", - }); - router.refresh(); - } else { - setPopup({ - message: `"${assistant.name}" could not be added to your list.`, - type: "error", - }); - } - } - router.push( - redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN - ? `/admin/assistants?u=${Date.now()}` - : `/chat?assistantId=${assistantId}` - ); - setIsRequestSuccessful(true); - } - }} - > - {({ - isSubmitting, - values, - setFieldValue, - ...formikProps - }: FormikProps) => { - function toggleToolInValues(toolId: number) { - const updatedEnabledToolsMap = { - ...values.enabled_tools_map, - [toolId]: !values.enabled_tools_map[toolId], - }; - setFieldValue("enabled_tools_map", updatedEnabledToolsMap); - } - - function searchToolEnabled() { - return searchTool && values.enabled_tools_map[searchTool.id] - ? true - : false; - } - - return ( -
    -
    - setIsIconDropdownOpen(!isIconDropdownOpen)} - > - {values.uploaded_image ? ( - Uploaded assistant icon - ) : existingPersona?.uploaded_image_id && - !removePersonaImage ? ( - Uploaded assistant icon - ) : ( - createSVG( - { - encodedGrid: values.icon_shape, - filledSquares: 0, - }, - values.icon_color, - undefined, - true - ) - )} -
    - } - popover={ -
    - - - {values.uploaded_image && ( - - )} - - {!values.uploaded_image && - (!existingPersona?.uploaded_image_id || - removePersonaImage) && ( - - )} - - {existingPersona?.uploaded_image_id && - removePersonaImage && - !values.uploaded_image && ( - - )} - - {existingPersona?.uploaded_image_id && - !removePersonaImage && - !values.uploaded_image && ( - - )} -
    - } - align="start" - side="bottom" - /> - - - - - - -

    - This icon will visually represent your Assistant -

    -
    -
    -
    -
    - - - - - - { - setFieldValue("system_prompt", e.target.value); - triggerFinalPromptUpdate( - e.target.value, - values.task_prompt, - searchToolEnabled() - ); - }} - error={finalPromptError} - /> - -
    -
    -
    - Default AI Model{" "} -
    - - - - - - -

    - Select a Large Language Model (Generative AI model) to - power this Assistant -

    -
    -
    -
    -
    -

    - Your assistant will use the user's set default unless - otherwise specified below. - {admin && - user?.preferences.default_model && - ` Your current (user-specific) default model is ${getDisplayNameForModel(destructureValue(user?.preferences?.default_model!).modelName)}`} -

    - {admin ? ( -
    -
    - ({ - name: llmProvider.name, - value: llmProvider.name, - icon: llmProvider.icon, - }))} - includeDefault={true} - onSelect={(selected) => { - if (selected !== values.llm_model_provider_override) { - setFieldValue("llm_model_version_override", null); - } - setFieldValue( - "llm_model_provider_override", - selected - ); - }} - /> -
    - - {values.llm_model_provider_override && ( -
    - -
    - )} -
    - ) : ( -
    - { - if (value !== null) { - const { modelName, provider, name } = - destructureValue(value); - setFieldValue( - "llm_model_version_override", - modelName - ); - setFieldValue("llm_model_provider_override", name); - } else { - setFieldValue("llm_model_version_override", null); - setFieldValue("llm_model_provider_override", null); - } - }} - /> -
    - )} -
    -
    -
    -
    - Capabilities{" "} -
    - - - - - - -

    - You can give your assistant advanced capabilities like - image generation -

    -
    -
    -
    -
    - Advanced -
    -
    - -
    - {imageGenerationTool && ( - - - -
    - { - toggleToolInValues(imageGenerationTool.id); - }} - disabled={ - !checkLLMSupportsImageInput( - providerDisplayNameToProviderName.get( - values.llm_model_provider_override || "" - ) || "", - values.llm_model_version_override || "" - ) - } - /> -
    -
    - {!checkLLMSupportsImageInput( - providerDisplayNameToProviderName.get( - values.llm_model_provider_override || "" - ) || "", - values.llm_model_version_override || "" - ) && ( - -

    - To use Image Generation, select GPT-4o or another - image compatible model as the default model for - this Assistant. -

    -
    - )} -
    -
    - )} - - {searchTool && ( - - - -
    - { - setFieldValue("num_chunks", null); - toggleToolInValues(searchTool.id); - }} - disabled={ccPairs.length === 0} - /> -
    -
    - {ccPairs.length === 0 && ( - -

    - To use the Search Tool, you need to have at least - one Connector-Credential pair configured. -

    -
    - )} -
    -
    - )} - - {ccPairs.length > 0 && searchTool && ( - <> - {searchToolEnabled() && ( - -
    - {ccPairs.length > 0 && ( - <> - -
    - - <> - Select which{" "} - {!user || user.role === "admin" ? ( - - Document Sets - - ) : ( - "Document Sets" - )}{" "} - this Assistant should search through. If - none are specified, the Assistant will - search through all available documents in - order to try and respond to queries. - - -
    - - {documentSets.length > 0 ? ( - ( -
    -
    - {documentSets.map((documentSet) => { - const ind = - values.document_set_ids.indexOf( - documentSet.id - ); - let isSelected = ind !== -1; - return ( - { - if (isSelected) { - arrayHelpers.remove(ind); - } else { - arrayHelpers.push( - documentSet.id - ); - } - }} - /> - ); - })} -
    -
    - )} - /> - ) : ( - - No Document Sets available.{" "} - {user?.role !== "admin" && ( - <> - If this functionality would be useful, - reach out to the administrators of - Danswer for assistance. - - )} - - )} - -
    - { - const value = e.target.value; - if ( - value === "" || - /^[0-9]+$/.test(value) - ) { - setFieldValue("num_chunks", value); - } - }} - /> - - - - -
    - - )} -
    -
    - )} - - )} - - {internetSearchTool && ( - { - toggleToolInValues(internetSearchTool.id); - }} - /> - )} - - {customTools.length > 0 && ( - <> - {customTools.map((tool) => ( - { - toggleToolInValues(tool.id); - }} - /> - ))} - - )} -
    -
    - - - - {showAdvancedOptions && ( - <> - {llmProviders.length > 0 && ( - <> - { - setFieldValue("task_prompt", e.target.value); - triggerFinalPromptUpdate( - values.system_prompt, - e.target.value, - searchToolEnabled() - ); - }} - explanationText="Learn about prompting in our docs!" - explanationLink="https://docs.danswer.dev/guides/assistants" - /> - - )} - -
    -
    -
    - Starter Messages (Optional){" "} -
    -
    - - ) => ( -
    - {values.starter_messages && - values.starter_messages.length > 0 && - values.starter_messages.map( - ( - starterMessage: StarterMessage, - index: number - ) => { - return ( -
    -
    -
    -
    - - - Shows up as the "title" - for this Starter Message. For - example, "Write an email". - - - -
    - -
    - - - A description which tells the user - what they might want to use this - Starter Message for. For example - "to a client about a new - feature" - - - -
    - -
    - - - The actual message to be sent as the - initial user message if a user - selects this starter prompt. For - example, "Write me an email to - a client about a new billing feature - we just released." - - - -
    -
    -
    - - arrayHelpers.remove(index) - } - /> -
    -
    -
    - ); - } - )} - - -
    - )} - /> -
    - - {isPaidEnterpriseFeaturesEnabled && - userGroups && - userGroups.length > 0 && ( - - )} - - )} - -
    - -
    - - ); - }} - - - ); -} diff --git a/web/src/app/admin/assistants/CollapsibleSection.tsx b/web/src/app/admin/assistants/CollapsibleSection.tsx deleted file mode 100644 index 139f93d26e7..00000000000 --- a/web/src/app/admin/assistants/CollapsibleSection.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; -import { Button } from "@tremor/react"; -import React, { ReactNode, useState } from "react"; -import { FiSettings } from "react-icons/fi"; - -interface CollapsibleSectionProps { - children: ReactNode; - prompt?: string; - className?: string; -} - -const CollapsibleSection: React.FC = ({ - children, - prompt, - className = "", -}) => { - const [isCollapsed, setIsCollapsed] = useState(false); - - const toggleCollapse = (e?: React.MouseEvent) => { - // Only toggle if the click is on the border or plus sign - if ( - !e || - e.currentTarget === e.target || - (e.target as HTMLElement).classList.contains("collapse-toggle") - ) { - setIsCollapsed(!isCollapsed); - } - }; - - return ( -
    -
    - {" "} - {isCollapsed ? ( - - - {prompt}{" "} - - ) : ( - <>{children} - )} -
    -
    - ); -}; - -export default CollapsibleSection; diff --git a/web/src/app/admin/assistants/HidableSection.tsx b/web/src/app/admin/assistants/HidableSection.tsx deleted file mode 100644 index 3741e514b88..00000000000 --- a/web/src/app/admin/assistants/HidableSection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from "react"; -import { FiChevronDown, FiChevronRight } from "react-icons/fi"; - -export function SectionHeader({ - children, - includeMargin = true, -}: { - children: string | JSX.Element; - includeMargin?: boolean; -}) { - return ( -
    - {children} -
    - ); -} - -export function HidableSection({ - children, - sectionTitle, - defaultHidden = false, -}: { - children: string | JSX.Element; - sectionTitle: string | JSX.Element; - defaultHidden?: boolean; -}) { - const [isHidden, setIsHidden] = useState(defaultHidden); - - return ( -
    -
    setIsHidden(!isHidden)} - > - {sectionTitle} -
    - {isHidden ? ( - - ) : ( - - )} -
    -
    - - {!isHidden &&
    {children}
    } -
    - ); -} diff --git a/web/src/app/admin/assistants/PersonaTable.tsx b/web/src/app/admin/assistants/PersonaTable.tsx deleted file mode 100644 index aa35a0f17a9..00000000000 --- a/web/src/app/admin/assistants/PersonaTable.tsx +++ /dev/null @@ -1,217 +0,0 @@ -"use client"; - -import { Text } from "@tremor/react"; -import { Persona } from "./interfaces"; -import { useRouter } from "next/navigation"; -import { CustomCheckbox } from "@/components/CustomCheckbox"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { useState, useMemo, useEffect } from "react"; -import { UniqueIdentifier } from "@dnd-kit/core"; -import { DraggableTable } from "@/components/table/DraggableTable"; -import { deletePersona, personaComparator } from "./lib"; -import { FiEdit2 } from "react-icons/fi"; -import { TrashIcon } from "@/components/icons/icons"; -import { getCurrentUser } from "@/lib/user"; -import { UserRole, User } from "@/lib/types"; -import { useUser } from "@/components/user/UserProvider"; - -function PersonaTypeDisplay({ persona }: { persona: Persona }) { - if (persona.default_persona) { - return Built-In; - } - - if (persona.is_public) { - return Global; - } - - if (persona.groups.length > 0 || persona.users.length > 0) { - return Shared; - } - - return Personal {persona.owner && <>({persona.owner.email})}; -} - -const togglePersonaVisibility = async ( - personaId: number, - isVisible: boolean -) => { - const response = await fetch(`/api/admin/persona/${personaId}/visible`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - is_visible: !isVisible, - }), - }); - return response; -}; - -export function PersonasTable({ - allPersonas, - editablePersonas, -}: { - allPersonas: Persona[]; - editablePersonas: Persona[]; -}) { - const router = useRouter(); - const { popup, setPopup } = usePopup(); - - const { isLoadingUser, isAdmin } = useUser(); - - const editablePersonaIds = new Set( - editablePersonas.map((p) => p.id.toString()) - ); - - const sortedPersonas = useMemo(() => { - const editable = editablePersonas.sort(personaComparator); - const nonEditable = allPersonas - .filter((p) => !editablePersonaIds.has(p.id.toString())) - .sort(personaComparator); - return [...editable, ...nonEditable]; - }, [allPersonas, editablePersonas]); - - const [finalPersonas, setFinalPersonas] = useState( - sortedPersonas.map((persona) => persona.id.toString()) - ); - const finalPersonaValues = finalPersonas - .filter((id) => new Set(allPersonas.map((p) => p.id.toString())).has(id)) - .map((id) => { - return sortedPersonas.find( - (persona) => persona.id.toString() === id - ) as Persona; - }); - - const updatePersonaOrder = async (orderedPersonaIds: UniqueIdentifier[]) => { - setFinalPersonas(orderedPersonaIds.map((id) => id.toString())); - - const displayPriorityMap = new Map(); - orderedPersonaIds.forEach((personaId, ind) => { - displayPriorityMap.set(personaId, ind); - }); - - const response = await fetch("/api/admin/persona/display-priority", { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - display_priority_map: Object.fromEntries(displayPriorityMap), - }), - }); - if (!response.ok) { - setPopup({ - type: "error", - message: `Failed to update persona order - ${await response.text()}`, - }); - router.refresh(); - } - }; - - if (isLoadingUser) { - return <>; - } - - return ( -
    - {popup} - - - Assistants will be displayed as options on the Chat / Search interfaces - in the order they are displayed below. Assistants marked as hidden will - not be displayed. Editable assistants are shown at the top. - - - { - const isEditable = editablePersonaIds.has(persona.id.toString()); - return { - id: persona.id.toString(), - cells: [ -
    - {!persona.default_persona && ( - - router.push( - `/admin/assistants/${persona.id}?u=${Date.now()}` - ) - } - /> - )} -

    - {persona.name} -

    -
    , -

    - {persona.description} -

    , - , -
    { - if (isEditable) { - const response = await togglePersonaVisibility( - persona.id, - persona.is_visible - ); - if (response.ok) { - router.refresh(); - } else { - setPopup({ - type: "error", - message: `Failed to update persona - ${await response.text()}`, - }); - } - } - }} - className={`px-1 py-0.5 rounded flex ${isEditable ? "hover:bg-hover cursor-pointer" : ""} select-none w-fit`} - > -
    - {!persona.is_visible ? ( -
    Hidden
    - ) : ( - "Visible" - )} -
    -
    - -
    -
    , -
    -
    - {!persona.default_persona && isEditable ? ( -
    { - const response = await deletePersona(persona.id); - if (response.ok) { - router.refresh(); - } else { - alert( - `Failed to delete persona - ${await response.text()}` - ); - } - }} - > - -
    - ) : ( - "-" - )} -
    -
    , - ], - staticModifiers: [[1, "lg:w-[250px] xl:w-[400px] 2xl:w-[550px]"]], - }; - })} - setRows={updatePersonaOrder} - /> -
    - ); -} diff --git a/web/src/app/admin/assistants/[id]/DeletePersonaButton.tsx b/web/src/app/admin/assistants/[id]/DeletePersonaButton.tsx deleted file mode 100644 index 0bbc268fae4..00000000000 --- a/web/src/app/admin/assistants/[id]/DeletePersonaButton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { Button } from "@tremor/react"; -import { FiTrash } from "react-icons/fi"; -import { deletePersona } from "../lib"; -import { useRouter } from "next/navigation"; -import { SuccessfulPersonaUpdateRedirectType } from "../enums"; - -export function DeletePersonaButton({ - personaId, - redirectType, -}: { - personaId: number; - redirectType: SuccessfulPersonaUpdateRedirectType; -}) { - const router = useRouter(); - - return ( - - ); -} diff --git a/web/src/app/admin/assistants/[id]/page.tsx b/web/src/app/admin/assistants/[id]/page.tsx deleted file mode 100644 index fab6f9f038b..00000000000 --- a/web/src/app/admin/assistants/[id]/page.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ErrorCallout } from "@/components/ErrorCallout"; -import { AssistantEditor } from "../AssistantEditor"; -import { BackButton } from "@/components/BackButton"; -import { Card, Title } from "@tremor/react"; -import { DeletePersonaButton } from "./DeletePersonaButton"; -import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS"; -import { SuccessfulPersonaUpdateRedirectType } from "../enums"; -import { RobotIcon } from "@/components/icons/icons"; -import { AdminPageTitle } from "@/components/admin/Title"; - -export default async function Page({ params }: { params: { id: string } }) { - const [values, error] = await fetchAssistantEditorInfoSS(params.id); - - let body; - if (!values) { - body = ( - - ); - } else { - body = ( - <> - - - - -
    - Delete Assistant -
    - -
    -
    - - ); - } - - return ( -
    - - } /> - {body} -
    - ); -} diff --git a/web/src/app/admin/assistants/enums.ts b/web/src/app/admin/assistants/enums.ts deleted file mode 100644 index 602d1692f27..00000000000 --- a/web/src/app/admin/assistants/enums.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum SuccessfulPersonaUpdateRedirectType { - ADMIN = "ADMIN", - CHAT = "CHAT", -} diff --git a/web/src/app/admin/assistants/interfaces.ts b/web/src/app/admin/assistants/interfaces.ts deleted file mode 100644 index 0696b5ae885..00000000000 --- a/web/src/app/admin/assistants/interfaces.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ToolSnapshot } from "@/lib/tools/interfaces"; -import { DocumentSet, MinimalUserSnapshot } from "@/lib/types"; - -export interface StarterMessage { - name: string; - description: string | null; - message: string; -} - -export interface Prompt { - id: number; - name: string; - description: string; - system_prompt: string; - task_prompt: string; - include_citations: boolean; - datetime_aware: boolean; - default_prompt: boolean; -} - -export interface Persona { - id: number; - name: string; - owner: MinimalUserSnapshot | null; - is_visible: boolean; - is_public: boolean; - display_priority: number | null; - description: string; - document_sets: DocumentSet[]; - prompts: Prompt[]; - tools: ToolSnapshot[]; - num_chunks?: number; - llm_relevance_filter?: boolean; - llm_filter_extraction?: boolean; - llm_model_provider_override?: string; - llm_model_version_override?: string; - starter_messages: StarterMessage[] | null; - default_persona: boolean; - users: MinimalUserSnapshot[]; - groups: number[]; - icon_shape?: number; - icon_color?: string; - uploaded_image_id?: string; -} diff --git a/web/src/app/admin/assistants/lib.ts b/web/src/app/admin/assistants/lib.ts deleted file mode 100644 index 613f98145f1..00000000000 --- a/web/src/app/admin/assistants/lib.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { Persona, Prompt, StarterMessage } from "./interfaces"; - -interface PersonaCreationRequest { - name: string; - description: string; - system_prompt: string; - task_prompt: string; - document_set_ids: number[]; - num_chunks: number | null; - include_citations: boolean; - is_public: boolean; - llm_relevance_filter: boolean | null; - llm_model_provider_override: string | null; - llm_model_version_override: string | null; - starter_messages: StarterMessage[] | null; - users?: string[]; - groups: number[]; - tool_ids: number[]; - icon_color: string | null; - icon_shape: number | null; - remove_image?: boolean; - uploaded_image: File | null; -} - -interface PersonaUpdateRequest { - id: number; - existingPromptId: number | undefined; - name: string; - description: string; - system_prompt: string; - task_prompt: string; - document_set_ids: number[]; - num_chunks: number | null; - include_citations: boolean; - is_public: boolean; - llm_relevance_filter: boolean | null; - llm_model_provider_override: string | null; - llm_model_version_override: string | null; - starter_messages: StarterMessage[] | null; - users?: string[]; - groups: number[]; - tool_ids: number[]; - icon_color: string | null; - icon_shape: number | null; - remove_image: boolean; - uploaded_image: File | null; -} - -function promptNameFromPersonaName(personaName: string) { - return `default-prompt__${personaName}`; -} - -function createPrompt({ - personaName, - systemPrompt, - taskPrompt, - includeCitations, -}: { - personaName: string; - systemPrompt: string; - taskPrompt: string; - includeCitations: boolean; -}) { - return fetch("/api/prompt", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: promptNameFromPersonaName(personaName), - description: `Default prompt for persona ${personaName}`, - system_prompt: systemPrompt, - task_prompt: taskPrompt, - include_citations: includeCitations, - }), - }); -} - -function updatePrompt({ - promptId, - personaName, - systemPrompt, - taskPrompt, - includeCitations, -}: { - promptId: number; - personaName: string; - systemPrompt: string; - taskPrompt: string; - includeCitations: boolean; -}) { - return fetch(`/api/prompt/${promptId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name: promptNameFromPersonaName(personaName), - description: `Default prompt for persona ${personaName}`, - system_prompt: systemPrompt, - task_prompt: taskPrompt, - include_citations: includeCitations, - }), - }); -} - -function buildPersonaAPIBody( - creationRequest: PersonaCreationRequest | PersonaUpdateRequest, - promptId: number, - uploaded_image_id: string | null -) { - const { - name, - description, - document_set_ids, - num_chunks, - llm_relevance_filter, - is_public, - groups, - users, - tool_ids, - icon_color, - icon_shape, - remove_image, - } = creationRequest; - - return { - name, - description, - num_chunks, - llm_relevance_filter, - llm_filter_extraction: false, - is_public, - recency_bias: "base_decay", - prompt_ids: [promptId], - document_set_ids, - llm_model_provider_override: creationRequest.llm_model_provider_override, - llm_model_version_override: creationRequest.llm_model_version_override, - starter_messages: creationRequest.starter_messages, - users, - groups, - tool_ids, - icon_color, - icon_shape, - uploaded_image_id, - remove_image, - }; -} - -export async function uploadFile(file: File): Promise { - const formData = new FormData(); - formData.append("file", file); - const response = await fetch("/api/admin/persona/upload-image", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - console.error("Failed to upload file"); - return null; - } - - const responseJson = await response.json(); - return responseJson.file_id; -} - -export async function createPersona( - personaCreationRequest: PersonaCreationRequest -): Promise<[Response, Response | null]> { - // first create prompt - const createPromptResponse = await createPrompt({ - personaName: personaCreationRequest.name, - systemPrompt: personaCreationRequest.system_prompt, - taskPrompt: personaCreationRequest.task_prompt, - includeCitations: personaCreationRequest.include_citations, - }); - const promptId = createPromptResponse.ok - ? (await createPromptResponse.json()).id - : null; - - let fileId = null; - if (personaCreationRequest.uploaded_image) { - fileId = await uploadFile(personaCreationRequest.uploaded_image); - if (!fileId) { - return [createPromptResponse, null]; - } - } - - const createPersonaResponse = - promptId !== null - ? await fetch("/api/persona", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify( - buildPersonaAPIBody(personaCreationRequest, promptId, fileId) - ), - }) - : null; - - return [createPromptResponse, createPersonaResponse]; -} - -export async function updatePersona( - personaUpdateRequest: PersonaUpdateRequest -): Promise<[Response, Response | null]> { - const { id, existingPromptId } = personaUpdateRequest; - - // first update prompt - let promptResponse; - let promptId; - if (existingPromptId !== undefined) { - promptResponse = await updatePrompt({ - promptId: existingPromptId, - personaName: personaUpdateRequest.name, - systemPrompt: personaUpdateRequest.system_prompt, - taskPrompt: personaUpdateRequest.task_prompt, - includeCitations: personaUpdateRequest.include_citations, - }); - promptId = existingPromptId; - } else { - promptResponse = await createPrompt({ - personaName: personaUpdateRequest.name, - systemPrompt: personaUpdateRequest.system_prompt, - taskPrompt: personaUpdateRequest.task_prompt, - includeCitations: personaUpdateRequest.include_citations, - }); - promptId = promptResponse.ok ? (await promptResponse.json()).id : null; - } - - let fileId = null; - if (personaUpdateRequest.uploaded_image) { - fileId = await uploadFile(personaUpdateRequest.uploaded_image); - if (!fileId) { - return [promptResponse, null]; - } - } - - const updatePersonaResponse = - promptResponse.ok && promptId - ? await fetch(`/api/persona/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify( - buildPersonaAPIBody(personaUpdateRequest, promptId, fileId) - ), - }) - : null; - - return [promptResponse, updatePersonaResponse]; -} - -export function deletePersona(personaId: number) { - return fetch(`/api/persona/${personaId}`, { - method: "DELETE", - }); -} - -export function buildFinalPrompt( - systemPrompt: string, - taskPrompt: string, - retrievalDisabled: boolean -) { - let queryString = Object.entries({ - system_prompt: systemPrompt, - task_prompt: taskPrompt, - retrieval_disabled: retrievalDisabled, - }) - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent(value)}` - ) - .join("&"); - - return fetch(`/api/persona/utils/prompt-explorer?${queryString}`); -} - -function smallerNumberFirstComparator(a: number, b: number) { - return a > b ? 1 : -1; -} - -function closerToZeroNegativesFirstComparator(a: number, b: number) { - if (a < 0 && b > 0) { - return -1; - } - if (a > 0 && b < 0) { - return 1; - } - - const absA = Math.abs(a); - const absB = Math.abs(b); - - if (absA === absB) { - return a > b ? 1 : -1; - } - - return absA > absB ? 1 : -1; -} - -export function personaComparator(a: Persona, b: Persona) { - if (a.display_priority === null && b.display_priority === null) { - return closerToZeroNegativesFirstComparator(a.id, b.id); - } - - if (a.display_priority !== b.display_priority) { - if (a.display_priority === null) { - return 1; - } - if (b.display_priority === null) { - return -1; - } - - return smallerNumberFirstComparator(a.display_priority, b.display_priority); - } - - return closerToZeroNegativesFirstComparator(a.id, b.id); -} diff --git a/web/src/app/admin/assistants/new/page.tsx b/web/src/app/admin/assistants/new/page.tsx deleted file mode 100644 index c770056321f..00000000000 --- a/web/src/app/admin/assistants/new/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { AssistantEditor } from "../AssistantEditor"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { RobotIcon } from "@/components/icons/icons"; -import { BackButton } from "@/components/BackButton"; -import { Card } from "@tremor/react"; -import { AdminPageTitle } from "@/components/admin/Title"; -import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS"; -import { SuccessfulPersonaUpdateRedirectType } from "../enums"; - -export default async function Page() { - const [values, error] = await fetchAssistantEditorInfoSS(); - - let body; - if (!values) { - body = ( - - ); - } else { - body = ( - - - - ); - } - - return ( -
    - - } - /> - {body} -
    - ); -} diff --git a/web/src/app/admin/assistants/page.tsx b/web/src/app/admin/assistants/page.tsx deleted file mode 100644 index 15909470582..00000000000 --- a/web/src/app/admin/assistants/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { PersonasTable } from "./PersonaTable"; -import { FiPlusSquare } from "react-icons/fi"; -import Link from "next/link"; -import { Divider, Text, Title } from "@tremor/react"; -import { fetchSS } from "@/lib/utilsSS"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { Persona } from "./interfaces"; -import { AssistantsIcon, RobotIcon } from "@/components/icons/icons"; -import { AdminPageTitle } from "@/components/admin/Title"; - -export default async function Page() { - const allPersonaResponse = await fetchSS("/admin/persona"); - const editablePersonaResponse = await fetchSS( - "/admin/persona?get_editable=true" - ); - - if (!allPersonaResponse.ok || !editablePersonaResponse.ok) { - return ( - - ); - } - - const allPersonas = (await allPersonaResponse.json()) as Persona[]; - const editablePersonas = (await editablePersonaResponse.json()) as Persona[]; - - return ( -
    - } title="Assistants" /> - - - Assistants are a way to build custom search/question-answering - experiences for different use cases. - - They allow you to customize: -
    -
      -
    • - The prompt used by your LLM of choice to respond to the user query -
    • -
    • The documents that are used as context
    • -
    -
    - -
    - - - Create an Assistant - -
    - - New Assistant -
    - - - - - Existing Assistants - -
    -
    - ); -} diff --git a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx deleted file mode 100644 index 4193c75d956..00000000000 --- a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx +++ /dev/null @@ -1,460 +0,0 @@ -"use client"; - -import { ArrayHelpers, FieldArray, Form, Formik } from "formik"; -import * as Yup from "yup"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { - DocumentSet, - SlackBotConfig, - StandardAnswerCategory, -} from "@/lib/types"; -import { - BooleanFormField, - SectionHeader, - SelectorFormField, - SubLabel, - TextArrayField, -} from "@/components/admin/connectors/Field"; -import { - createSlackBotConfig, - isPersonaASlackBotPersona, - updateSlackBotConfig, -} from "./lib"; -import { - Button, - Card, - Divider, - Tab, - TabGroup, - TabList, - TabPanel, - TabPanels, - Text, -} from "@tremor/react"; -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; - const [usingPersonas, setUsingPersonas] = useState( - existingSlackBotUsesPersona - ); - - return ( -
    - - {popup} - documentSet.id - ) - : ([] as number[]), - persona_id: - existingSlackBotConfig?.persona && - !isPersonaASlackBotPersona(existingSlackBotConfig.persona) - ? 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()), - response_type: Yup.string() - .oneOf(["quotes", "citations"]) - .required(), - answer_validity_check_enabled: Yup.boolean().required(), - questionmark_prefilter_enabled: Yup.boolean().required(), - respond_tag_only: Yup.boolean().required(), - respond_to_bots: Yup.boolean().required(), - enable_auto_filters: Yup.boolean().required(), - respond_member_group_list: Yup.array().of(Yup.string()).required(), - still_need_help_enabled: Yup.boolean().required(), - 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); - - // remove empty channel names - const cleanedValues = { - ...values, - channel_names: values.channel_names.filter( - (channelName) => channelName !== "" - ), - respond_member_group_list: values.respond_member_group_list, - usePersona: usingPersonas, - standard_answer_categories: values.standard_answer_categories.map( - (category) => category.id - ), - }; - if (!cleanedValues.still_need_help_enabled) { - cleanedValues.follow_up_tags = undefined; - } else { - if (!cleanedValues.follow_up_tags) { - cleanedValues.follow_up_tags = []; - } - } - let response; - if (isUpdate) { - response = await updateSlackBotConfig( - existingSlackBotConfig.id, - cleanedValues - ); - } else { - response = await createSlackBotConfig(cleanedValues); - } - formikHelpers.setSubmitting(false); - if (response.ok) { - router.push(`/admin/bot?u=${Date.now()}`); - } else { - const responseJson = await response.json(); - const errorMsg = responseJson.detail || responseJson.message; - setPopup({ - message: isUpdate - ? `Error updating DanswerBot config - ${errorMsg}` - : `Error creating DanswerBot config - ${errorMsg}`, - type: "error", - }); - } - }} - > - {({ isSubmitting, values, setFieldValue }) => ( -
    -
    - The Basics - - - The names of the Slack channels you want this - configuration to apply to. For example, - '#ask-danswer'. -
    -
    - NOTE: you still need to add DanswerBot to the - channel(s) in Slack itself. Setting this config will not - auto-add the bot to the channel. -
    - } - /> - - - If set to Citations, DanswerBot will respond with a direct - answer with inline citations. It will also provide links - to these cited documents below the answer. When in doubt, - choose this option. -
    -
    - If set to Quotes, DanswerBot will respond with a direct - answer as well as with quotes pulled from the context - documents to support that answer. DanswerBot will also - give a list of relevant documents. Choose this option if - you want a very detailed response AND/OR a list of - relevant documents would be useful just in case the LLM - missed anything. - - } - options={[ - { name: "Citations", value: "citations" }, - { name: "Quotes", value: "quotes" }, - ]} - /> - - - - When should DanswerBot respond? - - - - - - - - - - Post Response Behavior - - - {values.still_need_help_enabled && ( - - The full email addresses of the Slack users we should - tag if the user clicks the "Still need help?" - button. For example, 'mark@acme.com'. -
    - Or provide a user group by either the name or the - handle. For example, 'Danswer Team' or - 'danswer-team'. -
    -
    - If no emails are provided, we will not tag anyone and - will just react with a 🆘 emoji to the original message. -
    - } - /> - )} - - - -
    - - [Optional] Data Sources and Prompts - - - Use either an Assistant or Document Sets to control - how DanswerBot answers. - - -
      -
    • - You should use an Assistant if you also want to - customize the prompt and retrieval settings. -
    • -
    • - You should use Document Sets if you just want to control - which documents DanswerBot uses as references. -
    • -
    -
    - - NOTE: whichever tab you are when you submit the form - will be the one that is used. For example, if you are on the - "Assistants" tab, then the Assistant and its - attached knowledge will be used, even if you have Document - Sets selected. - -
    - - setUsingPersonas(index === 1)} - > - - Document Sets - Assistants - - - - ( -
    -
    - - The document sets that DanswerBot should search - through. If left blank, DanswerBot will search - through all documents. - -
    -
    - {documentSets.map((documentSet) => { - const ind = values.document_sets.indexOf( - documentSet.id - ); - let isSelected = ind !== -1; - return ( -
    { - if (isSelected) { - arrayHelpers.remove(ind); - } else { - arrayHelpers.push(documentSet.id); - } - }} - > -
    - {documentSet.name} -
    -
    - ); - })} -
    -
    - )} - /> -
    - - { - return { - name: persona.name, - value: persona.id, - }; - })} - /> - -
    -
    - - - -
    - - [Optional] Standard Answer Categories - -
    - { - 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(), - }) - )} - /> -
    -
    - - - -
    - -
    - - - )} - - - - ); -}; diff --git a/web/src/app/admin/bot/SlackBotTokensForm.tsx b/web/src/app/admin/bot/SlackBotTokensForm.tsx deleted file mode 100644 index 37bba487754..00000000000 --- a/web/src/app/admin/bot/SlackBotTokensForm.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Form, Formik } from "formik"; -import * as Yup from "yup"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { SlackBotTokens } from "@/lib/types"; -import { - TextArrayField, - TextFormField, -} from "@/components/admin/connectors/Field"; -import { - createSlackBotConfig, - setSlackBotTokens, - updateSlackBotConfig, -} from "./lib"; -import { Button, Card } from "@tremor/react"; - -interface SlackBotTokensFormProps { - onClose: () => void; - setPopup: (popupSpec: PopupSpec | null) => void; - existingTokens?: SlackBotTokens; -} - -export const SlackBotTokensForm = ({ - onClose, - setPopup, - existingTokens, -}: SlackBotTokensFormProps) => { - return ( - - { - formikHelpers.setSubmitting(true); - const response = await setSlackBotTokens(values); - formikHelpers.setSubmitting(false); - if (response.ok) { - setPopup({ - message: "Successfully set Slack tokens!", - type: "success", - }); - onClose(); - } else { - const errorMsg = await response.text(); - setPopup({ - message: `Error setting Slack tokens - ${errorMsg}`, - type: "error", - }); - } - }} - > - {({ isSubmitting }) => ( -
    - - -
    - -
    - - )} -
    -
    - ); -}; diff --git a/web/src/app/admin/bot/[id]/page.tsx b/web/src/app/admin/bot/[id]/page.tsx deleted file mode 100644 index 035c5a0a510..00000000000 --- a/web/src/app/admin/bot/[id]/page.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { AdminPageTitle } from "@/components/admin/Title"; -import { CPUIcon } from "@/components/icons/icons"; -import { SlackBotCreationForm } from "../SlackBotConfigCreationForm"; -import { fetchSS } from "@/lib/utilsSS"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { - DocumentSet, - SlackBotConfig, - StandardAnswerCategory, -} from "@/lib/types"; -import { Text } from "@tremor/react"; -import { BackButton } from "@/components/BackButton"; -import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; -import { - FetchAssistantsResponse, - fetchAssistantsSS, -} from "@/lib/assistants/fetchAssistantsSS"; - -async function Page({ params }: { params: { id: string } }) { - const tasks = [ - fetchSS("/manage/admin/slack-bot/config"), - fetchSS("/manage/document-set"), - fetchAssistantsSS(), - fetchSS("/manage/admin/standard-answer/category"), - ]; - - const [ - slackBotsResponse, - documentSetsResponse, - [assistants, assistantsFetchError], - standardAnswerCategoriesResponse, - ] = (await Promise.all(tasks)) as [ - Response, - Response, - FetchAssistantsResponse, - Response, - ]; - - if (!slackBotsResponse.ok) { - return ( - - ); - } - const allSlackBotConfigs = - (await slackBotsResponse.json()) as SlackBotConfig[]; - const slackBotConfig = allSlackBotConfigs.find( - (config) => config.id.toString() === params.id - ); - if (!slackBotConfig) { - return ( - - ); - } - - if (!documentSetsResponse.ok) { - return ( - - ); - } - const documentSets = (await documentSetsResponse.json()) as DocumentSet[]; - - if (assistantsFetchError) { - return ( - - ); - } - - if (!standardAnswerCategoriesResponse.ok) { - return ( - - ); - } - - const standardAnswerCategories = - (await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[]; - - return ( -
    - - - - } - title="Edit Slack Bot Config" - /> - - - Edit the existing configuration below! This config will determine how - DanswerBot behaves in the specified channels. - - - -
    - ); -} - -export default Page; diff --git a/web/src/app/admin/bot/hooks.ts b/web/src/app/admin/bot/hooks.ts deleted file mode 100644 index 95829938ec0..00000000000 --- a/web/src/app/admin/bot/hooks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { SlackBotConfig, SlackBotTokens } from "@/lib/types"; -import useSWR, { mutate } from "swr"; - -export const useSlackBotConfigs = () => { - const url = "/api/manage/admin/slack-bot/config"; - const swrResponse = useSWR(url, errorHandlingFetcher); - - return { - ...swrResponse, - refreshSlackBotConfigs: () => mutate(url), - }; -}; - -export const useSlackBotTokens = () => { - const url = "/api/manage/admin/slack-bot/tokens"; - const swrResponse = useSWR(url, errorHandlingFetcher); - - return { - ...swrResponse, - refreshSlackBotTokens: () => mutate(url), - }; -}; diff --git a/web/src/app/admin/bot/lib.ts b/web/src/app/admin/bot/lib.ts deleted file mode 100644 index c2d2b291502..00000000000 --- a/web/src/app/admin/bot/lib.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - ChannelConfig, - SlackBotResponseType, - SlackBotTokens, -} from "@/lib/types"; -import { Persona } from "../assistants/interfaces"; - -interface SlackBotConfigCreationRequest { - document_sets: number[]; - persona_id: number | null; - enable_auto_filters: boolean; - channel_names: string[]; - answer_validity_check_enabled: boolean; - questionmark_prefilter_enabled: boolean; - respond_tag_only: boolean; - respond_to_bots: boolean; - respond_member_group_list: string[]; - follow_up_tags?: string[]; - usePersona: boolean; - response_type: SlackBotResponseType; - standard_answer_categories: number[]; -} - -const buildFiltersFromCreationRequest = ( - creationRequest: SlackBotConfigCreationRequest -): string[] => { - const answerFilters = [] as string[]; - if (creationRequest.answer_validity_check_enabled) { - answerFilters.push("well_answered_postfilter"); - } - if (creationRequest.questionmark_prefilter_enabled) { - answerFilters.push("questionmark_prefilter"); - } - return answerFilters; -}; - -const buildRequestBodyFromCreationRequest = ( - creationRequest: SlackBotConfigCreationRequest -) => { - return JSON.stringify({ - channel_names: creationRequest.channel_names, - respond_tag_only: creationRequest.respond_tag_only, - respond_to_bots: creationRequest.respond_to_bots, - enable_auto_filters: creationRequest.enable_auto_filters, - respond_member_group_list: creationRequest.respond_member_group_list, - answer_filters: buildFiltersFromCreationRequest(creationRequest), - follow_up_tags: creationRequest.follow_up_tags?.filter((tag) => tag !== ""), - ...(creationRequest.usePersona - ? { persona_id: creationRequest.persona_id } - : { document_sets: creationRequest.document_sets }), - response_type: creationRequest.response_type, - standard_answer_categories: creationRequest.standard_answer_categories, - }); -}; - -export const createSlackBotConfig = async ( - creationRequest: SlackBotConfigCreationRequest -) => { - return fetch("/api/manage/admin/slack-bot/config", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: buildRequestBodyFromCreationRequest(creationRequest), - }); -}; - -export const updateSlackBotConfig = async ( - id: number, - creationRequest: SlackBotConfigCreationRequest -) => { - return fetch(`/api/manage/admin/slack-bot/config/${id}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: buildRequestBodyFromCreationRequest(creationRequest), - }); -}; - -export const deleteSlackBotConfig = async (id: number) => { - return fetch(`/api/manage/admin/slack-bot/config/${id}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - }); -}; - -export const setSlackBotTokens = async (slackBotTokens: SlackBotTokens) => { - return fetch(`/api/manage/admin/slack-bot/tokens`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(slackBotTokens), - }); -}; - -export function isPersonaASlackBotPersona(persona: Persona) { - return persona.name.startsWith("__slack_bot_persona__"); -} diff --git a/web/src/app/admin/bot/new/page.tsx b/web/src/app/admin/bot/new/page.tsx deleted file mode 100644 index 89881f944f4..00000000000 --- a/web/src/app/admin/bot/new/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { AdminPageTitle } from "@/components/admin/Title"; -import { CPUIcon } from "@/components/icons/icons"; -import { SlackBotCreationForm } from "../SlackBotConfigCreationForm"; -import { fetchSS } from "@/lib/utilsSS"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { DocumentSet, StandardAnswerCategory } from "@/lib/types"; -import { BackButton } from "@/components/BackButton"; -import { Text } from "@tremor/react"; -import { - FetchAssistantsResponse, - fetchAssistantsSS, -} from "@/lib/assistants/fetchAssistantsSS"; - -async function Page() { - 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 ( - - ); - } - const documentSets = (await documentSetsResponse.json()) as DocumentSet[]; - - if (assistantsFetchError) { - return ( - - ); - } - - if (!standardAnswerCategoriesResponse.ok) { - return ( - - ); - } - - const standardAnswerCategories = - (await standardAnswerCategoriesResponse.json()) as StandardAnswerCategory[]; - - return ( -
    - - } - title="New Slack Bot Config" - /> - - - Define a new configuration below! This config will determine how - DanswerBot behaves in the specified channels. - - - -
    - ); -} - -export default Page; diff --git a/web/src/app/admin/bot/page.tsx b/web/src/app/admin/bot/page.tsx deleted file mode 100644 index 14f270ee9bc..00000000000 --- a/web/src/app/admin/bot/page.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import { ThreeDotsLoader } from "@/components/Loading"; -import { PageSelector } from "@/components/PageSelector"; -import { - CPUIcon, - EditIcon, - SlackIcon, - TrashIcon, -} from "@/components/icons/icons"; -import { SlackBotConfig } from "@/lib/types"; -import { useState } from "react"; -import { useSlackBotConfigs, useSlackBotTokens } from "./hooks"; -import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup"; -import { deleteSlackBotConfig, isPersonaASlackBotPersona } from "./lib"; -import { SlackBotTokensForm } from "./SlackBotTokensForm"; -import { AdminPageTitle } from "@/components/admin/Title"; -import { - Button, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, - Title, -} from "@tremor/react"; -import { - FiArrowUpRight, - FiChevronDown, - FiChevronUp, - FiSlack, -} from "react-icons/fi"; -import Link from "next/link"; -import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh"; -import { ErrorCallout } from "@/components/ErrorCallout"; - -const numToDisplay = 50; - -const SlackBotConfigsTable = ({ - slackBotConfigs, - refresh, - setPopup, -}: { - slackBotConfigs: SlackBotConfig[]; - refresh: () => void; - setPopup: (popupSpec: PopupSpec | null) => void; -}) => { - const [page, setPage] = useState(1); - - // sort by name for consistent ordering - slackBotConfigs.sort((a, b) => { - if (a.id < b.id) { - return -1; - } else if (a.id > b.id) { - return 1; - } else { - return 0; - } - }); - - return ( -
    - - - - Channels - Persona - Document Sets - Delete - - - - {slackBotConfigs - .slice(numToDisplay * (page - 1), numToDisplay * page) - .map((slackBotConfig) => { - return ( - - -
    - - - -
    - {slackBotConfig.channel_config.channel_names - .map((channel_name) => `#${channel_name}`) - .join(", ")} -
    -
    -
    - - {slackBotConfig.persona && - !isPersonaASlackBotPersona(slackBotConfig.persona) ? ( - - - {slackBotConfig.persona.name} - - ) : ( - "-" - )} - - - {" "} -
    - {slackBotConfig.persona && - slackBotConfig.persona.document_sets.length > 0 - ? slackBotConfig.persona.document_sets - .map((documentSet) => documentSet.name) - .join(", ") - : "-"} -
    -
    - - {" "} -
    { - const response = await deleteSlackBotConfig( - slackBotConfig.id - ); - if (response.ok) { - setPopup({ - message: `Slack bot config "${slackBotConfig.id}" deleted`, - type: "success", - }); - } else { - const errorMsg = await response.text(); - setPopup({ - message: `Failed to delete Slack bot config - ${errorMsg}`, - type: "error", - }); - } - refresh(); - }} - > - -
    -
    -
    - ); - })} -
    -
    - -
    -
    - setPage(newPage)} - /> -
    -
    -
    - ); -}; - -const Main = () => { - const [slackBotTokensModalIsOpen, setSlackBotTokensModalIsOpen] = - useState(false); - const { popup, setPopup } = usePopup(); - const { - data: slackBotConfigs, - isLoading: isSlackBotConfigsLoading, - error: slackBotConfigsError, - refreshSlackBotConfigs, - } = useSlackBotConfigs(); - - const { data: slackBotTokens, refreshSlackBotTokens } = useSlackBotTokens(); - - if (isSlackBotConfigsLoading) { - return ; - } - - if (slackBotConfigsError || !slackBotConfigs || !slackBotConfigs) { - return ( - - ); - } - - return ( -
    - ); -}; - -const Page = () => { - return ( -
    - } - title="Slack Bot Configuration" - /> - - -
    -
    - ); -}; - -export default Page; diff --git a/web/src/app/admin/configuration/llm/ConfiguredLLMProviderDisplay.tsx b/web/src/app/admin/configuration/llm/ConfiguredLLMProviderDisplay.tsx deleted file mode 100644 index aa8c0f9725d..00000000000 --- a/web/src/app/admin/configuration/llm/ConfiguredLLMProviderDisplay.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup"; -import { FullLLMProvider, WellKnownLLMProviderDescriptor } from "./interfaces"; -import { Modal } from "@/components/Modal"; -import { LLMProviderUpdateForm } from "./LLMProviderUpdateForm"; -import { CustomLLMProviderUpdateForm } from "./CustomLLMProviderUpdateForm"; -import { useState } from "react"; -import { LLM_PROVIDERS_ADMIN_URL } from "./constants"; -import { mutate } from "swr"; -import { Badge, Button } from "@tremor/react"; -import isEqual from "lodash/isEqual"; - -function LLMProviderUpdateModal({ - llmProviderDescriptor, - onClose, - existingLlmProvider, - shouldMarkAsDefault, - setPopup, -}: { - llmProviderDescriptor: WellKnownLLMProviderDescriptor | null | undefined; - onClose: () => void; - existingLlmProvider?: FullLLMProvider; - shouldMarkAsDefault?: boolean; - setPopup?: (popup: PopupSpec) => void; -}) { - const providerName = existingLlmProvider?.name - ? `"${existingLlmProvider.name}"` - : null || - llmProviderDescriptor?.display_name || - llmProviderDescriptor?.name || - "Custom LLM Provider"; - return ( - onClose()} - > -
    - {llmProviderDescriptor ? ( - - ) : ( - - )} -
    -
    - ); -} - -function LLMProviderDisplay({ - llmProviderDescriptor, - existingLlmProvider, - shouldMarkAsDefault, -}: { - llmProviderDescriptor: WellKnownLLMProviderDescriptor | null | undefined; - existingLlmProvider: FullLLMProvider; - shouldMarkAsDefault?: boolean; -}) { - const [formIsVisible, setFormIsVisible] = useState(false); - const { popup, setPopup } = usePopup(); - - const providerName = - existingLlmProvider?.name || - llmProviderDescriptor?.display_name || - llmProviderDescriptor?.name; - return ( -
    - {popup} -
    -
    -
    {providerName}
    -
    ({existingLlmProvider.provider})
    - {!existingLlmProvider.is_default_provider && ( -
    { - const response = await fetch( - `${LLM_PROVIDERS_ADMIN_URL}/${existingLlmProvider.id}/default`, - { - method: "POST", - } - ); - if (!response.ok) { - const errorMsg = (await response.json()).detail; - setPopup({ - type: "error", - message: `Failed to set provider as default: ${errorMsg}`, - }); - return; - } - - mutate(LLM_PROVIDERS_ADMIN_URL); - setPopup({ - type: "success", - message: "Provider set as default successfully!", - }); - }} - > - Set as default -
    - )} -
    - - {existingLlmProvider && ( -
    - {existingLlmProvider.is_default_provider ? ( - - Default - - ) : ( - - Enabled - - )} -
    - )} - -
    - -
    -
    - {formIsVisible && ( - setFormIsVisible(false)} - existingLlmProvider={existingLlmProvider} - shouldMarkAsDefault={shouldMarkAsDefault} - setPopup={setPopup} - /> - )} -
    - ); -} - -export function ConfiguredLLMProviderDisplay({ - existingLlmProviders, - llmProviderDescriptors, -}: { - existingLlmProviders: FullLLMProvider[]; - llmProviderDescriptors: WellKnownLLMProviderDescriptor[]; -}) { - existingLlmProviders = existingLlmProviders.sort((a, b) => { - if (a.is_default_provider && !b.is_default_provider) { - return -1; - } - if (!a.is_default_provider && b.is_default_provider) { - return 1; - } - return a.provider > b.provider ? 1 : -1; - }); - - return ( -
    - {existingLlmProviders.map((provider) => { - const defaultProviderDesciptor = llmProviderDescriptors.find( - (llmProviderDescriptors) => - llmProviderDescriptors.name === provider.provider - ); - - return ( - - ); - })} -
    - ); -} diff --git a/web/src/app/admin/configuration/llm/CustomLLMProviderUpdateForm.tsx b/web/src/app/admin/configuration/llm/CustomLLMProviderUpdateForm.tsx deleted file mode 100644 index 80ff1f456b9..00000000000 --- a/web/src/app/admin/configuration/llm/CustomLLMProviderUpdateForm.tsx +++ /dev/null @@ -1,532 +0,0 @@ -import { LoadingAnimation } from "@/components/Loading"; -import { Button, Divider, Text } from "@tremor/react"; -import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; -import { - ArrayHelpers, - ErrorMessage, - Field, - FieldArray, - Form, - Formik, -} from "formik"; -import { FiPlus, FiTrash, FiX } from "react-icons/fi"; -import { LLM_PROVIDERS_ADMIN_URL } from "./constants"; -import { - Label, - SubLabel, - TextArrayField, - TextFormField, - BooleanFormField, -} from "@/components/admin/connectors/Field"; -import { useState } from "react"; -import { Bubble } from "@/components/Bubble"; -import { GroupsIcon } from "@/components/icons/icons"; -import { useSWRConfig } from "swr"; -import { useUserGroups } from "@/lib/hooks"; -import { FullLLMProvider } from "./interfaces"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import * as Yup from "yup"; -import isEqual from "lodash/isEqual"; - -function customConfigProcessing(customConfigsList: [string, string][]) { - const customConfig: { [key: string]: string } = {}; - customConfigsList.forEach(([key, value]) => { - customConfig[key] = value; - }); - return customConfig; -} - -export function CustomLLMProviderUpdateForm({ - onClose, - existingLlmProvider, - shouldMarkAsDefault, - setPopup, -}: { - onClose: () => void; - existingLlmProvider?: FullLLMProvider; - shouldMarkAsDefault?: boolean; - setPopup?: (popup: PopupSpec) => void; -}) { - const { mutate } = useSWRConfig(); - - const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); - - // EE only - const { data: userGroups, isLoading: userGroupsIsLoading } = useUserGroups(); - - const [isTesting, setIsTesting] = useState(false); - const [testError, setTestError] = useState(""); - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - // Define the initial values based on the provider's requirements - const initialValues = { - name: existingLlmProvider?.name ?? "", - provider: existingLlmProvider?.provider ?? "", - api_key: existingLlmProvider?.api_key ?? "", - api_base: existingLlmProvider?.api_base ?? "", - api_version: existingLlmProvider?.api_version ?? "", - default_model_name: existingLlmProvider?.default_model_name ?? null, - fast_default_model_name: - existingLlmProvider?.fast_default_model_name ?? null, - model_names: existingLlmProvider?.model_names ?? [], - custom_config_list: existingLlmProvider?.custom_config - ? Object.entries(existingLlmProvider.custom_config) - : [], - is_public: existingLlmProvider?.is_public ?? true, - groups: existingLlmProvider?.groups ?? [], - }; - - // Setup validation schema if required - const validationSchema = Yup.object({ - name: Yup.string().required("Display Name is required"), - provider: Yup.string().required("Provider Name is required"), - api_key: Yup.string(), - api_base: Yup.string(), - api_version: Yup.string(), - model_names: Yup.array(Yup.string().required("Model name is required")), - default_model_name: Yup.string().required("Model name is required"), - fast_default_model_name: Yup.string().nullable(), - custom_config_list: Yup.array(), - // EE Only - is_public: Yup.boolean().required(), - groups: Yup.array().of(Yup.number()), - }); - - return ( - { - setSubmitting(true); - - if (values.model_names.length === 0) { - const fullErrorMsg = "At least one model name is required"; - if (setPopup) { - setPopup({ - type: "error", - message: fullErrorMsg, - }); - } else { - alert(fullErrorMsg); - } - setSubmitting(false); - return; - } - - // don't set groups if marked as public - const groups = values.is_public ? [] : values.groups; - - // test the configuration - if (!isEqual(values, initialValues)) { - setIsTesting(true); - - const response = await fetch("/api/admin/llm/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - custom_config: customConfigProcessing(values.custom_config_list), - ...values, - }), - }); - setIsTesting(false); - - if (!response.ok) { - const errorMsg = (await response.json()).detail; - setTestError(errorMsg); - return; - } - } - - const response = await fetch(LLM_PROVIDERS_ADMIN_URL, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...values, - custom_config: customConfigProcessing(values.custom_config_list), - }), - }); - - if (!response.ok) { - const errorMsg = (await response.json()).detail; - const fullErrorMsg = existingLlmProvider - ? `Failed to update provider: ${errorMsg}` - : `Failed to enable provider: ${errorMsg}`; - if (setPopup) { - setPopup({ - type: "error", - message: fullErrorMsg, - }); - } else { - alert(fullErrorMsg); - } - return; - } - - if (shouldMarkAsDefault) { - const newLlmProvider = (await response.json()) as FullLLMProvider; - const setDefaultResponse = await fetch( - `${LLM_PROVIDERS_ADMIN_URL}/${newLlmProvider.id}/default`, - { - method: "POST", - } - ); - if (!setDefaultResponse.ok) { - const errorMsg = (await setDefaultResponse.json()).detail; - const fullErrorMsg = `Failed to set provider as default: ${errorMsg}`; - if (setPopup) { - setPopup({ - type: "error", - message: fullErrorMsg, - }); - } else { - alert(fullErrorMsg); - } - return; - } - } - - mutate(LLM_PROVIDERS_ADMIN_URL); - onClose(); - - const successMsg = existingLlmProvider - ? "Provider updated successfully!" - : "Provider enabled successfully!"; - if (setPopup) { - setPopup({ - type: "success", - message: successMsg, - }); - } else { - alert(successMsg); - } - - setSubmitting(false); - }} - > - {({ values, setFieldValue }) => { - return ( -
    - - - - Should be one of the providers listed at{" "} - - https://docs.litellm.ai/docs/providers - - . - - } - placeholder="Name of the custom provider" - /> - - - - - Fill in the following as is needed. Refer to the LiteLLM - documentation for the model provider name specified above in order - to determine which fields are required. - - - - - - - - - - - <> -
    - Additional configurations needed by the model provider. Are - passed to litellm via environment variables. -
    - -
    - For example, when configuring the Cloudflare provider, you - would need to set `CLOUDFLARE_ACCOUNT_ID` as the key and your - Cloudflare account ID as the value. -
    - -
    - - ) => ( -
    - {values.custom_config_list.map((_, index) => { - return ( -
    -
    -
    -
    - - - -
    - -
    - - - -
    -
    -
    - arrayHelpers.remove(index)} - /> -
    -
    -
    - ); - })} - - -
    - )} - /> - - - - - List the individual models that you want to make available as - a part of this provider. At least one must be specified. For - the best experience your [Provider Name]/[Model Name] should - match one of the pairs listed{" "} - - here - - . - - } - /> - - - - - - - - - - - - {showAdvancedOptions && ( - <> - {isPaidEnterpriseFeaturesEnabled && userGroups && ( - <> - - - {userGroups && - userGroups.length > 0 && - !values.is_public && ( -
    - - Select which User Groups should have access to this - LLM Provider. - -
    - {userGroups.map((userGroup) => { - const isSelected = values.groups.includes( - userGroup.id - ); - return ( - { - if (isSelected) { - setFieldValue( - "groups", - values.groups.filter( - (id) => id !== userGroup.id - ) - ); - } else { - setFieldValue("groups", [ - ...values.groups, - userGroup.id, - ]); - } - }} - > -
    - -
    {userGroup.name}
    -
    -
    - ); - })} -
    -
    - )} - - )} - - )} - -
    - {/* NOTE: this is above the test button to make sure it's visible */} - {testError && ( - {testError} - )} - -
    - - {existingLlmProvider && ( - - )} -
    -
    - - ); - }} -
    - ); -} diff --git a/web/src/app/admin/configuration/llm/LLMConfiguration.tsx b/web/src/app/admin/configuration/llm/LLMConfiguration.tsx deleted file mode 100644 index f8fb7512740..00000000000 --- a/web/src/app/admin/configuration/llm/LLMConfiguration.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { Modal } from "@/components/Modal"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { useState } from "react"; -import useSWR from "swr"; -import { Button, Callout, Text, Title } from "@tremor/react"; -import { ThreeDotsLoader } from "@/components/Loading"; -import { FullLLMProvider, WellKnownLLMProviderDescriptor } from "./interfaces"; -import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup"; -import { LLMProviderUpdateForm } from "./LLMProviderUpdateForm"; -import { LLM_PROVIDERS_ADMIN_URL } from "./constants"; -import { CustomLLMProviderUpdateForm } from "./CustomLLMProviderUpdateForm"; -import { ConfiguredLLMProviderDisplay } from "./ConfiguredLLMProviderDisplay"; - -function LLMProviderUpdateModal({ - llmProviderDescriptor, - onClose, - existingLlmProvider, - shouldMarkAsDefault, - setPopup, -}: { - llmProviderDescriptor: WellKnownLLMProviderDescriptor | null; - onClose: () => void; - existingLlmProvider?: FullLLMProvider; - shouldMarkAsDefault?: boolean; - setPopup?: (popup: PopupSpec) => void; -}) { - const providerName = - llmProviderDescriptor?.display_name || - llmProviderDescriptor?.name || - existingLlmProvider?.name || - "Custom LLM Provider"; - return ( - onClose()}> -
    - {llmProviderDescriptor ? ( - - ) : ( - - )} -
    -
    - ); -} - -function DefaultLLMProviderDisplay({ - llmProviderDescriptor, - shouldMarkAsDefault, -}: { - llmProviderDescriptor: WellKnownLLMProviderDescriptor | null; - shouldMarkAsDefault?: boolean; -}) { - const [formIsVisible, setFormIsVisible] = useState(false); - const { popup, setPopup } = usePopup(); - - const providerName = - llmProviderDescriptor?.display_name || llmProviderDescriptor?.name; - return ( -
    - {popup} -
    -
    -
    {providerName}
    -
    - -
    - -
    -
    - {formIsVisible && ( - setFormIsVisible(false)} - shouldMarkAsDefault={shouldMarkAsDefault} - setPopup={setPopup} - /> - )} -
    - ); -} - -function AddCustomLLMProvider({ - existingLlmProviders, -}: { - existingLlmProviders: FullLLMProvider[]; -}) { - const [formIsVisible, setFormIsVisible] = useState(false); - - if (formIsVisible) { - return ( - setFormIsVisible(false)} - > -
    - setFormIsVisible(false)} - shouldMarkAsDefault={existingLlmProviders.length === 0} - /> -
    -
    - ); - } - - return ( - - ); -} - -export function LLMConfiguration() { - const { data: llmProviderDescriptors } = useSWR< - WellKnownLLMProviderDescriptor[] - >("/api/admin/llm/built-in/options", errorHandlingFetcher); - const { data: existingLlmProviders } = useSWR( - LLM_PROVIDERS_ADMIN_URL, - errorHandlingFetcher - ); - - if (!llmProviderDescriptors || !existingLlmProviders) { - return ; - } - - return ( - <> - Enabled LLM Providers - - {existingLlmProviders.length > 0 ? ( - <> - - If multiple LLM providers are enabled, the default provider will be - used for all "Default" Assistants. For user-created - Assistants, you can select the LLM provider/model that best fits the - use case! - - - - ) : ( - - Please set one up below in order to start using Danswer! - - )} - - Add LLM Provider - - Add a new LLM provider by either selecting from one of the default - providers or by specifying your own custom LLM provider. - - -
    - {llmProviderDescriptors.map((llmProviderDescriptor) => { - return ( - - ); - })} -
    - -
    - -
    - - ); -} diff --git a/web/src/app/admin/configuration/llm/LLMProviderUpdateForm.tsx b/web/src/app/admin/configuration/llm/LLMProviderUpdateForm.tsx deleted file mode 100644 index 49d95d096f5..00000000000 --- a/web/src/app/admin/configuration/llm/LLMProviderUpdateForm.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import { LoadingAnimation } from "@/components/Loading"; -import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle"; -import { Button, Divider, Text } from "@tremor/react"; -import { Form, Formik } from "formik"; -import { FiTrash } from "react-icons/fi"; -import { LLM_PROVIDERS_ADMIN_URL } from "./constants"; -import { - SelectorFormField, - TextFormField, - BooleanFormField, - MultiSelectField, -} from "@/components/admin/connectors/Field"; -import { useState } from "react"; -import { Bubble } from "@/components/Bubble"; -import { GroupsIcon } from "@/components/icons/icons"; -import { useSWRConfig } from "swr"; -import { - defaultModelsByProvider, - getDisplayNameForModel, - useUserGroups, -} from "@/lib/hooks"; -import { FullLLMProvider, WellKnownLLMProviderDescriptor } from "./interfaces"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import * as Yup from "yup"; -import isEqual from "lodash/isEqual"; - -export function LLMProviderUpdateForm({ - llmProviderDescriptor, - onClose, - existingLlmProvider, - shouldMarkAsDefault, - setPopup, -}: { - llmProviderDescriptor: WellKnownLLMProviderDescriptor; - onClose: () => void; - existingLlmProvider?: FullLLMProvider; - shouldMarkAsDefault?: boolean; - setPopup?: (popup: PopupSpec) => void; -}) { - const { mutate } = useSWRConfig(); - - const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); - - // EE only - const { data: userGroups, isLoading: userGroupsIsLoading } = useUserGroups(); - - const [isTesting, setIsTesting] = useState(false); - const [testError, setTestError] = useState(""); - - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); - - // Define the initial values based on the provider's requirements - const initialValues = { - name: existingLlmProvider?.name ?? "", - api_key: existingLlmProvider?.api_key ?? "", - api_base: existingLlmProvider?.api_base ?? "", - api_version: existingLlmProvider?.api_version ?? "", - default_model_name: - existingLlmProvider?.default_model_name ?? - (llmProviderDescriptor.default_model || - llmProviderDescriptor.llm_names[0]), - fast_default_model_name: - existingLlmProvider?.fast_default_model_name ?? - (llmProviderDescriptor.default_fast_model || null), - custom_config: - existingLlmProvider?.custom_config ?? - llmProviderDescriptor.custom_config_keys?.reduce( - (acc, customConfigKey) => { - acc[customConfigKey.name] = ""; - return acc; - }, - {} as { [key: string]: string } - ), - is_public: existingLlmProvider?.is_public ?? true, - groups: existingLlmProvider?.groups ?? [], - display_model_names: - existingLlmProvider?.display_model_names || - defaultModelsByProvider[llmProviderDescriptor.name] || - [], - }; - - // Setup validation schema if required - const validationSchema = Yup.object({ - name: Yup.string().required("Display Name is required"), - api_key: llmProviderDescriptor.api_key_required - ? Yup.string().required("API Key is required") - : Yup.string(), - api_base: llmProviderDescriptor.api_base_required - ? Yup.string().required("API Base is required") - : Yup.string(), - api_version: llmProviderDescriptor.api_version_required - ? Yup.string().required("API Version is required") - : Yup.string(), - ...(llmProviderDescriptor.custom_config_keys - ? { - custom_config: Yup.object( - llmProviderDescriptor.custom_config_keys.reduce( - (acc, customConfigKey) => { - if (customConfigKey.is_required) { - acc[customConfigKey.name] = Yup.string().required( - `${customConfigKey.name} is required` - ); - } - return acc; - }, - {} as { [key: string]: Yup.StringSchema } - ) - ), - } - : {}), - default_model_name: Yup.string().required("Model name is required"), - fast_default_model_name: Yup.string().nullable(), - // EE Only - is_public: Yup.boolean().required(), - groups: Yup.array().of(Yup.number()), - display_model_names: Yup.array().of(Yup.string()), - }); - - return ( - { - setSubmitting(true); - - // test the configuration - if (!isEqual(values, initialValues)) { - setIsTesting(true); - - const response = await fetch("/api/admin/llm/test", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - provider: llmProviderDescriptor.name, - ...values, - }), - }); - setIsTesting(false); - - if (!response.ok) { - const errorMsg = (await response.json()).detail; - setTestError(errorMsg); - return; - } - } - - const response = await fetch(LLM_PROVIDERS_ADMIN_URL, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - provider: llmProviderDescriptor.name, - ...values, - fast_default_model_name: - values.fast_default_model_name || values.default_model_name, - }), - }); - - if (!response.ok) { - const errorMsg = (await response.json()).detail; - const fullErrorMsg = existingLlmProvider - ? `Failed to update provider: ${errorMsg}` - : `Failed to enable provider: ${errorMsg}`; - if (setPopup) { - setPopup({ - type: "error", - message: fullErrorMsg, - }); - } else { - alert(fullErrorMsg); - } - return; - } - - if (shouldMarkAsDefault) { - const newLlmProvider = (await response.json()) as FullLLMProvider; - const setDefaultResponse = await fetch( - `${LLM_PROVIDERS_ADMIN_URL}/${newLlmProvider.id}/default`, - { - method: "POST", - } - ); - if (!setDefaultResponse.ok) { - const errorMsg = (await setDefaultResponse.json()).detail; - const fullErrorMsg = `Failed to set provider as default: ${errorMsg}`; - if (setPopup) { - setPopup({ - type: "error", - message: fullErrorMsg, - }); - } else { - alert(fullErrorMsg); - } - return; - } - } - - mutate(LLM_PROVIDERS_ADMIN_URL); - onClose(); - - const successMsg = existingLlmProvider - ? "Provider updated successfully!" - : "Provider enabled successfully!"; - if (setPopup) { - setPopup({ - type: "success", - message: successMsg, - }); - } else { - alert(successMsg); - } - - setSubmitting(false); - }} - > - {({ values, setFieldValue }) => ( -
    - - - {llmProviderDescriptor.api_key_required && ( - - )} - - {llmProviderDescriptor.api_base_required && ( - - )} - - {llmProviderDescriptor.api_version_required && ( - - )} - - {llmProviderDescriptor.custom_config_keys?.map((customConfigKey) => ( -
    - -
    - ))} - - - - {llmProviderDescriptor.llm_names.length > 0 ? ( - ({ - name: getDisplayNameForModel(name), - value: name, - }))} - maxHeight="max-h-56" - /> - ) : ( - - )} - - {llmProviderDescriptor.llm_names.length > 0 ? ( - ({ - name: getDisplayNameForModel(name), - value: name, - }))} - includeDefault - maxHeight="max-h-56" - /> - ) : ( - - )} - - - - {llmProviderDescriptor.name != "azure" && ( - - )} - - {showAdvancedOptions && ( - <> - {llmProviderDescriptor.llm_names.length > 0 && ( -
    - ({ - value: name, - label: getDisplayNameForModel(name), - }))} - onChange={(selected) => - setFieldValue("display_model_names", selected) - } - /> -
    - )} - - {isPaidEnterpriseFeaturesEnabled && userGroups && ( - <> - - - {userGroups && userGroups.length > 0 && !values.is_public && ( -
    - - Select which User Groups should have access to this LLM - Provider. - -
    - {userGroups.map((userGroup) => { - const isSelected = values.groups.includes( - userGroup.id - ); - return ( - { - if (isSelected) { - setFieldValue( - "groups", - values.groups.filter( - (id) => id !== userGroup.id - ) - ); - } else { - setFieldValue("groups", [ - ...values.groups, - userGroup.id, - ]); - } - }} - > -
    - -
    {userGroup.name}
    -
    -
    - ); - })} -
    -
    - )} - - )} - - )} -
    - {/* NOTE: this is above the test button to make sure it's visible */} - {testError && {testError}} - -
    - - {existingLlmProvider && ( - - )} -
    -
    - - )} -
    - ); -} diff --git a/web/src/app/admin/configuration/llm/constants.ts b/web/src/app/admin/configuration/llm/constants.ts deleted file mode 100644 index a265f4a2b2d..00000000000 --- a/web/src/app/admin/configuration/llm/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const LLM_PROVIDERS_ADMIN_URL = "/api/admin/llm/provider"; - -export const EMBEDDING_PROVIDERS_ADMIN_URL = - "/api/admin/embedding/embedding-provider"; diff --git a/web/src/app/admin/configuration/llm/interfaces.ts b/web/src/app/admin/configuration/llm/interfaces.ts deleted file mode 100644 index 2d0d49196b4..00000000000 --- a/web/src/app/admin/configuration/llm/interfaces.ts +++ /dev/null @@ -1,55 +0,0 @@ -export interface CustomConfigKey { - name: string; - description: string | null; - is_required: boolean; - is_secret: boolean; -} - -export interface WellKnownLLMProviderDescriptor { - name: string; - display_name: string; - - api_key_required: boolean; - api_base_required: boolean; - api_version_required: boolean; - custom_config_keys: CustomConfigKey[] | null; - - llm_names: string[]; - default_model: string | null; - default_fast_model: string | null; - is_public: boolean; - groups: number[]; -} - -export interface LLMProvider { - name: string; - provider: string; - api_key: string | null; - api_base: string | null; - api_version: string | null; - custom_config: { [key: string]: string } | null; - default_model_name: string; - fast_default_model_name: string | null; - is_public: boolean; - groups: number[]; - display_model_names: string[] | null; -} - -export interface FullLLMProvider extends LLMProvider { - id: number; - is_default_provider: boolean | null; - model_names: string[]; - icon?: React.FC<{ size?: number; className?: string }>; -} - -export interface LLMProviderDescriptor { - name: string; - provider: string; - model_names: string[]; - default_model_name: string; - fast_default_model_name: string | null; - is_default_provider: boolean | null; - is_public: boolean; - groups: number[]; - display_model_names: string[] | null; -} diff --git a/web/src/app/admin/configuration/llm/page.tsx b/web/src/app/admin/configuration/llm/page.tsx deleted file mode 100644 index 9771a53c3af..00000000000 --- a/web/src/app/admin/configuration/llm/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { AdminPageTitle } from "@/components/admin/Title"; -import { FiCpu } from "react-icons/fi"; -import { LLMConfiguration } from "./LLMConfiguration"; -import { CpuIcon } from "@/components/icons/icons"; - -const Page = () => { - return ( -
    - } - /> - - -
    - ); -}; - -export default Page; diff --git a/web/src/app/admin/configuration/search/UpgradingPage.tsx b/web/src/app/admin/configuration/search/UpgradingPage.tsx deleted file mode 100644 index da379656336..00000000000 --- a/web/src/app/admin/configuration/search/UpgradingPage.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { ThreeDotsLoader } from "@/components/Loading"; -import { Modal } from "@/components/Modal"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { ConnectorIndexingStatus } from "@/lib/types"; -import { Button, Text, Title } from "@tremor/react"; -import { useState } from "react"; -import useSWR, { mutate } from "swr"; -import { ReindexingProgressTable } from "../../../../components/embedding/ReindexingProgressTable"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { - CloudEmbeddingModel, - HostedEmbeddingModel, -} from "../../../../components/embedding/interfaces"; -import { Connector } from "@/lib/connectors/connectors"; - -export default function UpgradingPage({ - futureEmbeddingModel, -}: { - futureEmbeddingModel: CloudEmbeddingModel | HostedEmbeddingModel; -}) { - const [isCancelling, setIsCancelling] = useState(false); - - const { data: connectors } = useSWR[]>( - "/api/manage/connector", - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - const { - data: ongoingReIndexingStatus, - isLoading: isLoadingOngoingReIndexingStatus, - } = useSWR[]>( - "/api/manage/admin/connector/indexing-status?secondary_index=true", - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - const onCancel = async () => { - const response = await fetch("/api/search-settings/cancel-new-embedding", { - method: "POST", - }); - if (response.ok) { - mutate("/api/search-settings/get-secondary-search-settings"); - } else { - alert( - `Failed to cancel embedding model update - ${await response.text()}` - ); - } - setIsCancelling(false); - }; - - return ( - <> - {isCancelling && ( - setIsCancelling(false)} - title="Cancel Embedding Model Switch" - > -
    -
    - Are you sure you want to cancel? -
    -
    - Cancelling will revert to the previous model and all progress will - be lost. -
    -
    - -
    -
    -
    - )} - - {futureEmbeddingModel && connectors && connectors.length > 0 && ( -
    - Current Upgrade Status -
    -
    - Currently in the process of switching to:{" "} - {futureEmbeddingModel.model_name} -
    - - - - - The table below shows the re-indexing progress of all existing - connectors. Once all connectors have been re-indexed successfully, - the new model will be used for all search queries. Until then, we - will use the old model so that no downtime is necessary during - this transition. - - - {isLoadingOngoingReIndexingStatus ? ( - - ) : ongoingReIndexingStatus ? ( - - ) : ( - - )} -
    -
    - )} - - ); -} diff --git a/web/src/app/admin/configuration/search/page.tsx b/web/src/app/admin/configuration/search/page.tsx deleted file mode 100644 index b2abebae725..00000000000 --- a/web/src/app/admin/configuration/search/page.tsx +++ /dev/null @@ -1,191 +0,0 @@ -"use client"; - -import { ThreeDotsLoader } from "@/components/Loading"; -import { AdminPageTitle } from "@/components/admin/Title"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { Button, Card, Text, Title } from "@tremor/react"; -import useSWR from "swr"; -import { ModelPreview } from "../../../../components/embedding/ModelSelector"; -import { - AVAILABLE_CLOUD_PROVIDERS, - HostedEmbeddingModel, - CloudEmbeddingModel, - AVAILABLE_MODELS, -} from "@/components/embedding/interfaces"; - -import { ErrorCallout } from "@/components/ErrorCallout"; - -export interface EmbeddingDetails { - api_key: string; - custom_config: any; - default_model_id?: number; - name: string; -} -import { EmbeddingIcon } from "@/components/icons/icons"; - -import Link from "next/link"; -import { SavedSearchSettings } from "../../embeddings/interfaces"; -import UpgradingPage from "./UpgradingPage"; -import { useContext } from "react"; -import { SettingsContext } from "@/components/settings/SettingsProvider"; - -function Main() { - const settings = useContext(SettingsContext); - const { - data: currentEmeddingModel, - isLoading: isLoadingCurrentModel, - error: currentEmeddingModelError, - } = useSWR( - "/api/search-settings/get-current-search-settings", - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - const { data: searchSettings, isLoading: isLoadingSearchSettings } = - useSWR( - "/api/search-settings/get-current-search-settings", - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - const { - data: futureEmbeddingModel, - isLoading: isLoadingFutureModel, - error: futureEmeddingModelError, - } = useSWR( - "/api/search-settings/get-secondary-search-settings", - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - if ( - isLoadingCurrentModel || - isLoadingFutureModel || - isLoadingSearchSettings - ) { - return ; - } - - if ( - currentEmeddingModelError || - !currentEmeddingModel || - futureEmeddingModelError - ) { - return ; - } - - const currentModelName = currentEmeddingModel?.model_name; - const AVAILABLE_CLOUD_PROVIDERS_FLATTENED = AVAILABLE_CLOUD_PROVIDERS.flatMap( - (provider) => - provider.embedding_models.map((model) => ({ - ...model, - provider_type: provider.provider_type, - model_name: model.model_name, // Ensure model_name is set for consistency - })) - ); - - const currentModel: CloudEmbeddingModel | HostedEmbeddingModel = - AVAILABLE_MODELS.find((model) => model.model_name === currentModelName) || - AVAILABLE_CLOUD_PROVIDERS_FLATTENED.find( - (model) => model.model_name === currentEmeddingModel.model_name - )!; - - return ( -
    - {!futureEmbeddingModel ? ( - <> - {settings?.settings.needs_reindexing && ( -

    - Your search settings are currently out of date! We recommend - updating your search settings and re-indexing. -

    - )} - Embedding Model - - {currentModel ? ( - - ) : ( - Choose your Embedding Model - )} - - Post-processing - - - {searchSettings && ( - <> -
    -
    -
    - Reranking Model - - {searchSettings.rerank_model_name || "Not set"} - -
    - -
    - Results to Rerank - - {searchSettings.num_rerank} - -
    - -
    - - Multilingual Expansion - - - {searchSettings.multilingual_expansion.length > 0 - ? searchSettings.multilingual_expansion.join(", ") - : "None"} - -
    - -
    - Multipass Indexing - - {searchSettings.multipass_indexing - ? "Enabled" - : "Disabled"} - -
    - -
    - - Disable Reranking for Streaming - - - {searchSettings.disable_rerank_for_streaming - ? "Yes" - : "No"} - -
    -
    -
    - - )} -
    - - - - - - ) : ( - - )} -
    - ); -} - -function Page() { - return ( -
    - } - /> -
    -
    - ); -} - -export default Page; diff --git a/web/src/app/admin/connector/[ccPairId]/ConfigDisplay.tsx b/web/src/app/admin/connector/[ccPairId]/ConfigDisplay.tsx deleted file mode 100644 index 7bd42947116..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/ConfigDisplay.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { getNameFromPath } from "@/lib/fileUtils"; -import { ValidSources } from "@/lib/types"; -import { List, ListItem, Card, Title } from "@tremor/react"; - -function convertObjectToString(obj: any): string | any { - // Check if obj is an object and not an array or null - if (typeof obj === "object" && obj !== null) { - if (!Array.isArray(obj)) { - return JSON.stringify(obj); - } else { - if (obj.length === 0) { - return null; - } - return obj.map((item) => convertObjectToString(item)).join(", "); - } - } - if (typeof obj === "boolean") { - return obj.toString(); - } - return obj; -} - -function buildConfigEntries( - obj: any, - sourceType: ValidSources -): { [key: string]: string } { - if (sourceType === "file") { - return obj.file_locations - ? { - file_names: obj.file_locations.map(getNameFromPath), - } - : {}; - } else if (sourceType === "google_sites") { - return { - base_url: obj.base_url, - }; - } - return obj; -} - -export function AdvancedConfigDisplay({ - pruneFreq, - refreshFreq, - indexingStart, -}: { - pruneFreq: number | null; - refreshFreq: number | null; - indexingStart: Date | null; -}) { - const formatRefreshFrequency = (seconds: number | null): string => { - if (seconds === null) return "-"; - const minutes = Math.round(seconds / 60); - return `${minutes} minute${minutes !== 1 ? "s" : ""}`; - }; - const formatPruneFrequency = (seconds: number | null): string => { - if (seconds === null) return "-"; - const days = Math.round(seconds / (60 * 60 * 24)); - return `${days} day${days !== 1 ? "s" : ""}`; - }; - - const formatDate = (date: Date | null): string => { - if (date === null) return "-"; - return date.toLocaleString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZoneName: "short", - }); - }; - - return ( - <> - Advanced Configuration - - - {pruneFreq && ( - - Pruning Frequency - {formatPruneFrequency(pruneFreq)} - - )} - {refreshFreq && ( - - Refresh Frequency - {formatRefreshFrequency(refreshFreq)} - - )} - {indexingStart && ( - - Indexing Start - {formatDate(indexingStart)} - - )} - - - - ); -} - -export function ConfigDisplay({ - connectorSpecificConfig, - sourceType, -}: { - connectorSpecificConfig: any; - sourceType: ValidSources; -}) { - const configEntries = Object.entries( - buildConfigEntries(connectorSpecificConfig, sourceType) - ); - if (!configEntries.length) { - return null; - } - - return ( - <> - Configuration - - - {configEntries.map(([key, value]) => ( - - {key} - {convertObjectToString(value) || "-"} - - ))} - - - - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/DeletionButton.tsx b/web/src/app/admin/connector/[ccPairId]/DeletionButton.tsx deleted file mode 100644 index 7ea03747bd3..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/DeletionButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { Button } from "@tremor/react"; -import { CCPairFullInfo, ConnectorCredentialPairStatus } from "./types"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { FiTrash } from "react-icons/fi"; -import { deleteCCPair } from "@/lib/documentDeletion"; -import { mutate } from "swr"; -import { buildCCPairInfoUrl } from "./lib"; - -export function DeletionButton({ ccPair }: { ccPair: CCPairFullInfo }) { - const { popup, setPopup } = usePopup(); - - const isDeleting = - ccPair?.latest_deletion_attempt?.status === "PENDING" || - ccPair?.latest_deletion_attempt?.status === "STARTED"; - - let tooltip: string; - if (ccPair.status !== ConnectorCredentialPairStatus.ACTIVE) { - if (isDeleting) { - tooltip = "This connector is currently being deleted"; - } else { - tooltip = "Click to delete"; - } - } else { - tooltip = "You must pause the connector before deleting it"; - } - - return ( -
    - {popup} - -
    - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/IndexingAttemptsTable.tsx b/web/src/app/admin/connector/[ccPairId]/IndexingAttemptsTable.tsx deleted file mode 100644 index b9861a29759..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/IndexingAttemptsTable.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { - Table, - TableHead, - TableRow, - TableHeaderCell, - TableBody, - TableCell, - Text, - Button, - Divider, -} from "@tremor/react"; -import { IndexAttemptStatus } from "@/components/Status"; -import { CCPairFullInfo } from "./types"; -import { useState } from "react"; -import { PageSelector } from "@/components/PageSelector"; -import { localizeAndPrettify } from "@/lib/time"; -import { getDocsProcessedPerMinute } from "@/lib/indexAttempt"; -import { Modal } from "@/components/Modal"; -import { CheckmarkIcon, CopyIcon, SearchIcon } from "@/components/icons/icons"; -import Link from "next/link"; -import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal"; - -const NUM_IN_PAGE = 8; - -export function IndexingAttemptsTable({ ccPair }: { ccPair: CCPairFullInfo }) { - const [page, setPage] = useState(1); - const [indexAttemptTracePopupId, setIndexAttemptTracePopupId] = useState< - number | null - >(null); - const indexAttemptToDisplayTraceFor = ccPair.index_attempts.find( - (indexAttempt) => indexAttempt.id === indexAttemptTracePopupId - ); - const [copyClicked, setCopyClicked] = useState(false); - - return ( - <> - {indexAttemptToDisplayTraceFor && - indexAttemptToDisplayTraceFor.full_exception_trace && ( - setIndexAttemptTracePopupId(null)} - exceptionTrace={indexAttemptToDisplayTraceFor.full_exception_trace!} - /> - )} - - - - - Time Started - Status - New Doc Cnt - Total Doc Cnt - Error Message - - - - {ccPair.index_attempts - .slice(NUM_IN_PAGE * (page - 1), NUM_IN_PAGE * page) - .map((indexAttempt) => { - const docsPerMinute = - getDocsProcessedPerMinute(indexAttempt)?.toFixed(2); - return ( - - - {indexAttempt.time_started - ? localizeAndPrettify(indexAttempt.time_started) - : "-"} - - - - {docsPerMinute && ( -
    - {docsPerMinute} docs / min -
    - )} -
    - -
    -
    -
    {indexAttempt.new_docs_indexed}
    - {indexAttempt.docs_removed_from_index > 0 && ( -
    - (also removed {indexAttempt.docs_removed_from_index}{" "} - docs that were detected as deleted in the source) -
    - )} -
    -
    -
    - {indexAttempt.total_docs_indexed} - -
    - {indexAttempt.error_count > 0 && ( - - - -  View Errors - - - )} - - {indexAttempt.status === "success" && ( - - {"-"} - - )} - - {indexAttempt.status === "failed" && - indexAttempt.error_msg && ( - - {indexAttempt.error_msg} - - )} - - {indexAttempt.full_exception_trace && ( -
    { - setIndexAttemptTracePopupId(indexAttempt.id); - }} - className="mt-2 text-link cursor-pointer select-none" - > - View Full Trace -
    - )} -
    -
    -
    - ); - })} -
    -
    - {ccPair.index_attempts.length > NUM_IN_PAGE && ( -
    -
    - { - setPage(newPage); - window.scrollTo({ - top: 0, - left: 0, - behavior: "smooth", - }); - }} - /> -
    -
    - )} - - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/ModifyStatusButtonCluster.tsx b/web/src/app/admin/connector/[ccPairId]/ModifyStatusButtonCluster.tsx deleted file mode 100644 index 10460459e32..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/ModifyStatusButtonCluster.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { Button } from "@tremor/react"; -import { CCPairFullInfo, ConnectorCredentialPairStatus } from "./types"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { mutate } from "swr"; -import { buildCCPairInfoUrl } from "./lib"; -import { setCCPairStatus } from "@/lib/ccPair"; - -export function ModifyStatusButtonCluster({ - ccPair, -}: { - ccPair: CCPairFullInfo; -}) { - const { popup, setPopup } = usePopup(); - - return ( - <> - {popup} - {ccPair.status === ConnectorCredentialPairStatus.PAUSED ? ( - - ) : ( - - )} - - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/ReIndexButton.tsx b/web/src/app/admin/connector/[ccPairId]/ReIndexButton.tsx deleted file mode 100644 index dced8811a3f..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/ReIndexButton.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup"; -import { runConnector } from "@/lib/connector"; -import { Button, Divider, Text } from "@tremor/react"; -import { mutate } from "swr"; -import { buildCCPairInfoUrl } from "./lib"; -import { useState } from "react"; -import { Modal } from "@/components/Modal"; - -function ReIndexPopup({ - connectorId, - credentialId, - ccPairId, - setPopup, - hide, -}: { - connectorId: number; - credentialId: number; - ccPairId: number; - setPopup: (popupSpec: PopupSpec | null) => void; - hide: () => void; -}) { - async function triggerIndexing(fromBeginning: boolean) { - const errorMsg = await runConnector( - connectorId, - [credentialId], - fromBeginning - ); - if (errorMsg) { - setPopup({ - message: errorMsg, - type: "error", - }); - } else { - setPopup({ - message: "Triggered connector run", - type: "success", - }); - } - mutate(buildCCPairInfoUrl(ccPairId)); - } - - return ( - -
    - - - - This will pull in and index all documents that have changed and/or - have been added since the last successful indexing run. - - - - - - - - This will cause a complete re-indexing of all documents from the - source. - - - - NOTE: depending on the number of documents stored in the - source, this may take a long time. - -
    -
    - ); -} - -export function ReIndexButton({ - ccPairId, - connectorId, - credentialId, - isDisabled, - isDeleting, -}: { - ccPairId: number; - connectorId: number; - credentialId: number; - isDisabled: boolean; - isDeleting: boolean; -}) { - const { popup, setPopup } = usePopup(); - const [reIndexPopupVisible, setReIndexPopupVisible] = useState(false); - - return ( - <> - {reIndexPopupVisible && ( - setReIndexPopupVisible(false)} - /> - )} - {popup} - - - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/lib.ts b/web/src/app/admin/connector/[ccPairId]/lib.ts deleted file mode 100644 index c2d02b23d75..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/lib.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ValidSources } from "@/lib/types"; - -export function buildCCPairInfoUrl(ccPairId: string | number) { - return `/api/manage/admin/cc-pair/${ccPairId}`; -} - -export function buildSimilarCredentialInfoURL( - source_type: ValidSources, - get_editable: boolean = false -) { - const base = `/api/manage/admin/similar-credentials/${source_type}`; - return get_editable ? `${base}?get_editable=True` : base; -} diff --git a/web/src/app/admin/connector/[ccPairId]/page.tsx b/web/src/app/admin/connector/[ccPairId]/page.tsx deleted file mode 100644 index f5da225a867..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/page.tsx +++ /dev/null @@ -1,256 +0,0 @@ -"use client"; - -import { CCPairFullInfo, ConnectorCredentialPairStatus } from "./types"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; -import { CCPairStatus } from "@/components/Status"; -import { BackButton } from "@/components/BackButton"; -import { Button, Divider, Title } from "@tremor/react"; -import { IndexingAttemptsTable } from "./IndexingAttemptsTable"; -import { AdvancedConfigDisplay, ConfigDisplay } from "./ConfigDisplay"; -import { ModifyStatusButtonCluster } from "./ModifyStatusButtonCluster"; -import { DeletionButton } from "./DeletionButton"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { ReIndexButton } from "./ReIndexButton"; -import { isCurrentlyDeleting } from "@/lib/documentDeletion"; -import { ValidSources } from "@/lib/types"; -import useSWR, { mutate } from "swr"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { ThreeDotsLoader } from "@/components/Loading"; -import CredentialSection from "@/components/credentials/CredentialSection"; -import { buildCCPairInfoUrl } from "./lib"; -import { SourceIcon } from "@/components/SourceIcon"; -import { credentialTemplates } from "@/lib/connectors/credentials"; -import { useEffect, useRef, useState } from "react"; -import { CheckmarkIcon, EditIcon, XIcon } from "@/components/icons/icons"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { updateConnectorCredentialPairName } from "@/lib/connector"; - -// since the uploaded files are cleaned up after some period of time -// re-indexing will not work for the file connector. Also, it would not -// make sense to re-index, since the files will not have changed. -const CONNECTOR_TYPES_THAT_CANT_REINDEX: ValidSources[] = ["file"]; - -function Main({ ccPairId }: { ccPairId: number }) { - const { - data: ccPair, - isLoading, - error, - } = useSWR( - buildCCPairInfoUrl(ccPairId), - errorHandlingFetcher, - { refreshInterval: 5000 } // 5 seconds - ); - - const [editableName, setEditableName] = useState(ccPair?.name || ""); - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef(null); - - const { popup, setPopup } = usePopup(); - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - } - }, [isEditing]); - const handleNameChange = (e: React.ChangeEvent) => { - setEditableName(e.target.value); - }; - - const handleUpdateName = async () => { - try { - const response = await updateConnectorCredentialPairName( - ccPair?.id!, - editableName - ); - if (!response.ok) { - throw new Error(await response.text()); - } - mutate(buildCCPairInfoUrl(ccPairId)); - setIsEditing(false); - setPopup({ - message: "Connector name updated successfully", - type: "success", - }); - } catch (error) { - setPopup({ - message: `Failed to update connector name`, - type: "error", - }); - } - }; - - if (isLoading) { - return ; - } - - if (error || !ccPair) { - return ( - - ); - } - - const lastIndexAttempt = ccPair.index_attempts[0]; - const isDeleting = ccPair.status === ConnectorCredentialPairStatus.DELETING; - - // figure out if we need to artificially deflate the number of docs indexed. - // This is required since the total number of docs indexed by a CC Pair is - // updated before the new docs for an indexing attempt. If we don't do this, - // there is a mismatch between these two numbers which may confuse users. - const totalDocsIndexed = - lastIndexAttempt?.status === "in_progress" && - ccPair.index_attempts.length === 1 - ? lastIndexAttempt.total_docs_indexed - : ccPair.num_docs_indexed; - - const refresh = () => { - mutate(buildCCPairInfoUrl(ccPairId)); - }; - - const startEditing = () => { - setEditableName(ccPair.name); - setIsEditing(true); - }; - - const resetEditing = () => { - setIsEditing(false); - setEditableName(ccPair.name); - }; - - const { - prune_freq: pruneFreq, - refresh_freq: refreshFreq, - indexing_start: indexingStart, - } = ccPair.connector; - return ( - <> - {popup} - -
    -
    - -
    - - {ccPair.is_editable_for_current_user && isEditing ? ( -
    - - - -
    - ) : ( -

    - ccPair.is_editable_for_current_user && startEditing() - } - className={`group flex ${ccPair.is_editable_for_current_user ? "cursor-pointer" : ""} text-3xl text-emphasis gap-x-2 items-center font-bold`} - > - {ccPair.name} - {ccPair.is_editable_for_current_user && ( - - )} -

    - )} - - {ccPair.is_editable_for_current_user && ( -
    - {!CONNECTOR_TYPES_THAT_CANT_REINDEX.includes( - ccPair.connector.source - ) && ( - - )} - {!isDeleting && } -
    - )} -
    - -
    - Total Documents Indexed:{" "} - {totalDocsIndexed} -
    - {!ccPair.is_editable_for_current_user && ( -
    - {ccPair.is_public - ? "Public connectors are not editable by curators." - : "This connector belongs to groups where you don't have curator permissions, so it's not editable."} -
    - )} - {credentialTemplates[ccPair.connector.source] && - ccPair.is_editable_for_current_user && ( - <> - - - Credentials - - refresh()} - /> - - )} - - - - {(pruneFreq || indexingStart || refreshFreq) && ( - - )} - - {/* NOTE: no divider / title here for `ConfigDisplay` since it is optional and we need - to render these conditionally.*/} -
    -
    - Indexing Attempts -
    - -
    - -
    -
    - {ccPair.is_editable_for_current_user && ( - - )} -
    -
    - - ); -} - -export default function Page({ params }: { params: { ccPairId: string } }) { - const ccPairId = parseInt(params.ccPairId); - - return ( -
    -
    -
    - ); -} diff --git a/web/src/app/admin/connector/[ccPairId]/types.ts b/web/src/app/admin/connector/[ccPairId]/types.ts deleted file mode 100644 index 1cc43311e21..00000000000 --- a/web/src/app/admin/connector/[ccPairId]/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Connector } from "@/lib/connectors/connectors"; -import { Credential } from "@/lib/connectors/credentials"; -import { DeletionAttemptSnapshot, IndexAttemptSnapshot } from "@/lib/types"; - -export enum ConnectorCredentialPairStatus { - ACTIVE = "ACTIVE", - PAUSED = "PAUSED", - DELETING = "DELETING", -} - -export interface CCPairFullInfo { - id: number; - name: string; - status: ConnectorCredentialPairStatus; - num_docs_indexed: number; - connector: Connector; - credential: Credential; - index_attempts: IndexAttemptSnapshot[]; - latest_deletion_attempt: DeletionAttemptSnapshot | null; - is_public: boolean; - is_editable_for_current_user: boolean; -} diff --git a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx deleted file mode 100644 index dd8d19ca720..00000000000 --- a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx +++ /dev/null @@ -1,617 +0,0 @@ -"use client"; - -import * as Yup from "yup"; -import { TrashIcon } from "@/components/icons/icons"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import useSWR, { mutate } from "swr"; -import { HealthCheckBanner } from "@/components/health/healthcheck"; - -import { Card, Divider, Title } from "@tremor/react"; -import { AdminPageTitle } from "@/components/admin/Title"; -import { buildSimilarCredentialInfoURL } from "@/app/admin/connector/[ccPairId]/lib"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { useFormContext } from "@/components/context/FormContext"; -import { getSourceDisplayName } from "@/lib/sources"; -import { SourceIcon } from "@/components/SourceIcon"; -import { useRef, useState, useEffect } from "react"; -import { submitConnector } from "@/components/admin/connectors/ConnectorForm"; -import { deleteCredential, linkCredential } from "@/lib/credential"; -import { submitFiles } from "./pages/utils/files"; -import { submitGoogleSite } from "./pages/utils/google_site"; -import AdvancedFormPage from "./pages/Advanced"; -import DynamicConnectionForm from "./pages/DynamicConnectorCreationForm"; -import CreateCredential from "@/components/credentials/actions/CreateCredential"; -import ModifyCredential from "@/components/credentials/actions/ModifyCredential"; -import { ValidSources } from "@/lib/types"; -import { Credential, credentialTemplates } from "@/lib/connectors/credentials"; -import { - ConnectionConfiguration, - connectorConfigs, -} from "@/lib/connectors/connectors"; -import { Modal } from "@/components/Modal"; -import { ArrowRight } from "@phosphor-icons/react"; -import { ArrowLeft } from "@phosphor-icons/react/dist/ssr"; -import { FiPlus } from "react-icons/fi"; -import GDriveMain from "./pages/gdrive/GoogleDrivePage"; -import { GmailMain } from "./pages/gmail/GmailPage"; -import { - useGmailCredentials, - useGoogleDriveCredentials, -} from "./pages/utils/hooks"; -import { Formik, FormikProps } from "formik"; -import { - IsPublicGroupSelector, - IsPublicGroupSelectorFormType, -} from "@/components/IsPublicGroupSelector"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import { AdminBooleanFormField } from "@/components/credentials/CredentialFields"; - -export type AdvancedConfigFinal = { - pruneFreq: number | null; - refreshFreq: number | null; - indexingStart: Date | null; -}; - -export default function AddConnector({ - connector, -}: { - connector: ValidSources; -}) { - const [currentCredential, setCurrentCredential] = - useState | null>(null); - - const { data: credentials } = useSWR[]>( - buildSimilarCredentialInfoURL(connector), - errorHandlingFetcher, - { refreshInterval: 5000 } - ); - - const { data: editableCredentials } = useSWR[]>( - buildSimilarCredentialInfoURL(connector, true), - errorHandlingFetcher, - { refreshInterval: 5000 } - ); - const [selectedFiles, setSelectedFiles] = useState([]); - - const credentialTemplate = credentialTemplates[connector]; - - const { - setFormStep, - setAllowAdvanced, - setAlowCreate, - formStep, - nextFormStep, - prevFormStep, - } = useFormContext(); - - const { popup, setPopup } = usePopup(); - - const configuration: ConnectionConfiguration = connectorConfigs[connector]; - const [formValues, setFormValues] = useState< - Record & IsPublicGroupSelectorFormType - >({ - name: "", - groups: [], - is_public: false, - ...configuration.values.reduce( - (acc, field) => { - if (field.type === "list") { - acc[field.name] = field.default || []; - } else if (field.type === "checkbox") { - acc[field.name] = field.default || false; - } else if (field.default !== undefined) { - acc[field.name] = field.default; - } - return acc; - }, - {} as { [record: string]: any } - ), - }); - - const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled(); - - // Default to 10 minutes unless otherwise specified - const defaultAdvancedSettings = { - refreshFreq: formValues.overrideDefaultFreq || 10, - pruneFreq: 30, - indexingStart: null as string | null, - }; - - const [advancedSettings, setAdvancedSettings] = useState( - defaultAdvancedSettings - ); - - const [createConnectorToggle, setCreateConnectorToggle] = useState(false); - const formRef = useRef>(null); - - const [isFormValid, setIsFormValid] = useState(false); - - const handleFormStatusChange = (isValid: boolean) => { - setIsFormValid(isValid || connector == "file"); - }; - - const { liveGDriveCredential } = useGoogleDriveCredentials(); - - const { liveGmailCredential } = useGmailCredentials(); - - const credentialActivated = - (connector === "google_drive" && liveGDriveCredential) || - (connector === "gmail" && liveGmailCredential) || - currentCredential; - - const noCredentials = credentialTemplate == null; - - if (noCredentials && 1 != formStep) { - setFormStep(Math.max(1, formStep)); - } - - if (!noCredentials && !credentialActivated && formStep != 0) { - setFormStep(Math.min(formStep, 0)); - } - - const resetAdvancedConfigs = (formikProps: FormikProps) => { - formikProps.resetForm({ values: defaultAdvancedSettings }); - setAdvancedSettings(defaultAdvancedSettings); - }; - - const convertStringToDateTime = (indexingStart: string | null) => { - return indexingStart ? new Date(indexingStart) : null; - }; - - const createConnector = async () => { - const { - name, - groups, - is_public: isPublic, - ...connector_specific_config - } = formValues; - const { pruneFreq, indexingStart, refreshFreq } = advancedSettings; - - // Apply transforms from connectors.ts configuration - const transformedConnectorSpecificConfig = Object.entries( - connector_specific_config - ).reduce( - (acc, [key, value]) => { - const matchingConfigValue = configuration.values.find( - (configValue) => configValue.name === key - ); - if ( - matchingConfigValue && - "transform" in matchingConfigValue && - matchingConfigValue.transform - ) { - acc[key] = matchingConfigValue.transform(value as string[]); - } else { - acc[key] = value; - } - return acc; - }, - {} as Record - ); - - const AdvancedConfig: AdvancedConfigFinal = { - pruneFreq: advancedSettings.pruneFreq * 60 * 60 * 24, - indexingStart: convertStringToDateTime(indexingStart), - refreshFreq: advancedSettings.refreshFreq * 60, - }; - - // google sites-specific handling - if (connector == "google_site") { - const response = await submitGoogleSite( - selectedFiles, - formValues?.base_url, - setPopup, - AdvancedConfig, - name - ); - if (response) { - setTimeout(() => { - window.open("/admin/indexing/status", "_self"); - }, 1000); - } - return; - } - - // file-specific handling - if (connector == "file" && selectedFiles.length > 0) { - const response = await submitFiles( - selectedFiles, - setPopup, - setSelectedFiles, - name, - AdvancedConfig, - isPublic, - groups - ); - if (response) { - setTimeout(() => { - window.open("/admin/indexing/status", "_self"); - }, 1000); - } - return; - } - - const { message, isSuccess, response } = await submitConnector( - { - connector_specific_config: transformedConnectorSpecificConfig, - input_type: connector == "web" ? "load_state" : "poll", // single case - name: name, - source: connector, - refresh_freq: refreshFreq * 60 || null, - prune_freq: pruneFreq * 60 * 60 * 24 || null, - indexing_start: convertStringToDateTime(indexingStart), - is_public: isPublic, - groups: groups, - }, - undefined, - credentialActivated ? false : true, - isPublic - ); - // If no credential - if (!credentialActivated) { - if (isSuccess) { - setPopup({ - message: "Connector created! Redirecting to connector home page", - type: "success", - }); - setTimeout(() => { - window.open("/admin/indexing/status", "_self"); - }, 1000); - } else { - setPopup({ message: message, type: "error" }); - } - } - - // Without credential - if (credentialActivated && isSuccess && response) { - const credential = - currentCredential || liveGDriveCredential || liveGmailCredential; - const linkCredentialResponse = await linkCredential( - response.id, - credential?.id!, - name, - isPublic, - groups - ); - if (linkCredentialResponse.ok) { - setPopup({ - message: "Connector created! Redirecting to connector home page", - type: "success", - }); - setTimeout(() => { - window.open("/admin/indexing/status", "_self"); - }, 1000); - } else { - const errorData = await linkCredentialResponse.json(); - setPopup({ - message: errorData.message, - type: "error", - }); - } - } else if (isSuccess) { - setPopup({ - message: - "Credential created succsfully! Redirecting to connector home page", - type: "success", - }); - } else { - setPopup({ message: message, type: "error" }); - } - }; - - const displayName = getSourceDisplayName(connector) || connector; - if (!credentials || !editableCredentials) { - return <>; - } - - const refresh = () => { - mutate(buildSimilarCredentialInfoURL(connector)); - }; - const onDeleteCredential = async (credential: Credential) => { - const response = await deleteCredential(credential.id, true); - if (response.ok) { - setPopup({ - message: "Credential deleted successfully!", - type: "success", - }); - } else { - const errorData = await response.json(); - setPopup({ - message: errorData.message, - type: "error", - }); - } - }; - - const onSwap = async (selectedCredential: Credential) => { - setCurrentCredential(selectedCredential); - setAlowCreate(true); - setPopup({ - message: "Swapped credential successfully!", - type: "success", - }); - refresh(); - }; - - const validationSchema = Yup.object().shape({ - name: Yup.string().required("Connector Name is required"), - ...configuration.values.reduce( - (acc, field) => { - let schema: any = - field.type === "list" - ? Yup.array().of(Yup.string()) - : field.type === "checkbox" - ? Yup.boolean() - : Yup.string(); - - if (!field.optional) { - schema = schema.required(`${field.label} is required`); - } - acc[field.name] = schema; - return acc; - }, - {} as Record - ), - }); - - const advancedValidationSchema = Yup.object().shape({ - indexingStart: Yup.string().nullable(), - pruneFreq: Yup.number().min(0, "Prune frequency must be non-negative"), - refreshFreq: Yup.number().min(0, "Refresh frequency must be non-negative"), - }); - - const isFormSubmittable = (values: any) => { - return ( - values.name.trim() !== "" && - Object.keys(values).every((key) => { - const field = configuration.values.find((f) => f.name === key); - return field?.optional || values[key] !== ""; - }) - ); - }; - - return ( -
    - {popup} -
    - -
    - - } - title={displayName} - /> - - {formStep == 0 && - (connector == "google_drive" ? ( - <> - - Select a credential - - -
    - -
    - - ) : connector == "gmail" ? ( - <> - - Select a credential - - -
    - -
    - - ) : ( - <> - - Select a credential - - {!createConnectorToggle && ( - - )} - - {!(connector == "google_drive") && createConnectorToggle && ( - setCreateConnectorToggle(false)} - > - <> - - Create a {getSourceDisplayName(connector)} credential - - setCreateConnectorToggle(false)} - /> - - - )} - -
    - -
    - - ))} - - {formStep == 1 && ( - <> - - { - // Can be utilized for logging purposes - }} - > - {(formikProps) => { - setFormValues(formikProps.values); - handleFormStatusChange( - formikProps.isValid && isFormSubmittable(formikProps.values) - ); - setAllowAdvanced( - formikProps.isValid && isFormSubmittable(formikProps.values) - ); - - return ( -
    - - {isPaidEnterpriseFeaturesEnabled && ( - <> - - - )} -
    - ); - }} -
    -
    -
    - {!noCredentials ? ( - - ) : ( -
    - )} - - - {!(connector == "file") && ( -
    - -
    - )} -
    - - )} - - {formStep === 2 && ( - <> - - {}} - > - {(formikProps) => { - setAdvancedSettings(formikProps.values); - - return ( - <> - -
    - -
    - - ); - }} -
    -
    -
    - - -
    - - )} -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/ConnectorWrapper.tsx b/web/src/app/admin/connectors/[connector]/ConnectorWrapper.tsx deleted file mode 100644 index 345ace085bc..00000000000 --- a/web/src/app/admin/connectors/[connector]/ConnectorWrapper.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { ValidSources } from "@/lib/types"; -import AddConnector from "./AddConnectorPage"; -import { FormProvider } from "@/components/context/FormContext"; -import Sidebar from "./Sidebar"; -import { HeaderTitle } from "@/components/header/HeaderTitle"; -import { Button } from "@tremor/react"; -import { isValidSource } from "@/lib/sources"; - -export default function ConnectorWrapper({ connector }: { connector: string }) { - return ( - -
    - -
    - {!isValidSource(connector) ? ( -
    - -

    ‘{connector}‘ is not a valid Connector Type!

    -
    - -
    - ) : ( - - )} -
    -
    -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/Sidebar.tsx b/web/src/app/admin/connectors/[connector]/Sidebar.tsx deleted file mode 100644 index 5d678938dc7..00000000000 --- a/web/src/app/admin/connectors/[connector]/Sidebar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useFormContext } from "@/components/context/FormContext"; -import { HeaderTitle } from "@/components/header/HeaderTitle"; - -import { BackIcon, SettingsIcon } from "@/components/icons/icons"; -import { Logo } from "@/components/Logo"; -import { SettingsContext } from "@/components/settings/SettingsProvider"; -import { credentialTemplates } from "@/lib/connectors/credentials"; -import Link from "next/link"; -import { useContext } from "react"; - -export default function Sidebar() { - const { formStep, setFormStep, connector, allowAdvanced, allowCreate } = - useFormContext(); - const combinedSettings = useContext(SettingsContext); - if (!combinedSettings) { - return null; - } - const enterpriseSettings = combinedSettings.enterpriseSettings; - const noCredential = credentialTemplates[connector] == null; - - const settingSteps = [ - ...(!noCredential ? ["Credential"] : []), - "Connector", - ...(connector == "file" ? [] : ["Advanced (optional)"]), - ]; - - return ( -
    -
    -
    -
    -
    - -
    - -
    - {enterpriseSettings && enterpriseSettings.application_name ? ( - {enterpriseSettings.application_name} - ) : ( - Danswer - )} -
    -
    - -
    - - -

    Admin Page

    - -
    - -
    -
    -
    - {connector != "file" && ( -
    - )} - {settingSteps.map((step, index) => { - const allowed = - (step == "Connector" && allowCreate) || - (step == "Advanced (optional)" && allowAdvanced) || - index <= formStep; - - return ( -
    { - if (allowed) { - setFormStep(index - (noCredential ? 1 : 0)); - } - }} - > -
    -
    - {formStep === index && ( -
    - )} -
    -
    -
    - {step} -
    -
    - ); - })} -
    -
    -
    -
    -
    -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/auth/callback/route.ts b/web/src/app/admin/connectors/[connector]/auth/callback/route.ts deleted file mode 100644 index 9d80e1b2fd2..00000000000 --- a/web/src/app/admin/connectors/[connector]/auth/callback/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { getDomain } from "@/lib/redirectSS"; -import { buildUrl } from "@/lib/utilsSS"; -import { NextRequest, NextResponse } from "next/server"; -import { cookies } from "next/headers"; -import { - GMAIL_AUTH_IS_ADMIN_COOKIE_NAME, - GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME, -} from "@/lib/constants"; -import { processCookies } from "@/lib/userSS"; - -export const GET = async (request: NextRequest) => { - const connector = request.url.includes("gmail") ? "gmail" : "google-drive"; - const callbackEndpoint = `/manage/connector/${connector}/callback`; - const url = new URL(buildUrl(callbackEndpoint)); - url.search = request.nextUrl.search; - - const response = await fetch(url.toString(), { - headers: { - cookie: processCookies(cookies()), - }, - }); - - if (!response.ok) { - console.log( - `Error in ${connector} callback:`, - (await response.json()).detail - ); - return NextResponse.redirect(new URL("/auth/error", getDomain(request))); - } - - const authCookieName = - connector === "gmail" - ? GMAIL_AUTH_IS_ADMIN_COOKIE_NAME - : GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME; - - if (cookies().get(authCookieName)?.value?.toLowerCase() === "true") { - return NextResponse.redirect( - new URL(`/admin/connectors/${connector}`, getDomain(request)) - ); - } - - return NextResponse.redirect(new URL("/user/connectors", getDomain(request))); -}; diff --git a/web/src/app/admin/connectors/[connector]/page.tsx b/web/src/app/admin/connectors/[connector]/page.tsx deleted file mode 100644 index 265d6922ebc..00000000000 --- a/web/src/app/admin/connectors/[connector]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import ConnectorWrapper from "./ConnectorWrapper"; - -export default async function Page({ - params, -}: { - params: { connector: string }; -}) { - return ; -} diff --git a/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx b/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx deleted file mode 100644 index 470ab8d2a77..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { Dispatch, forwardRef, SetStateAction } from "react"; -import { Formik, Form, FormikProps } from "formik"; -import * as Yup from "yup"; -import NumberInput from "./ConnectorInput/NumberInput"; -import { TextFormField } from "@/components/admin/connectors/Field"; - -interface AdvancedFormPageProps { - formikProps: FormikProps<{ - indexingStart: string | null; - pruneFreq: number; - refreshFreq: number; - }>; -} - -const AdvancedFormPage = forwardRef, AdvancedFormPageProps>( - ({ formikProps }, ref) => { - const { indexingStart, refreshFreq, pruneFreq } = formikProps.values; - - return ( -
    -

    - Advanced Configuration -

    - -
    -
    - -
    - -
    - -
    - -
    - -
    -
    -
    - ); - } -); - -AdvancedFormPage.displayName = "AdvancedFormPage"; -export default AdvancedFormPage; diff --git a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/FileInput.tsx b/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/FileInput.tsx deleted file mode 100644 index 50af2dfff70..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/FileInput.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FileUpload } from "@/components/admin/connectors/FileUpload"; -import CredentialSubText from "@/components/credentials/CredentialFields"; - -interface FileInputProps { - name: string; - label: string; - optional?: boolean; - description?: string; - selectedFiles: File[]; - setSelectedFiles: (files: File[]) => void; -} - -export default function FileInput({ - name, - label, - optional = false, - description, - selectedFiles, - setSelectedFiles, -}: FileInputProps) { - return ( - <> - - {description && {description}} - - - ); -} diff --git a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/ListInput.tsx b/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/ListInput.tsx deleted file mode 100644 index 059d08d539d..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/ListInput.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import CredentialSubText from "@/components/credentials/CredentialFields"; -import { TrashIcon } from "@/components/icons/icons"; -import { ListOption } from "@/lib/connectors/connectors"; -import { Field, FieldArray, useField } from "formik"; -import { FaPlus } from "react-icons/fa"; - -export default function ListInput({ - field, - onUpdate, -}: { - field: ListOption; - onUpdate?: (values: string[]) => void; -}) { - const [fieldProps, , helpers] = useField(field.name); - - return ( - - {({ push, remove }) => ( -
    - - {field.description && ( - {field.description} - )} - - {fieldProps.value.map((value: string, index: number) => ( -
    - - -
    - ))} - - -
    - )} -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/NumberInput.tsx b/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/NumberInput.tsx deleted file mode 100644 index 5a9f5041b5d..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/NumberInput.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { SubLabel } from "@/components/admin/connectors/Field"; -import { Field } from "formik"; - -export default function NumberInput({ - label, - value, - optional, - description, - name, - showNeverIfZero, -}: { - value?: number; - label: string; - name: string; - optional?: boolean; - description?: string; - showNeverIfZero?: boolean; -}) { - return ( -
    - - {description && {description}} - - -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/SelectInput.tsx b/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/SelectInput.tsx deleted file mode 100644 index e01a02dc323..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/ConnectorInput/SelectInput.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import CredentialSubText from "@/components/credentials/CredentialFields"; -import { ListOption, SelectOption } from "@/lib/connectors/connectors"; -import { Field } from "formik"; - -export default function SelectInput({ - field, - value, - onChange, -}: { - field: SelectOption; - value: any; - onChange?: (e: Event) => void; -}) { - return ( - <> - - {field.description && ( - {field.description} - )} - - - - {field.options?.map((option: any) => ( - - ))} - - - ); -} diff --git a/web/src/app/admin/connectors/[connector]/pages/DynamicConnectorCreationForm.tsx b/web/src/app/admin/connectors/[connector]/pages/DynamicConnectorCreationForm.tsx deleted file mode 100644 index 507b976f9a8..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/DynamicConnectorCreationForm.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { - ChangeEvent, - Dispatch, - FC, - SetStateAction, - useEffect, - useState, -} from "react"; -import { Formik, Form, Field, FieldArray, FormikProps } from "formik"; -import * as Yup from "yup"; -import { FaPlus } from "react-icons/fa"; -import { useUserGroups } from "@/lib/hooks"; -import { UserGroup, User, UserRole } from "@/lib/types"; -import { Divider } from "@tremor/react"; -import CredentialSubText, { - AdminBooleanFormField, -} from "@/components/credentials/CredentialFields"; -import { TrashIcon } from "@/components/icons/icons"; -import { FileUpload } from "@/components/admin/connectors/FileUpload"; -import { ConnectionConfiguration } from "@/lib/connectors/connectors"; -import { useFormContext } from "@/components/context/FormContext"; -import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled"; -import { Text } from "@tremor/react"; -import { getCurrentUser } from "@/lib/user"; -import { FiUsers } from "react-icons/fi"; -import SelectInput from "./ConnectorInput/SelectInput"; -import NumberInput from "./ConnectorInput/NumberInput"; -import { TextFormField } from "@/components/admin/connectors/Field"; -import ListInput from "./ConnectorInput/ListInput"; -import FileInput from "./ConnectorInput/FileInput"; - -export interface DynamicConnectionFormProps { - config: ConnectionConfiguration; - selectedFiles: File[]; - setSelectedFiles: Dispatch>; - values: any; -} - -const DynamicConnectionForm: FC = ({ - config, - selectedFiles, - setSelectedFiles, - values, -}) => { - return ( - <> -

    {config.description}

    - - {config.subtext && ( - {config.subtext} - )} - - - - {config.values.map((field) => { - if (!field.hidden) { - return ( -
    - {field.type == "file" ? ( - - ) : field.type == "zip" ? ( - - ) : field.type === "list" ? ( - - ) : field.type === "select" ? ( - - ) : field.type === "number" ? ( - - ) : field.type === "checkbox" ? ( - - ) : ( - - )} -
    - ); - } - })} - - ); -}; - -export default DynamicConnectionForm; diff --git a/web/src/app/admin/connectors/[connector]/pages/formelements/NumberInput.tsx b/web/src/app/admin/connectors/[connector]/pages/formelements/NumberInput.tsx deleted file mode 100644 index 5a9f5041b5d..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/formelements/NumberInput.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { SubLabel } from "@/components/admin/connectors/Field"; -import { Field } from "formik"; - -export default function NumberInput({ - label, - value, - optional, - description, - name, - showNeverIfZero, -}: { - value?: number; - label: string; - name: string; - optional?: boolean; - description?: string; - showNeverIfZero?: boolean; -}) { - return ( -
    - - {description && {description}} - - -
    - ); -} diff --git a/web/src/app/admin/connectors/[connector]/pages/gdrive/Credential.tsx b/web/src/app/admin/connectors/[connector]/pages/gdrive/Credential.tsx deleted file mode 100644 index a320d466b40..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/gdrive/Credential.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import { Button } from "@/components/Button"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { useState } from "react"; -import { useSWRConfig } from "swr"; -import * as Yup from "yup"; -import { useRouter } from "next/navigation"; -import { adminDeleteCredential } from "@/lib/credential"; -import { setupGoogleDriveOAuth } from "@/lib/googleDrive"; -import { GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants"; -import Cookies from "js-cookie"; -import { TextFormField } from "@/components/admin/connectors/Field"; -import { Form, Formik } from "formik"; -import { Card } from "@tremor/react"; -import { - Credential, - GoogleDriveCredentialJson, - GoogleDriveServiceAccountCredentialJson, -} from "@/lib/connectors/credentials"; - -type GoogleDriveCredentialJsonTypes = "authorized_user" | "service_account"; - -export const DriveJsonUpload = ({ - setPopup, -}: { - setPopup: (popupSpec: PopupSpec | null) => void; -}) => { - const { mutate } = useSWRConfig(); - const [credentialJsonStr, setCredentialJsonStr] = useState< - string | undefined - >(); - - return ( - <> - { - if (!event.target.files) { - return; - } - const file = event.target.files[0]; - const reader = new FileReader(); - - reader.onload = function (loadEvent) { - if (!loadEvent?.target?.result) { - return; - } - const fileContents = loadEvent.target.result; - setCredentialJsonStr(fileContents as string); - }; - - reader.readAsText(file); - }} - /> - - - - ); -}; - -interface DriveJsonUploadSectionProps { - setPopup: (popupSpec: PopupSpec | null) => void; - appCredentialData?: { client_id: string }; - serviceAccountCredentialData?: { service_account_email: string }; - isAdmin: boolean; -} - -export const DriveJsonUploadSection = ({ - setPopup, - appCredentialData, - serviceAccountCredentialData, - isAdmin, -}: DriveJsonUploadSectionProps) => { - const { mutate } = useSWRConfig(); - - if (serviceAccountCredentialData?.service_account_email) { - return ( -
    -
    - Found existing service account key with the following Email: -

    - {serviceAccountCredentialData.service_account_email} -

    -
    - {isAdmin ? ( - <> -
    - If you want to update these credentials, delete the existing - credentials through the button below, and then upload a new - credentials JSON. -
    - - - ) : ( - <> -
    - To change these credentials, please contact an administrator. -
    - - )} -
    - ); - } - - if (appCredentialData?.client_id) { - return ( -
    -
    - Found existing app credentials with the following Client ID: -

    {appCredentialData.client_id}

    -
    -
    - If you want to update these credentials, delete the existing - credentials through the button below, and then upload a new - credentials JSON. -
    - -
    - ); - } - - if (!isAdmin) { - return ( -
    -

    - Curators are unable to set up the google drive credentials. To add a - Google Drive connector, please contact an administrator. -

    -
    - ); - } - - return ( -
    -

    - Follow the guide{" "} - - here - {" "} - to either (1) setup a google OAuth App in your company workspace or (2) - create a Service Account. -
    -
    - Download the credentials JSON if choosing option (1) or the Service - Account key JSON if chooosing option (2), and upload it here. -

    - -
    - ); -}; - -interface DriveCredentialSectionProps { - googleDrivePublicCredential?: Credential; - googleDriveServiceAccountCredential?: Credential; - serviceAccountKeyData?: { service_account_email: string }; - appCredentialData?: { client_id: string }; - setPopup: (popupSpec: PopupSpec | null) => void; - refreshCredentials: () => void; - connectorExists: boolean; -} - -export const DriveOAuthSection = ({ - googleDrivePublicCredential, - googleDriveServiceAccountCredential, - serviceAccountKeyData, - appCredentialData, - setPopup, - refreshCredentials, - connectorExists, -}: DriveCredentialSectionProps) => { - const router = useRouter(); - - const existingCredential = - googleDrivePublicCredential || googleDriveServiceAccountCredential; - if (existingCredential) { - return ( - <> -

    - Existing credential already setup! -

    - - - ); - } - - if (serviceAccountKeyData?.service_account_email) { - return ( -
    -

    - When using a Google Drive Service Account, you can either have Danswer - act as the service account itself OR you can specify an account for - the service account to impersonate. -
    -
    - If you want to use the service account itself, leave the{" "} - 'User email to impersonate' field blank when - submitting. If you do choose this option, make sure you have shared - the documents you want to index with the service account. -

    - - - { - formikHelpers.setSubmitting(true); - - const response = await fetch( - "/api/manage/admin/connector/google-drive/service-account-credential", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - google_drive_delegated_user: - values.google_drive_delegated_user, - }), - } - ); - - if (response.ok) { - setPopup({ - message: "Successfully created service account credential", - type: "success", - }); - } else { - const errorMsg = await response.text(); - setPopup({ - message: `Failed to create service account credential - ${errorMsg}`, - type: "error", - }); - } - refreshCredentials(); - }} - > - {({ isSubmitting }) => ( -
    - -
    - -
    - - )} -
    -
    -
    - ); - } - - if (appCredentialData?.client_id) { - return ( -
    -

    - Next, you must provide credentials via OAuth. This gives us read - access to the docs you have access to in your google drive account. -

    - -
    - ); - } - - // case where no keys have been uploaded in step 1 - return ( -

    - Please upload either a OAuth Client Credential JSON or a Google Drive - Service Account Key JSON in Step 1 before moving onto Step 2. -

    - ); -}; diff --git a/web/src/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage.tsx b/web/src/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage.tsx deleted file mode 100644 index 4494e4b22ee..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/gdrive/GoogleDrivePage.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import React from "react"; -import { useState, useEffect } from "react"; -import useSWR from "swr"; -import { FetchError, errorHandlingFetcher } from "@/lib/fetcher"; -import { ErrorCallout } from "@/components/ErrorCallout"; -import { LoadingAnimation } from "@/components/Loading"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { ConnectorIndexingStatus } from "@/lib/types"; -import { getCurrentUser } from "@/lib/user"; -import { User, UserRole } from "@/lib/types"; -import { usePublicCredentials } from "@/lib/hooks"; -import { Title } from "@tremor/react"; -import { DriveJsonUploadSection, DriveOAuthSection } from "./Credential"; -import { - Credential, - GoogleDriveCredentialJson, - GoogleDriveServiceAccountCredentialJson, -} from "@/lib/connectors/credentials"; -import { GoogleDriveConfig } from "@/lib/connectors/connectors"; -import { useUser } from "@/components/user/UserProvider"; -import { useConnectorCredentialIndexingStatus } from "@/lib/hooks"; - -const GDriveMain = ({}: {}) => { - const { isLoadingUser, isAdmin } = useUser(); - - const { - data: appCredentialData, - isLoading: isAppCredentialLoading, - error: isAppCredentialError, - } = useSWR<{ client_id: string }, FetchError>( - "/api/manage/admin/connector/google-drive/app-credential", - errorHandlingFetcher - ); - - const { - data: serviceAccountKeyData, - isLoading: isServiceAccountKeyLoading, - error: isServiceAccountKeyError, - } = useSWR<{ service_account_email: string }, FetchError>( - "/api/manage/admin/connector/google-drive/service-account-key", - errorHandlingFetcher - ); - - const { - data: connectorIndexingStatuses, - isLoading: isConnectorIndexingStatusesLoading, - error: connectorIndexingStatusesError, - } = useConnectorCredentialIndexingStatus(); - const { - data: credentialsData, - isLoading: isCredentialsLoading, - error: credentialsError, - refreshCredentials, - } = usePublicCredentials(); - - const { popup, setPopup } = usePopup(); - - const appCredentialSuccessfullyFetched = - appCredentialData || - (isAppCredentialError && isAppCredentialError.status === 404); - const serviceAccountKeySuccessfullyFetched = - serviceAccountKeyData || - (isServiceAccountKeyError && isServiceAccountKeyError.status === 404); - - if (isLoadingUser) { - return <>; - } - - if ( - (!appCredentialSuccessfullyFetched && isAppCredentialLoading) || - (!serviceAccountKeySuccessfullyFetched && isServiceAccountKeyLoading) || - (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || - (!credentialsData && isCredentialsLoading) - ) { - return ( -
    - -
    - ); - } - - if (credentialsError || !credentialsData) { - return ; - } - - if (connectorIndexingStatusesError || !connectorIndexingStatuses) { - return ; - } - - if ( - !appCredentialSuccessfullyFetched || - !serviceAccountKeySuccessfullyFetched - ) { - return ( - - ); - } - - const googleDrivePublicCredential: - | Credential - | undefined = credentialsData.find( - (credential) => - credential.credential_json?.google_drive_tokens && credential.admin_public - ); - const googleDriveServiceAccountCredential: - | Credential - | undefined = credentialsData.find( - (credential) => credential.credential_json?.google_drive_service_account_key - ); - const googleDriveConnectorIndexingStatuses: ConnectorIndexingStatus< - GoogleDriveConfig, - GoogleDriveCredentialJson - >[] = connectorIndexingStatuses.filter( - (connectorIndexingStatus) => - connectorIndexingStatus.connector.source === "google_drive" - ); - - return ( - <> - {popup} - - Step 1: Provide your Credentials - - - - {isAdmin && ( - <> - - Step 2: Authenticate with Danswer - - 0} - /> - - )} - - ); -}; - -export default GDriveMain; diff --git a/web/src/app/admin/connectors/[connector]/pages/gmail/Credential.tsx b/web/src/app/admin/connectors/[connector]/pages/gmail/Credential.tsx deleted file mode 100644 index 8b456884f1a..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/gmail/Credential.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import { Button } from "@/components/Button"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { useState } from "react"; -import { useSWRConfig } from "swr"; -import * as Yup from "yup"; -import { useRouter } from "next/navigation"; -import { adminDeleteCredential } from "@/lib/credential"; -import { setupGmailOAuth } from "@/lib/gmail"; -import { GMAIL_AUTH_IS_ADMIN_COOKIE_NAME } from "@/lib/constants"; -import Cookies from "js-cookie"; -import { TextFormField } from "@/components/admin/connectors/Field"; -import { Form, Formik } from "formik"; -import { Card } from "@tremor/react"; -import { - Credential, - GmailCredentialJson, - GmailServiceAccountCredentialJson, -} from "@/lib/connectors/credentials"; - -type GmailCredentialJsonTypes = "authorized_user" | "service_account"; - -const DriveJsonUpload = ({ - setPopup, -}: { - setPopup: (popupSpec: PopupSpec | null) => void; -}) => { - const { mutate } = useSWRConfig(); - const [credentialJsonStr, setCredentialJsonStr] = useState< - string | undefined - >(); - - return ( - <> - { - if (!event.target.files) { - return; - } - const file = event.target.files[0]; - const reader = new FileReader(); - - reader.onload = function (loadEvent) { - if (!loadEvent?.target?.result) { - return; - } - const fileContents = loadEvent.target.result; - setCredentialJsonStr(fileContents as string); - }; - - reader.readAsText(file); - }} - /> - - - - ); -}; - -interface DriveJsonUploadSectionProps { - setPopup: (popupSpec: PopupSpec | null) => void; - appCredentialData?: { client_id: string }; - serviceAccountCredentialData?: { service_account_email: string }; - isAdmin: boolean; -} - -export const GmailJsonUploadSection = ({ - setPopup, - appCredentialData, - serviceAccountCredentialData, - isAdmin, -}: DriveJsonUploadSectionProps) => { - const { mutate } = useSWRConfig(); - - if (serviceAccountCredentialData?.service_account_email) { - return ( -
    -
    - Found existing service account key with the following Email: -

    - {serviceAccountCredentialData.service_account_email} -

    -
    - {isAdmin ? ( - <> -
    - If you want to update these credentials, delete the existing - credentials through the button below, and then upload a new - credentials JSON. -
    - - - ) : ( - <> -
    - To change these credentials, please contact an administrator. -
    - - )} -
    - ); - } - - if (appCredentialData?.client_id) { - return ( -
    -
    - Found existing app credentials with the following Client ID: -

    {appCredentialData.client_id}

    -
    -
    - If you want to update these credentials, delete the existing - credentials through the button below, and then upload a new - credentials JSON. -
    - -
    - ); - } - - if (!isAdmin) { - return ( -
    -

    - Curators are unable to set up the Gmail credentials. To add a Gmail - connector, please contact an administrator. -

    -
    - ); - } - - return ( -
    -

    - Follow the guide{" "} - - here - {" "} - to setup a google OAuth App in your company workspace. -
    -
    - Download the credentials JSON and upload it here. -

    - -
    - ); -}; - -interface DriveCredentialSectionProps { - gmailPublicCredential?: Credential; - gmailServiceAccountCredential?: Credential; - serviceAccountKeyData?: { service_account_email: string }; - appCredentialData?: { client_id: string }; - setPopup: (popupSpec: PopupSpec | null) => void; - refreshCredentials: () => void; - connectorExists: boolean; -} - -export const GmailOAuthSection = ({ - gmailPublicCredential, - gmailServiceAccountCredential, - serviceAccountKeyData, - appCredentialData, - setPopup, - refreshCredentials, - connectorExists, -}: DriveCredentialSectionProps) => { - const router = useRouter(); - - const existingCredential = - gmailPublicCredential || gmailServiceAccountCredential; - if (existingCredential) { - return ( - <> -

    - Existing credential already setup! -

    - - - ); - } - - if (serviceAccountKeyData?.service_account_email) { - return ( -
    -

    - When using a Gmail Service Account, you can either have Danswer act as - the service account itself OR you can specify an account for the - service account to impersonate. -
    -
    - If you want to use the service account itself, leave the{" "} - 'User email to impersonate' field blank when - submitting. If you do choose this option, make sure you have shared - the documents you want to index with the service account. -

    - - - { - formikHelpers.setSubmitting(true); - - const response = await fetch( - "/api/manage/admin/connector/gmail/service-account-credential", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - gmail_delegated_user: values.gmail_delegated_user, - }), - } - ); - - if (response.ok) { - setPopup({ - message: "Successfully created service account credential", - type: "success", - }); - } else { - const errorMsg = await response.text(); - setPopup({ - message: `Failed to create service account credential - ${errorMsg}`, - type: "error", - }); - } - refreshCredentials(); - }} - > - {({ isSubmitting }) => ( -
    - -
    - -
    - - )} -
    -
    -
    - ); - } - - if (appCredentialData?.client_id) { - return ( -
    -

    - Next, you must provide credentials via OAuth. This gives us read - access to the docs you have access to in your gmail account. -

    - -
    - ); - } - - // case where no keys have been uploaded in step 1 - return ( -

    - Please upload a OAuth Client Credential JSON in Step 1 before moving onto - Step 2. -

    - ); -}; diff --git a/web/src/app/admin/connectors/[connector]/pages/gmail/GmailPage.tsx b/web/src/app/admin/connectors/[connector]/pages/gmail/GmailPage.tsx deleted file mode 100644 index 5f52eb31013..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/gmail/GmailPage.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import useSWR from "swr"; -import { errorHandlingFetcher } from "@/lib/fetcher"; -import { LoadingAnimation } from "@/components/Loading"; -import { usePopup } from "@/components/admin/connectors/Popup"; -import { ConnectorIndexingStatus } from "@/lib/types"; -import { getCurrentUser } from "@/lib/user"; -import { User, UserRole } from "@/lib/types"; -import { - Credential, - GmailCredentialJson, - GmailServiceAccountCredentialJson, -} from "@/lib/connectors/credentials"; -import { GmailOAuthSection, GmailJsonUploadSection } from "./Credential"; -import { usePublicCredentials } from "@/lib/hooks"; -import { Title } from "@tremor/react"; -import { GmailConfig } from "@/lib/connectors/connectors"; -import { useState, useEffect } from "react"; -import { useUser } from "@/components/user/UserProvider"; -import { useConnectorCredentialIndexingStatus } from "@/lib/hooks"; - -export const GmailMain = () => { - const { isLoadingUser, isAdmin } = useUser(); - - const { - data: appCredentialData, - isLoading: isAppCredentialLoading, - error: isAppCredentialError, - } = useSWR<{ client_id: string }>( - "/api/manage/admin/connector/gmail/app-credential", - errorHandlingFetcher - ); - const { - data: serviceAccountKeyData, - isLoading: isServiceAccountKeyLoading, - error: isServiceAccountKeyError, - } = useSWR<{ service_account_email: string }>( - "/api/manage/admin/connector/gmail/service-account-key", - errorHandlingFetcher - ); - const { - data: connectorIndexingStatuses, - isLoading: isConnectorIndexingStatusesLoading, - error: connectorIndexingStatusesError, - } = useConnectorCredentialIndexingStatus(); - - const { - data: credentialsData, - isLoading: isCredentialsLoading, - error: credentialsError, - refreshCredentials, - } = usePublicCredentials(); - - const { popup, setPopup } = usePopup(); - - const appCredentialSuccessfullyFetched = - appCredentialData || - (isAppCredentialError && isAppCredentialError.status === 404); - const serviceAccountKeySuccessfullyFetched = - serviceAccountKeyData || - (isServiceAccountKeyError && isServiceAccountKeyError.status === 404); - - if (isLoadingUser) { - return <>; - } - - if ( - (!appCredentialSuccessfullyFetched && isAppCredentialLoading) || - (!serviceAccountKeySuccessfullyFetched && isServiceAccountKeyLoading) || - (!connectorIndexingStatuses && isConnectorIndexingStatusesLoading) || - (!credentialsData && isCredentialsLoading) - ) { - return ( -
    - -
    - ); - } - - if (credentialsError || !credentialsData) { - return ( -
    -
    Failed to load credentials.
    -
    - ); - } - - if (connectorIndexingStatusesError || !connectorIndexingStatuses) { - return ( -
    -
    Failed to load connectors.
    -
    - ); - } - - if ( - !appCredentialSuccessfullyFetched || - !serviceAccountKeySuccessfullyFetched - ) { - return ( -
    -
    - Error loading Gmail app credentials. Contact an administrator. -
    -
    - ); - } - - const gmailPublicCredential: Credential | undefined = - credentialsData.find( - (credential) => - credential.credential_json?.gmail_tokens && credential.admin_public - ); - const gmailServiceAccountCredential: - | Credential - | undefined = credentialsData.find( - (credential) => credential.credential_json?.gmail_service_account_key - ); - const gmailConnectorIndexingStatuses: ConnectorIndexingStatus< - GmailConfig, - GmailCredentialJson - >[] = connectorIndexingStatuses.filter( - (connectorIndexingStatus) => - connectorIndexingStatus.connector.source === "gmail" - ); - - return ( - <> - {popup} - - Step 1: Provide your Credentials - - - - {isAdmin && ( - <> - - Step 2: Authenticate with Danswer - - 0} - /> - - )} - - ); -}; diff --git a/web/src/app/admin/connectors/[connector]/pages/utils/files.ts b/web/src/app/admin/connectors/[connector]/pages/utils/files.ts deleted file mode 100644 index d847efe89d1..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/utils/files.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { createConnector, runConnector } from "@/lib/connector"; -import { createCredential, linkCredential } from "@/lib/credential"; -import { FileConfig } from "@/lib/connectors/connectors"; -import { AdvancedConfigFinal } from "../../AddConnectorPage"; - -export const submitFiles = async ( - selectedFiles: File[], - setPopup: (popup: PopupSpec) => void, - setSelectedFiles: (files: File[]) => void, - name: string, - advancedConfig: AdvancedConfigFinal, - isPublic: boolean, - groups?: number[] -) => { - const formData = new FormData(); - - selectedFiles.forEach((file) => { - formData.append("files", file); - }); - - const response = await fetch("/api/manage/admin/connector/file/upload", { - method: "POST", - body: formData, - }); - const responseJson = await response.json(); - if (!response.ok) { - setPopup({ - message: `Unable to upload files - ${responseJson.detail}`, - type: "error", - }); - return; - } - - const filePaths = responseJson.file_paths as string[]; - - const [connectorErrorMsg, connector] = await createConnector({ - name: "FileConnector-" + Date.now(), - source: "file", - input_type: "load_state", - connector_specific_config: { - file_locations: filePaths, - }, - refresh_freq: null, - prune_freq: null, - indexing_start: null, - is_public: isPublic, - groups: groups, - }); - if (connectorErrorMsg || !connector) { - setPopup({ - message: `Unable to create connector - ${connectorErrorMsg}`, - type: "error", - }); - return; - } - - // Since there is no "real" credential associated with a file connector - // we create a dummy one here so that we can associate the CC Pair with a - // user. This is needed since the user for a CC Pair is found via the credential - // associated with it. - const createCredentialResponse = await createCredential({ - credential_json: {}, - admin_public: true, - source: "file", - curator_public: isPublic, - groups: groups, - name, - }); - if (!createCredentialResponse.ok) { - const errorMsg = await createCredentialResponse.text(); - setPopup({ - message: `Error creating credential for CC Pair - ${errorMsg}`, - type: "error", - }); - return; - false; - } - const credentialId = (await createCredentialResponse.json()).id; - - const credentialResponse = await linkCredential( - connector.id, - credentialId, - name, - isPublic, - groups - ); - if (!credentialResponse.ok) { - const credentialResponseJson = await credentialResponse.json(); - setPopup({ - message: `Unable to link connector to credential - ${credentialResponseJson.detail}`, - type: "error", - }); - return false; - } - - const runConnectorErrorMsg = await runConnector(connector.id, [0]); - if (runConnectorErrorMsg) { - setPopup({ - message: `Unable to run connector - ${runConnectorErrorMsg}`, - type: "error", - }); - return false; - } - - setSelectedFiles([]); - setPopup({ - type: "success", - message: "Successfully uploaded files!", - }); - return true; -}; diff --git a/web/src/app/admin/connectors/[connector]/pages/utils/google_site.ts b/web/src/app/admin/connectors/[connector]/pages/utils/google_site.ts deleted file mode 100644 index f1689e8fcdf..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/utils/google_site.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { createConnector, runConnector } from "@/lib/connector"; -import { linkCredential } from "@/lib/credential"; -import { GoogleSitesConfig } from "@/lib/connectors/connectors"; -import { AdvancedConfigFinal } from "../../AddConnectorPage"; - -export const submitGoogleSite = async ( - selectedFiles: File[], - base_url: any, - setPopup: (popup: PopupSpec) => void, - advancedConfig: AdvancedConfigFinal, - name?: string -) => { - const uploadCreateAndTriggerConnector = async () => { - const formData = new FormData(); - - selectedFiles.forEach((file) => { - formData.append("files", file); - }); - - const response = await fetch("/api/manage/admin/connector/file/upload", { - method: "POST", - body: formData, - }); - const responseJson = await response.json(); - if (!response.ok) { - setPopup({ - message: `Unable to upload files - ${responseJson.detail}`, - type: "error", - }); - return false; - } - - const filePaths = responseJson.file_paths as string[]; - const [connectorErrorMsg, connector] = - await createConnector({ - name: name ? name : `GoogleSitesConnector-${base_url}`, - source: "google_sites", - input_type: "load_state", - connector_specific_config: { - base_url: base_url, - zip_path: filePaths[0], - }, - refresh_freq: advancedConfig.refreshFreq, - prune_freq: advancedConfig.pruneFreq, - indexing_start: advancedConfig.indexingStart, - }); - if (connectorErrorMsg || !connector) { - setPopup({ - message: `Unable to create connector - ${connectorErrorMsg}`, - type: "error", - }); - return false; - } - - const credentialResponse = await linkCredential(connector.id, 0, base_url); - if (!credentialResponse.ok) { - const credentialResponseJson = await credentialResponse.json(); - setPopup({ - message: `Unable to link connector to credential - ${credentialResponseJson.detail}`, - type: "error", - }); - return false; - } - - const runConnectorErrorMsg = await runConnector(connector.id, [0]); - if (runConnectorErrorMsg) { - setPopup({ - message: `Unable to run connector - ${runConnectorErrorMsg}`, - type: "error", - }); - return false; - } - setPopup({ - type: "success", - message: "Successfully created Google Site connector!", - }); - return true; - }; - - try { - const response = await uploadCreateAndTriggerConnector(); - return response; - } catch (e) { - return false; - } -}; diff --git a/web/src/app/admin/connectors/[connector]/pages/utils/hooks.ts b/web/src/app/admin/connectors/[connector]/pages/utils/hooks.ts deleted file mode 100644 index d3a48e3a26b..00000000000 --- a/web/src/app/admin/connectors/[connector]/pages/utils/hooks.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { GmailConfig } from "@/lib/connectors/connectors"; - -export const gmailConnectorNameBuilder = (values: GmailConfig) => - "GmailConnector"; - -import { usePublicCredentials } from "@/lib/hooks"; -import { - Credential, - GmailCredentialJson, - GmailServiceAccountCredentialJson, - GoogleDriveCredentialJson, - GoogleDriveServiceAccountCredentialJson, -} from "@/lib/connectors/credentials"; - -export const useGmailCredentials = () => { - const { - data: credentialsData, - isLoading: isCredentialsLoading, - error: credentialsError, - refreshCredentials, - } = usePublicCredentials(); - - const gmailPublicCredential: Credential | undefined = - credentialsData?.find( - (credential) => - credential.credential_json?.gmail_tokens && credential.admin_public - ); - - const gmailServiceAccountCredential: - | Credential - | undefined = credentialsData?.find( - (credential) => credential.credential_json?.gmail_service_account_key - ); - - const liveGmailCredential = - gmailPublicCredential || gmailServiceAccountCredential; - - return { - liveGmailCredential, - }; -}; - -export const useGoogleDriveCredentials = () => { - const { data: credentialsData } = usePublicCredentials(); - - const googleDrivePublicCredential: - | Credential - | undefined = credentialsData?.find( - (credential) => - credential.credential_json?.google_drive_tokens && credential.admin_public - ); - - const googleDriveServiceAccountCredential: - | Credential - | undefined = credentialsData?.find( - (credential) => credential.credential_json?.google_drive_service_account_key - ); - - const liveGDriveCredential = - googleDrivePublicCredential || googleDriveServiceAccountCredential; - - return { - liveGDriveCredential, - }; -}; diff --git a/web/src/app/admin/documents/ScoreEditor.tsx b/web/src/app/admin/documents/ScoreEditor.tsx deleted file mode 100644 index 0cdacc339ab..00000000000 --- a/web/src/app/admin/documents/ScoreEditor.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { PopupSpec } from "@/components/admin/connectors/Popup"; -import { updateBoost } from "./lib"; -import { EditableValue } from "@/components/EditableValue"; - -export const ScoreSection = ({ - documentId, - initialScore, - setPopup, - refresh, - consistentWidth = true, -}: { - documentId: string; - initialScore: number; - setPopup: (popupSpec: PopupSpec | null) => void; - refresh: () => void; - consistentWidth?: boolean; -}) => { - const onSubmit = async (value: string) => { - const numericScore = Number(value); - if (isNaN(numericScore)) { - setPopup({ - message: "Score must be a number", - type: "error", - }); - return false; - } - - const errorMsg = await updateBoost(documentId, numericScore); - if (errorMsg) { - setPopup({ - message: errorMsg, - type: "error", - }); - return false; - } else { - setPopup({ - message: "Updated score!", - type: "success", - }); - refresh(); - } - - return true; - }; - - return ( - - ); -}; diff --git a/web/src/app/admin/documents/explorer/Explorer.tsx b/web/src/app/admin/documents/explorer/Explorer.tsx deleted file mode 100644 index a773c222484..00000000000 --- a/web/src/app/admin/documents/explorer/Explorer.tsx +++ /dev/null @@ -1,220 +0,0 @@ -"use client"; - -import { adminSearch } from "./lib"; -import { MagnifyingGlass } from "@phosphor-icons/react"; -import { useState, useEffect } from "react"; -import { DanswerDocument } from "@/lib/search/interfaces"; -import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay"; -import { CustomCheckbox } from "@/components/CustomCheckbox"; -import { updateHiddenStatus } from "../lib"; -import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup"; -import { getErrorMsg } from "@/lib/fetchUtils"; -import { ScoreSection } from "../ScoreEditor"; -import { useRouter } from "next/navigation"; -import { HorizontalFilters } from "@/components/search/filtering/Filters"; -import { useFilters } from "@/lib/hooks"; -import { buildFilters } from "@/lib/search/utils"; -import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge"; -import { DocumentSet } from "@/lib/types"; -import { SourceIcon } from "@/components/SourceIcon"; -import { Connector } from "@/lib/connectors/connectors"; - -const DocumentDisplay = ({ - document, - refresh, - setPopup, -}: { - document: DanswerDocument; - refresh: () => void; - setPopup: (popupSpec: PopupSpec | null) => void; -}) => { - return ( -
    - -
    -
    -

    Boost:

    - -
    -
    { - const response = await updateHiddenStatus( - document.document_id, - !document.hidden - ); - if (response.ok) { - refresh(); - } else { - setPopup({ - type: "error", - message: `Failed to update document - ${getErrorMsg( - response - )}}`, - }); - } - }} - className="px-1 py-0.5 bg-hover hover:bg-hover-light rounded flex cursor-pointer select-none" - > -
    - {document.hidden ? ( -
    Hidden
    - ) : ( - "Visible" - )} -
    -
    - -
    -
    -
    - {document.updated_at && ( -
    - -
    - )} -

    - {buildDocumentSummaryDisplay(document.match_highlights, document.blurb)} -

    -
    - ); -}; - -export function Explorer({ - initialSearchValue, - connectors, - documentSets, -}: { - initialSearchValue: string | undefined; - connectors: Connector[]; - documentSets: DocumentSet[]; -}) { - const router = useRouter(); - const { popup, setPopup } = usePopup(); - - const [query, setQuery] = useState(initialSearchValue || ""); - const [timeoutId, setTimeoutId] = useState(null); - const [results, setResults] = useState([]); - - const filterManager = useFilters(); - - const onSearch = async (query: string) => { - const filters = buildFilters( - filterManager.selectedSources, - filterManager.selectedDocumentSets, - filterManager.timeRange, - filterManager.selectedTags - ); - const results = await adminSearch(query, filters); - if (results.ok) { - setResults((await results.json()).documents); - } - setTimeoutId(null); - }; - - useEffect(() => { - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - - if (query && query.trim() !== "") { - router.replace( - `/admin/documents/explorer?query=${encodeURIComponent(query)}` - ); - - const newTimeoutId = window.setTimeout(() => onSearch(query), 300); - setTimeoutId(newTimeoutId); - } else { - setResults([]); - } - }, [ - query, - filterManager.selectedDocumentSets, - filterManager.selectedSources, - filterManager.timeRange, - ]); - - return ( -
    - {popup} -
    -
    - -
    - {popup} - - - Setup a Slack bot that connects to Danswer. Once setup, you will be able - to ask questions to Danswer directly from Slack. Additionally, you can: - - - -
      -
    • - Setup DanswerBot to automatically answer questions in certain - channels. -
    • -
    • - Choose which document sets DanswerBot should answer from, depending - on the channel the question is being asked. -
    • -
    • - Directly message DanswerBot to search just as you would in the web - UI. -
    • -
    -
    - - - Follow the{" "} -
    - guide{" "} - - found in the Danswer documentation to get started! - - - Step 1: Configure Slack Tokens - {!slackBotTokens ? ( -
    - refreshSlackBotTokens()} - setPopup={setPopup} - /> -
    - ) : ( - <> - Tokens saved! - - {slackBotTokensModalIsOpen && ( -
    - { - refreshSlackBotTokens(); - setSlackBotTokensModalIsOpen(false); - }} - setPopup={setPopup} - existingTokens={slackBotTokens} - /> -
    - )} - - )} - {slackBotTokens && ( - <> - Step 2: Setup DanswerBot - - Configure Danswer to automatically answer questions in Slack - channels. By default, Danswer only responds in channels where a - configuration is setup unless it is explicitly tagged. - - -
    - - - - - - {slackBotConfigs.length > 0 && ( -
    - -
    - )} - - )} -

    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 diff --git a/web/public/microsoft.png b/web/public/microsoft.png deleted file mode 100644 index 11feffc1d82cf95f4fcf7eaf9ad39991d5d99056..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5598 zcmeAS@N?(olHy`uVBq!ia0y~yV15C@9Be=l-^Ev+04d25*NBqf{Irtt#G+IN$CUh} zR0Yr6#Prml)Wnp^!jq{s3=E>p*LBuuCJx2Rl0&`xhM^LkCL_=*v zqw5W)l83jr);Ml#$j=f;obldLA(AbwZ36eSju}8p7)nJO`G6FYERYZYx`9FA0wa(d z6(lP_)&hfWG%Tr|j$#)uGcYtbumi)E#R^DplmH2b5iNMw{NC@W&b(Vz&7UtfUCP|< z-mbhoJaUW|79CF02vcZY$q5S~NNNNJ8#rf!gKbohoB$au6lqaQfHO0=#DL^*aLqF+ z2&#(750H^vI8wQ`5Qay^u&;d_;*5nS1mrU<2>gEP`?r5A4P9O?5sfkJyF`>382$+Q zHaoBb^CvJ}Hu8bWg2ujK$1k5D2+VpMB?2!#e0Hv_D+^ylMU(QQ0S95^PV+ICKS3j3^P6 - - - - - - diff --git a/web/public/r2.png b/web/public/r2.png deleted file mode 100644 index 958b3bf4992212cff2d76e1266beb0ca7be85a58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12851 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 diff --git a/web/src/app/admin/add-connector/page.tsx b/web/src/app/admin/add-connector/page.tsx deleted file mode 100644 index bf7032b5f90..00000000000 --- a/web/src/app/admin/add-connector/page.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; -import { SourceIcon } from "@/components/SourceIcon"; -import { AdminPageTitle } from "@/components/admin/Title"; -import { ConnectorIcon } from "@/components/icons/icons"; -import { SourceCategory, SourceMetadata } from "@/lib/search/interfaces"; -import { listSourceMetadata } from "@/lib/sources"; -import { Title, Text, Button } from "@tremor/react"; -import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; - -function SourceTile({ - sourceMetadata, - preSelect, -}: { - sourceMetadata: SourceMetadata; - preSelect?: boolean; -}) { - return ( - - - - {sourceMetadata.displayName} - - - ); -} -export default function Page() { - const sources = useMemo(() => listSourceMetadata(), []); - const [searchTerm, setSearchTerm] = useState(""); - - const searchInputRef = useRef(null); - - useEffect(() => { - if (searchInputRef.current) { - searchInputRef.current.focus(); - } - }, []); - const filterSources = (sources: SourceMetadata[]) => { - if (!searchTerm) return sources; - const lowerSearchTerm = searchTerm.toLowerCase(); - return sources.filter( - (source) => - source.displayName.toLowerCase().includes(lowerSearchTerm) || - source.category.toLowerCase().includes(lowerSearchTerm) - ); - }; - - const categorizedSources = useMemo(() => { - const filtered = filterSources(sources); - return Object.values(SourceCategory).reduce( - (acc, category) => { - acc[category] = sources.filter( - (source) => - source.category === category && - (filtered.includes(source) || - category.toLowerCase().includes(searchTerm.toLowerCase())) - ); - return acc; - }, - {} as Record - ); - }, [sources, searchTerm]); - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - const filteredCategories = Object.entries(categorizedSources).filter( - ([_, sources]) => sources.length > 0 - ); - if ( - filteredCategories.length > 0 && - filteredCategories[0][1].length > 0 - ) { - const firstSource = filteredCategories[0][1][0]; - if (firstSource) { - window.open(firstSource.adminUrl, "_self"); - } - } - } - }; - - return ( -

  2. 19rC;Uy7sCh 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≀_quVa%gSNrGhG(Z^e|A0>M!r|$afr*FI*eZHr4kXYD99=d> zj;F=18YBU#Snbpq@?-TQpXDMh2n@)1!N<-{d$!LY5j!9DBn#R&=5UPI5Y{V+1`-W^V60cIP2hRs7{!EW>1T3yTlvQ#1M0fny z_r^O)p@`IJi%x`TF8o%J1jF3#igp;w6KEPy(DHBWz?x%ADzZ0 zIGbeUlvm{w{skBKHuEF)lY~)#Vj*pYmB-^3C4BRL!-YTbTWfyF0$#vR(+DZ^eXKT0 z^PA9$#1qnz6eqy616t3#WEPE`AV#s`ktIykFHp!Z=qqc4gg&x~e*VpMi)4!A*9Rck zr}pX$zfJ&qZ8fc%NZJZ}u=T)V;ShK$HjS-6X_ahhVm}RFq?-vfh+>g<$L2k-w26L=THHlm62C+Ye8yV*5}Ct~m1KKWQn*VC}6)Jt527kvyIO_6%Z zPRC_6RhYn`9$y8r82XE?Qg-UuKm+bL?WQ7jx>VxGz%0)|T&4zBFmQ~U^NSl_+rURL z;&IF-GiwlAh~KX&`smq%nISr0t$g2Pe~Q@2{XFMbr=bH!qE8iDP&Nb2_!N7=fYL9l z_2n^}j&rYiE9HNVq<_^-RSdphqp2lhghVueJ7H3_JFGoWY+-t~FFnFeTl}tzt5^vd zZ6@+?IVC$cxiWk8>}25bN&4&bccEBQK%Fl0Ax$7owhtYshB^+fh6PaTjif%2mzrUN zyF0FR=`cEoQAgl7x&aQxh$5I|GZ!4QxKa32V5G-NMo2t*;OWawimRv0&tgVa!z8E{ z8ns4jkAADBPY&c@>*L<^gVt*wdGK|8n>NA8#+(sGP|BW!RtyYtdc=0JF&*7!fXo)> z%F$s;NcIcN6Ex0LEm=rt+g$7Q+b$eiz@BrA?jO^%l>~P@t;RW-6}f&woQ+G^$TQi) z?X-2WxQoFo$w6SiJnJq8Bjr;!LS{^s#ac;`)bFM%#Yx#F_TLi@tm($xo|E# zs|F!@2GoISY;2yb>O>Pps-tCu3}`rW6NL)G4u~i4#1+_~reM^$R(&hk8pk>lJzXFK z1huhU!f%2Mk#~t~Evb(SE?e8((gPR+tMWH+z0YBIsZWMd+W8kmG{6s)jr^LAciBdm zXx2?R(xz@QH^HfKsKC}l5k%6==prkoJG3%9()vumCY7B7-i0h&<7x=3b6aFZ`^2xv z=U+EbeyD=&v~^kYgdhVxdm;VF;w(SU6^dZQQ2$bc*xl=d=&=0VH+mXA6>m`uN5}sPwG{piq9~8IO|p8h<+ANk5n>1*f7_1zE%5vy;zfBPcd4<&KXI`Lc6?u zESji9YD(0ASiP;a!ZE*ZH1Z4QQq3N9epIJ-WT*!t1wlqq((pD!K{eg_qyzHO0C=&S z;gqbmT--*DI@X=#7?77&8XLBuAW!%+@mqj4X&BiQA}KPTLQIX{3O{EkC2M=nW06&m z+mPFIE7Lc-m!|bAM6#*k8b=fs_ta_1C45U#Qw!@)V32{z0T;J=IvJ&B$@6?3uy|;vI1+CRb)|aF| zY7&X^*}tc#vXBfHgT{^Ds@-N{S!7?wwh1(~R6uARTR$hrK;KS%nG2h?caz(#$}hAz zFHccHc?&M~rH^$q8m*GLasvg>#dKJJs{ct1i2n_DE{I9eX;(Dz+s^9?fejh^H2i?a z35YVWgKpBm%je0tT|eYi4U{~pZ)ElZjT4?bY@&~1Y7$wj#^%61)jGv0Fwnp_JzFU% z?>Y673K{rX2XqVu#4Kl2geF90R4IlMg9Ua}J6PCb%eWS(u=25_35qh2OplNz6B2$l z!v+Vj<|^=-U`7C#GH_<^7F|Re*hHp2mYW|6EdrBil5{Uk2Q5{7rAE~3jHr`&P95jUvNGswf?JxPF#)f$c0wd=Ejk&gGBscgfQtbMOA82b%D##oQMHx4^02#E{ehhOJ*B0ioz&j4@q z7pi&sUf$a52(8(+dKFb(38uSdi7-DhthwY4twF$lV|A^(t+Fe8%nV7fb4&D|9)S^j z6q+*pp#B+)Nk;(&Gd9YBNZKDw8Dr1rYp#W$M(Pc(vUY{XCp>_6b{wcpH5;5HY%+NY zi1rj;m62b=d)k+N)@Z++p|gk(Nc(AcwjKvY(NZsdxqdeN^O8BjBW*##2z4VGv<^?< zt~wuhP$=MUo$k`Oi6|G;lV^HN-xeYEVm0HVsB~_=d8D5l_yPj91o!l)jGK0BqbPN> zV6Y4@N4?+Mga z^eNyN@TzXS=kmYiWzu>GZm{s3uQrf&U{mGxsM-M5z7lyi@}VY?k;zEN!7Wh}{EW$^ z96E9(j3g$OTc7nw8XKT>+UiMh;Q9RT%6)?9NS1sd<*i#HFX$@1AD7o9H3N2JRsuG0uW{mW&>F!Ik(HJTR4AzcUi5 z=E4o3NN|@kapviWwvpdY$0dKa0O3-$zgayYPk2<%^u&SV`1v;kHmBjEECa<&%YdiW zAXb-flh8QKd^fd*WlVP^v^oouKx1pH5$n~)C;kppQ9tu~r%<>y6QDfWwX5$uoN5Cb z{n*;KLUp+?7IfXbf}wFlFtFlvXJQ)5WpQ&>=X2->2|rz+3hpN1l_Bs1TEUD4l+0z| z?@0)(;8=!OMoRJ~4ZPn57DE8JW2~i;zHl7X4$>5z)s@CjnrG4L$?I6l$cpk0_|n5M zC?JXrY+-fc{NWn!MsU?sKg>NX1ASi;5Ojj-skE=t+%R1Aft`a1$ST7qNO69DGbWHbD3%>m(L&wy|j&@YqVB3*rb{QyVe$Uf3p~|K+oE1 zsU1wMX*G7Lv#lR9j<~2Gy6mpq{!1M|nsB6AOva};Iqc$*0NdT|>lEh8IJ3p){qrL@ zErV6v$Zs;fY?of-iPslXk+1$dwX!Px>9x2rli7FO>mjh51^()x`{y@jSW!bM^aPtx zpZUI=p9eZx79eElZE&pcCtZ-13FQpYoVQrF5v4CxqSNYawC&Q0_@2KS#cs;$Q8lj0k1!QM zhL>F1_!RVnnbK5hz+jvBb~RG6E>HWf>6zdpgN9$96@nI4hX#HrLxyDBH(_kE6e&fv)T!pt4KqO z+f4ZG@1Ar^4XUxv>>Q^+Ok=~Bh44)WRsYY}AL`S+qe~T?!0eG}Non#(*a5E*QPdL_ zC!V+WJ!k6gG?mZHiV}zwtmPZnIRZU9=aw1r}zEBYWW!ZCZDA6R9kb)8DLXsG9(x3ifk%VHtNoA>@fZz-lI) z^<1=EKR`oNFaj3go8G=e@u_JUdYVRlk@ILqZT)vg!2-fW>3g6J7;|LDsB+k^2eY+f z{W2L~Y+%krpNU(rTrhls>U~3e?A=E0$x+Io6R~%qx6@(tVrX{0st5**XxyKBG=lSE z9fKcLb!gS<4ovzN9rC#h=Aw%r0~E@Xmaz+~K|tRV|KU>U1CP~J;@CO-M^orYbnI}= zvA-NS@S{YfEzCOc|HK3%@Mizj48op-rxUY+U3m>la3MsJb!*uhW{Spb=uFP|;HGvu zF+kPGPi69&fo*1>Xjiz6D10j|Bf$6RLbS1pq56|QYj$G^><>bZmWYMGaz>s=$`Crn ze0V64M~KoB2)myL*$KTS$kv_MSQ47|c>mW#JF2DmhS-ji$_H!ElVaV|5;#uu{Cok9 z{g-dHVc~RgNfYcG;BR)OF_*u4_`k=9qHj-g9@p}Ut~i|#{F%B9%CP-AEgmBh_WiCb zz)3@Fh9k`bYp80%!ssFUx$rDeRe+#qovD!@mLA9&aaZBV9LDv4#X6wI%B4Kgl1{{w z)J`P?#N10eb?E_P`R=mV+uy22Jrrar5f!3#Zuj)KEO8Q-qq&#!c0;MXrNQDGS`|r{ zk-%~(z9$OsIjvmvCf5l2QyN6B*Z9iql{Y(uUj%OP(e4^C1fBnOW>F4lRrz1f zr??BzR&1}U#)jmmHC?h{b(#0y^CbZL4T=i+m?X9iJatHzX7TxdzBUi1IJx*g7KqLH zsG58Z6=a~&QK{Y)5UsoAtHjP%4m9Qiduio*GaK>=9R9HD>&e(uIi{!2$M&EC)}VDt z>YrG=`=B)hWm6xSUR5yi>yGSLE1(YM-!;Oq>c12QR?E7@^Wp7lJS95&2TbldlmB%} z%CtrJ3i|kDEq&lhOZxdA-JM)5x$)qsm8Wr7iJI0qh9_M~{wJUPk!c3#ZrPa7DRZ2P7hKmxMpnvMWURP>WEwH_uls(z%!F8NPjoOdtQLSQQc_kG0U zaf=`~9qVXrYf(=LGx{Ibxl_8qt!L1I$gD~vzx84)drFUmNQfe}HG5n%>hBI%`q5Vh z>+-%e)#sMX&lzuBtHq#|-1`$EU7MqAOw_ zFzb-yq~0)CfweM?XLk+<&(_t?7shtijLH_yZf?lSa9TI;roTER*%EP%&$QZeY~lFd z9|HW|SeJh9I*e5!K3<*wl9pkoPFPo!6R5Sf)AdB3N~Ih2IxQpPA~z&l!p~nfa2&cs z@J+UCSA=yrR@K%3C`le$M?Hk4q3Uhe{Z1T+qv>?v zLI8+@7at`=cZC4w#G3gGbnh_Mbi1~g^qKANZEkfmlR@Fw+BPYC1j^&-@?R4oUCZ1} zk8Qa!ORy2jd2U(;k$nE$S6IQPEGWRff;e1a%Z?Vr$mh{zNC?Z7S31LuY%^Yi3K;T0 zDoFB}K8`<4JcfU@H%ZLHWh{mMxHxZ&uIn!aVX$2hlRo>nmnP63sL{DJ{b*ngfGts? zaa;RQTa}OL=MYqC=HR#Tq7<9a%&_LP&S(UGgIcOP|D;x+y|^^6k~hMbsJlbZ{o8&rnP$DI)m4Nt-lJ9B zJ8XPxn&x3HndaFAcNk0-45Wqco6qEHB+W|<5F3=|>i>fHcP32JI#NDl@ru!G zeGZ7`Fu4pohkYmYYqVWvK!XbGjt}cH2?fLKWK=hlyHoM^N~cHA0ICUoh}eeGD4OU= zJ4j8+&rACH%Kz&8OrFlv{W;`g5NOK!TVAH<;Wl4C$0|EeVhC1Os}pFPG;$L+cbk0L z&`Ju9mP~5^H6V2TwU`M*OS)4}HcKNQe@Wp$o$0gb>==~5Y0c9H*UfTwqlGHtS{%R^ zRKh2r^al%$t&AM|cgO0w4n5EBX`F+yyMH1DUh-iyN3o$F=!Vp^(0E zX_;Qa`rZe!LUX}}@~=uBf6?krbl{PAxAWdDnm2Tp>V>mYH#C|+Fds^#U%vTd?asdP zMQDj=u)2Ir%+m{U45}BKyP|6h=bive%iUK?etp)nH0mQBsJigd`g*ev?~zrvGLN9u zJ#?`)k7uWAhtXwvZ3ul7U>vV$>c)c*jXk2wpl`O zV$9k8FI_m1PQiEZDm@q=0BcIZTO$v~zwB2I@<&3SiZi_JE*xGEzJ#J$#urJ>MVoXI z>*VDLG%(eMyO2E~s&6AGSFTy$n*(?&V67vcmFbpD^!`U6-&d)rpoQ2@l94ySk1}ll zNEtlFH?i*9$-5si10%?M-dLmI;-I3)RhMN=_*LKJp?v%e9{pQd9@nX7lj9M=s|Uw~ zr=saP$>h$K+0zrtOCDE6Vqy)B{5#b;v(80PSAYXH^H!!rjcsa7Y$x-H$uVj-xihQ@JBqv>h* z0%40Q6MGWh4mbbGVI)=_Ui$Fegb{aACys*$ntMh29jaFfOQ_gXv{sji>9dx zNoS76>tG*urLI!>U6P3k;HfwY`nA|VjJQ1*wMOIOrH5N8r+&$!N*P&Y4|C|#bNVn! z!AL}j&H zJbUEn8foc9v}fU|Fk*0fm}!HOJMq)$ho)jCf^#$Q!LaD=;e#OP`4`{!MRUlXbc%G6 zl_n~(3S~IUN{D@ibnM8pWL5K`Jp~i|$D$nIt{PrGn^gmA1eR0FylL+KK;4TsvU8BTmynaOTio^ah zj_1MmcvD0PTU>)=7SDwlPH`b!ws*A#A%5bQZs3`6W)PGSlt8J%__ET$O^S93AgAA+ z5LMIwF+cPiIz2F7zkrPX#UC5Q;M}9NIJ~u#!+%?IUG!WxAm407wkZba_zY3BXh&2l z{@cnx^~PMY$;xS6cKzxfg`qADedtv zYq^5}hAx=KH|@-1!(#&3{pv#gxoz-}Gvy0MQ3p_RGLmNjbweMdUS9-IlLQP~+u$I} z?x<(N6f)f4iyusB&Vs~90>XF=HIc{RYX)RcY9aY=)Tqq5m!SqSHC zq6`1{jm|5LPF7~W{*_8=$SDJK1aZ5M1#sXfK{X3;q9kCStt4mV`APFv%JDRow_lax z9V;lXTu#OG-?Rp`Xx)hZosvA3=f}dVsgvkZVwB-eGr@XzCB(WGaRz|J2$p2_O{^%0 ziXyU!vVIVX6Kear2@BTVG;hYQhq9i|CvDNpx z#oQ|>M1Qhd%$q`jh-rC6Zc4qz;NuWk92WRm2eC_NVl?H=Ir+EWtUb|Jmk>F#V0caV z;x=j+GgWg^{jM(S`pJ(?{u~^$nF|{(`}6T{Ja@)Sz0ElMw{NA)wZEGljcNCU)GZ4v z;h0@q{CS39Q!_>G8;p+mloWo|t3p%=#&K z>x6UQ)QOY{k5(tP;p3`~1ZVm9xs`bC7N%b5v+a(8N6i2UYTG`WFE5zgxu>oyIF@bV z?Crnl)9e?wbpSf9@=*As>>RG0vqbZe+qY&l_BDx<8O5wXjnV~XL$ITB&Ub+}OR40V zAZEXw^1UPD)9M3qRPKJh3vVRVmqf!plyuA&&3#lGdxQuYA|3?Nh<1pLb%5wWG1&Qg&>{xpZ zuytk#52Z_t$k(#74pR--3jvm=bqzqtkvGQb4%3{287SxIR_fEBwoX$9!wmdcli|Aw!cqAyIJjm8IejpXlS?+ zR=ng1sQ+UHzn#;_^K#E^ZQP3FpUUs0jarc~gLAw{mgt9O6Fb4XTU{)Y=Wb=i95aSEc@i!B zdpGHdqEseLY843a&Czm;0!fOs7EtA&Qac4-yth*PghvaYF?#UX&GA^Oew8{SiK=lA zdMWveG4mn$LtnRz-%&v>Drb1Uk}11e{+Q$0N`AVaP=wXLR80T38}hoYY413Uyqd|B zxBJW1=F@Q3l`hLOJ*yZQKh&$uWlhyp7&@hC`0}R6y9v&pi611YEQHT<<8^1WX`M6< z{ELQJ_hCq;4pa7_Or|@R9xWLA*eBi1uP$JNrIvzwVWH=Mv2V#Q4jKRonUW-GW&HPU zYx&;#zMXcHYj4w1H^171q0p4aZ#-}`DLdd+Ktc{{sij~bo|V#%)>WO-4{L8rHt@w+ zZQZ(N-p*0HQn+-X7gmyi;-AlNP-PnJbg4ZUflMpk2%ipiZJ!aE`I{%*ZFUoWM5p?Z zGr5YAX~~J>_FjM!*bfJxnQ>RJuP+>!(;W1DbO_2N${&GUPE0ML(xojs` z@+3I-+BQAS2>oGk4Scglqa`#sCaLU=OM3zn$o-$*cIDkvoVcvx;_H;$84p_DuBYxL zhLC-#`8YLUb1fJeEICc?3LtZ7;s3rd__3elqvVH>gjUrtrX0M=%l@>QZANWxc~gvu zO{jzHwfe!qw;-}_{6x0?S}a8>Azs;&++oiop?Uj~5Je7W&hKL(Zv%~)As0j?9+=Sa zviLCF%KBsyqFWHnj1ANdKST#AWbCE9+l!|j342tz|v5-<+&fXZh~FV{^DMEmE( z`s$5g1lrQ{%1U^We9Q5!gYQ*Z9F4i2tHhUeqGYx1j%s4|5b7-Ul)A#(x-FUS7|&fk zA}vFtDTgA~+<9kL0$y#0E8XA8h}>(exfyia;-MF5dzXDET=n`X zL{%y4uem2g%oVt@ZI(F++5kGL#Qs!Y2!P8>dK~7^j(r3W>btdeMqK5>e*+*gzvGcS z{xTS6*wbm8Zeh$f!Q?B-$d~Zb;!&4J0_c08xRRd5+ccK0IUY22jxSmB}6bEITbWbt#Z$3NP0$!WNI{0{5iRD}Qh2yUCO8Z|1Z zYy8jh8;RRnsnKy|WYl!EAG^wEbXGND;E8?@O(=@g^H_R0a05J%69&+u?#W`mb_}Kz+{$9}<4*&H zA0C;TZBlHV?NBc%v<^s2@+TuH%|?Dk!Ht`k<0xC$k}SF^Iawfvf;f*8$6juqDdaml zH)v=jXhh}l$2}n2jNI(psSFtV93Z}HqOH4auT=Rc_5yMnA(HEV=F1egp4t*wBlSI* z8JlW&cFRC5Q}Q~epV=UOG3=#~9(IS7Nt)DbJ zX=LAbQJy|EKRyT}ub44*J|$x#iI-w)i5$FiUy0m~?!>ql_Ewrskxu2r>#6oXI6-H7 z>a>@hPsw|^wJTy#FWFxsDQ_B&(7p32xL{TS<+rl36$}cS#oe?ZmxHN zsJ1YrZBvL?gk0hgyR3$q9rh%#-I~#BXXV(0=#9dhihh({qokoRK3q@y-jOt6dM2^H z)y^hYek>heJM>{FSSE=fXmc=(s-peKSX*=1*OgDJo;V*?+ezGAsD&3mnH7l;^wF)9 zVR8E&w3(o4T!#1Edrdg;M){=r<+FTcTm38wMbsE6?s? zvf<`j?9CKgjE&W5zld{;$`4CeO^u`zycNvby;mX^hM#N=lO7Q!7tpgTMsoS!!X%$W z-E~AYh!nL{D7O!Zx!6vb25-h6SOxk@i+gmV^l*%f}Sno!cY^7c<$<~vERRrfnui}k@|Jwe9 zU<}Ks5Et%*vKV8zDc5n)cYxKetxV$Cmw60DzOLnpi_~1{8SGe7iLk2j7cVZnVmP;A zwMSD!%V+ldah58wd|KmiYkEeQ`GP=AEJ#&T3?S3{ag)5SbWVyLbKKe%2|W#~@}ly8 zBUjbs67c7%6nG8HpYSY(2a*CETi*@hRd}1`U+JC75y zwOz~#-Mf01DkC>N_y_lTUCXgs{FQgw@^T{vSx+Ea-^=b|SX2ftMAq^U2MGj&Vg4A* zf~lsC$6Tz>3LShk86Yi8aafYJueLG24Uw-s@VtwD4u1$d00BuBSoQa)GC0Ps&CU*1 z9-1EH*=esAtUuE%94177>_U9(~R_6FyH}Du>WP` zTHYdRREDYKt+cWdlb5D1Y~X08n*_wF2l&2&EAh}w*F>&-3g&%xWHKpF-#{eT)xhPe z*9l}*d8)~je9wq$3YO(k)vC#*7?hvSdwLW+giNQ010V8wgj0O}aFHe8dwI%tJ=@7O zR|TsI7R$D?vsJsU@9I`M)c__w%MadqxOSZUG^2$x+|JRVfByGgOijf<$Y=-qOR$}wu zgTztxBSHplSMul@2wwDtNcct-(%M($GWt)|!nC899#r1)Pj=MNK<^)Uw!;VQsH_fv3y~2Z8^6-GI%{HS)I7JyjIxvXMz`+8n>aXFmdhb zg_=LuRG#xQ({*JJl2z>5s}5Vna*1f&u{XO;F7bvLpEI2Be~nj|(K*jBut=FZ&E82> z66${pNf`=Q`X0Ze{|(Hui}k59DJ{mZg=pk#FYiKqb((72{FuW6@s+C6OMlU5wa*PMqiQ z=`E~AbN$!6g968R!ii`C-AyCBuToSny|#IoMJDR0F?~Z5Ub=r8UG>p=G;M+PL!W2h z-~o^H(~%rH4>;vOMZU66K!P=)DMz3CW#-PpN@UqmdaSEQgio4@Sb{#-K4+O&83BxV zjq_Nk#G$7fH&auS)LRekMr{%@)X$~&|0tft-ZmQ#rbZ6R2E26r)hm-tx96MHHOHDI z4{CyB8LqY-#vV&*&l?cQKNl^$tc%xIzN%h+BA+3=VYV5iXqvH%>l}BT!ygXy>nk^E z0UkspLbSZ)??CCj-RrMzb@UAXlwx4s*Fz>RY+|1!d-gF)4XrRA(Oz!88+>|fEh{^} zA=`f55OCT!rWzy-S-87?xtYPjPf$~8T4=+5x0eM;CNRhi89bgSdld#y}-_X?*g_3{h5!AI)q~TAcQwRsCuW)nKq#;xmHXqC6!+tj)b1-Pu zsVs&7K+Ek^)hqb%Y<70P_V~Wbu1k2S;U%~)?>+Rp(~9%L8J{(eth58L`nDti&PwhP zkC?!@b|jPY{z&b?Z=N!(XpalqY}?36+be#0mBZ=?=_+S8`i*)SGn@^AdpDoXRm5$~v_g!kq>`PfX&OCr9(R1^-4em3I+= zDmc7D|3qB8;G;i|mE<$1Ski3$3I3RxeV8#BNQiPVb)CfmUgcrE*oN{9)cepgT!ba7 zv0WbPY)!%TcJyHH1+PLcCJy#X8ZY0fg~zwUM7DnbLOuu4E>N^ILFPGXOVI~A7uGyn zjRMP4?`TDD=zk5aV4fZP@h%~GB6LFGs&{+)SLVETIXKcwuI#l%%;-IGd?hBq#j;hZ zCwH|_GnG6*JnoS>f0e^O^27jcZ}F|EspkWxpWE7C$)hcg^$)yZU3t{-^FRMMnKP4K z=;FD)g*@@wlo1SfsWOERDc@K`#%c>wZZ`iqYgH-i5R5MV7hJ|cpl7=y`6>qS2!Gll zGRDhW_H6i=CO0#cp&-WGC#GB@`s{$c$AKO%crYrvo~#uaG_ zyaCX_*Ux`A!;4Mnx+J#&()>`honx$r-+;&{QDI*xgU^tR3*B*5H4CK1C5nkrtL2}} z#QLt?%#!A9Gm};VNa$!&Z8imcB;QefYE0CnTCd(kwP^@3q1z%8#Y<-f^|>NLnlBf% zI8|`2>-mw-?LhW!sM_dCqiWQn70WUM$0YWHgxS~fhe;Apx!PZ}@{ArX{6n4<&IVPa zp0kve8K?{lOG~KLA^^FNU(&J}1y?tbx*WC&|=ktbI=i0Qe?VeIz^xubnAJ&(}7uc0+*uV7( zkbHCR7@j;Q%(TvAZ$o@P8Xjy$`{5~kmSb1AD}EB|S_D=EXc*gT9vHh1ss*~9o$}wd z$xO^elICVsbrA}7%7Rm;6?@cEuvxJ+!R~L@xA<~Vj7hbr;+3qKpf$5u7~}e0%C9F6 zeS7DotAxw^CB}TL`S^mN_1C0* zQZnEIo$nra9UsKFJA$m&p4Gg^cV4Z5GhsA5#CmS?MM^u*AZ zhO%l@>-Fk83i3wF4AKgTWu3WRnpoG2`-~9-;}bNsd9p6}PPXNjLB$Q~&9MIKY`a3Q zL(fhTDZ&!OyZ5Yg8|LKEt`Y6eQuQ^2hkBweY?qwwtRpRd<^;xZpt0T`-SB>)P+pow znY2=z(-^$&T-np4$AvcPXaAs9B3sbgB(x<$3e%6{Ilg3*_K7D)_Mj-@xMVKk4j&b`%=ot zO!qo}i|wx3u=pY!$b&kp^XUvdzgaK=ocgZ3cwlaTNKJl%1GeaGt6SvMasCe##3!WR zQ3^482QBhG_;U|1?fI>NqgF}UeQy6XcFzs*-S0L!)#1j2ry6HO?};RRVlloAGz&{1 z?cMpE-gGJjaAu_EvphR^NcaFt4+Lt#Z&_yTY3aGyRq4)xLm{abYE`;Ym)@Pfn!#eg z;Fifauf-+xTRJgE`!mak82+KU8JHNJkr_ja^H#(U`~|ywwJ!e~o~i~(F_o52zY@<) zT8h5km-h2ykn`?+N&emMmvp$-1Sn%M5b_Cmed1~vKZKtU+##?elW(py#Ni` z@9m0l|B-w9@1mP9)|C$I01S_q=*Y>q3VK~nSq*mAq0kF!ej!1wUn!Aj@*M4E7ed zmy74&hc5IBcM5`fhq#jPzmQ&xz$y<`c4+3?vYo$N090zbyjbRG$)l19YRkuFErRVLh3$#L%t3)? z44uCSZ6KA6@^52#>PBk3>ue#QG z4u>nA3t1XF%C$H@c)S{TVTLhGsF?whKL@4KM9I z6Naoq^C>t$6-Wons>6{@+nj2v3|-D`hGa9b0m^ew*vIKfgrn#Yf%*)J!kpobJFLYe zLw9#C(N=sT~}*Gxi#P9}G7#$V!m<$gQ6$*~9uZn)Po#1flL@h$ONHr^k-kh^y0 z{yTWRc(&I_`KqbXhH4`TRN<&%8o5!nY?RhEbw-IOX%;c4nWe6+Ops9&Ci`)ONje|(B8#n*Br`Z%&vtxF+=!`#|PQ@88lAF9Mq|8!f@;C z5Me(|h%9P|byq?#5IGe|)VEOv_kqmOxKnq1>G|tjFZbi|&4_@6=$M}?Q3=Bj`_f=B zNA#w%R>R>E;JSd{dG$eTd{ATgYl{qitC5D`aP^rMs+ATaovJtTT3nA`A2&;g4b76> z*ine%c@$^_@N7qJ@pYS}@=d$pU%GbZylPUjZuib(*s2K@7?_$FR-zSP&=Bsr@cnHybYQDMBXpkW5W|^#>%cVz6&`dPylnNJTpA`k?xm4@ z!?%@SLOfwy-g83~A2`4az@fh;?eu9$QxV+|Ei88gyaK^@S?vUxT(hqgdT)UnVKR?>u>^dzl zlC0-H2*DnHIJ~;B_U^g3+FXkDBI1Kv%~|Fg4^mdrDg)D-0F;_>qFg&P?7q^jWJL=G z(fV^?b$Nv#lF5l-vmz5$J?@i8Vo-Yd=QNhyQgW+B^#kdY^y=~*={$ykeFlG<4fd>C z$>ldQP3=+Sj9HlR{9+k=RvBs{=ewEahst4>AQY_wh+pgy3 zAZ!!O@EbSG~uuYt4$kv;3P>77cpQmVPlCRahbH)wH-!G z-y5)05!nV2m%Q)A4<$M2WhZ-XKGgGZUsXe64RV?U-J?R zB$5q{Lz?|BqR7hMCkUrqq`TYrg0y%`{6&E6IfJgG1Yt?l4^%;L+^exC+R&EAkd-}% zV6@?hSC|XzVQG#0SCy?(wZX)>jIL991i&M#yU^eCPzs{}%9Lo0VPtuFt zEK#V0C=wGX#e@+eSwqQA2wAffV;M;iF+?eZ>`RuiCCij8TO-&FQt}FL)&iVb$InTMD<@P)+_aj0YgQA7fWe(;)Ze8u}tQo^ctHuuQYb-zS z-LjN;_OrN9YY;&zUX9i3N#PO4-neV*j~gWKbj4K@11cRp9vnXqA&`hnk?B3PH#PYN zT@Sk)&6H)x@+J|n^Z9Q4$IYS$`r4;jSxGX6Qf{h&gm>VA-diT{AZIzpr?Y=1guKr_ zMXyK^dvYm$Mp#X=_1=7smIUBo1sqERffEFqYCR< zSDL}6l0PC_rJU0>&1|ZYt82dcgBPfWCbI-ARx5Sll^>M8?GG)i^glc`m8LFz&zpM# zRDfuiD%L$zMmu;Zs`7G`Qwx%IA9rjA_6i4MXA+%81l(QZiVd^U-Z^XPm#vZu7NoT{JP+hL;N&W z`eZ(?6g(`JrK_5JnTzp|d@VSy-a`_O&0SpkR6VfUe^er`V*Onl^7RF(SKt8)M-ysw zV_z7yk8TA6?#s)C1c=LkJBIPE>_}a zlzOB-&(j`G%Hxo~<1y&bf^F;I!xU;xGN1;}(5Zej_57sU@G@A&R}Ia#cF*y1Nf@>f z`UY^@jPMXILmSHo&78nSbAbregByZ5%5H}|dDd@#05v52qEw9M#i|#W?u;b#V1~}i zK$*clyhK-y)pj0=Ut-+6O0rtcYQjtiN~~+;DRh#+e5W^y5+_f#our&CE~-UR;>NJF zbL&^twI`0uH~GlnROVXgJ$Q?W4y}|9UWDvoLw#2r$3yhDB)=;auK0)KN>c0fX03@g z(IM-j=BmV`nJ_DXU8=Uw$I*tb;1IOVHk!|jRIVEyJ+5+1fPuWXN?0l&FJ6Oheu$}q9+K|IIjH^|A}KCFW3?{ z<}<^x==@5=(mA|t$O>ymekf%vmpV4jD7M7je2GW=(M|WNI|nFr)5(lSisqhwvpCm9 zpr-1J(mqDwKRiF0BYB!okb7diq4E*s2wxXCT?vpoGtysjJK{Qj;nYU~?{{h!*j*v$Yv$G?u5_^c^!k&j%h`r1f8`={hnu+z_t6bR0W1NvR-ESHSwi$B*YO z#Xcoe1Uo2@b!ErbRZs6l@iKhvoVoJU*k0B4V|N)b5nwMDOMj^s*GS2ge!|Dyoc(a~ zF13r!{%0;rfdO)>18(+7r6v|m%6+Od)62NdD499^@QW5;lPmeb-g{$gmEatYe8Tsw zX{^fI<%)p2ZWY^GumLxxotgFL%3aISvgndbY&44((#*-hK?!8ywHL_wX2&KWb+v@T z&HDkBZ#Ik*$h5+ou|1fTITjDHa3Npl{Vb(xANOtSO|aD>EakLdeL~lR_ zq=;cZ{el5!{IsR#(krn$d+b$@JwE8bw-6`kY-gT1@Mz3o+zRP##f+;Vr1hJI1kJ?7 ziK2?DX9^pZ*KgaZavuTkl}%-MDD^&?^8EyGD`FEHCt1xB{wC(2>(IOCTk?x>I0>=h zWP4EPs|nPaAhg=Y&+&^t9OV#xT)kc;+VXWunbz-&V(T%(zFo5~(8HZMQ6 z;uOjK4COUuj}Ch>FIg{b*uU(ifcJt(o|vJPOo!aQvb zDPYAk^+-|a^cO-U+R}!~x(6I8xE_%Ew-kbf!}-gZ*YKUo4xAx}Gm?u12D$Oql!8lA zN1BGO6b(p`Gx`bCpNqxP8-@9uRjsscn%2&~J+Pk;Gu(3Fb2*!9yHkrr!KMe!R>|J( zEHNXNf(F4FCnRaQ^S_9eHO3t3VezG>$IZ+c9Cqhd^dgkpS7txWzgRHRpu=}S5O5vH zQnE03CGd=rozZRqc4d%RX@_VID zejZW`bam>9<(CH^p7dB@L<;Z~rHCyhEck#U(bGm#cRtaN{DJc=-?a6(au@}j+CQ#) z0ermtz|BvLBwYvcbFy=|{%5tRkHVuM7pC>Y@knu&M{E2#Qxr_LgU3znVamtkAxS#A zkEO~9-fS%fOO)3WkB3?F!A>zyHEY`mNuq-3Vxz4=nHw|*c+rS%=v&zcWa+7iPiRy5 z$NeUM@E66FVV!3cJ`$)!bH$YGyY{tTtqw66p~#c5jh*0mR46OOx$Ql+`{@nNWu3!C zQA^}&8bEawA?fu*u^+j$BzAh-xZBNZm{K(sEk#9l`Ork{94~Hs+!!kL_9sfls5DgU(aLrY<`nlS@S=8F)v)d#{O&(uF}GWO7}~Yw%*RWk3j#5D=pSw7#(B#6chS^ z)0%0GaZC|ifWu?viFL?pLmR90RRrqSg`i4}^gjNC-yAa%G#j2y*}uO}kN8XxCEs_; zvBnrr;vbFF57)13!W7P$0R#KGqqM=!n7YkoyJ-(j#WL>o>`wf%*$0Yx^4_|YMH>u8 zj;2n6oj#gKDJGE0Hc%=z-ey_PQ7<(sqVUXTe#EpU9(cq?+8dcVvpj8Lun{xNnyY_f zLkbVP=wPEcesN-~yI+_sm@K<`|HTJPXV|ex9(>@!fI}g*C0A_Ee0z?@P|gTQvb%7F z8loc&F_te>_s1<83}dZ_{W)EeRFnlK&g|2XUasbfGE#9|LJg1WeW(Lz0}gSQ2vqo5 zbMzBrx^`@cx1~!HJ0t4zXzaG#{0b{L%Cb(1imLsz-la6J2w)-_g8vFhN1D|kPeBze ztiNe7f;SjcG*|n4MhUU^$u^`;5dY4osg6yGvF3-bJXq7#b?mnQ-sA*qa_6(2ih`xs zw-3QuZ+;78m1MDKbmGai+nkO`geo1;5_@`M{a0e!^uuF~Vj@P(9Uaw5dxwJe*jU@w z4`;a6hb`?t1TuIYl$v6bOCvQS*@;p;7mAF7n+FBMgTZl=E0$jW=2}hpi@j$hyDpTl zs|9|rU0j(Imjp%U8&qIhYIo7A*wmQ??{ZuJrh>exT73@|8^1?i-T1J<_qJqPt)KPe z$$TSl2JS`k0i*;Tq(mR*^I+5BSp(cDQJ(uH9M?Q7M6<5hP!;l(ot9z|R+D}@b4TG8 zxSP%r)XrIi`4Z)mr6jx<7rK7#CgO>0v5Eb9WJcjKbYSO-jVJ1&Ro0kS9XKt_8%vcx zmurHfc+U{f=H3OJ&U7TX$%9IJPGLmIowv82t$y-YAs<-#!!I`{-;%i^J$XXm4_A^+ zU}SnL?tzsyh>9YJN;1XQCFDcu0m-V`6j}X?Z0Su-<{em2470&9aaL=Ms@UrlKfCUd zEM7bNQcfRNZzxzo%E8!^ick6U3M*Vv<+Zt%Iw=nKqAmr_Px@xj4cmhFOudmdG&?bH zl2KUHZfMC`KRzU=aP0*cw71c;7T9&MMI**LQ;}`onFY3Vz1W&ap6uK&Q8Hwpx4yJQ z^CWX*$ii2ijs2rZmaZtl`M8FzC>hQozQ=chd(+wd!5_}5Bj>+PUlP!bRuEH&3kN}J z06}sZkrQCQKV!$KJ6r|6{QK%uuhY$4W~Me&61~vDmM6z`Ptalc9nYEjrJUr7wwzCz zaLUjky(Fj3fhP?(K_~NGVL9VS26t3!SgzQeO>lB^Fgeu0f~&l%GSI+UV22m`t#gO6 z?|?B5sGLOKEn92bcGW43;^WAcykrK}C=(lI|J_6aH;qXR)=ec$PW|C77o{&4^@oWj zHdJfSiJ^M-uDJzusam-}gQ!LOr^NOGIKB^>H=P`J%oJU5mk-lo>HpI~3Pr4S`|yrH z?F3~~lkpxxDy7Mj@u*W~vcwx5z6M}Srcpn9D(e15PEA-#IjO!7(mYez5k9F>&IK!_SdV7v}5zBWV4cw{xYnm8Q!f(5NSR@n`i z>R*bB9!s@Hy7Ro(A&1V1G22Z)Fx(5gU&vSTY^jnOy*NDU!SVhK=hM*P{flE-bzV+T zT6Zl}*F{zJarV!cQkQgJX~v~C&8wdWtI&CH>2bDubErnWWK5KiyAublp`FJiW_l3P zui?Sb>bm$h(o=htCB04X;4xr##mV?nB7W+&5W|fVQ_tL_ZCbWn6%TdA23q$PPks@R z&V>b=-_^{Fp{={I?(;H?`$wyCCm!D5V6Xj90M#o}3F$=|dhF|@DrMt9VF!#f038W(S^y2XCf%V24gvv9S_IdT*i8Qky+BasM;cQ zrc$m1Uk}M)$ZxOMXHgVm=AK#7FJ7d4+bwijA5Ayo3>T)oYUR0oadY4XomG4!=hhQh@u`Q!8w%CCcDrp`VFK6(8Xc``GHVeuqvuidCqJMMNZ&b?eFj^wRiIG>yT zMx;&M3i5Y*x5mp#OOMrSjBIJ|c}pfj{Vl0I75yM{i#0)7HYBT5`(>*;QM*b?38!MT zVp!)!^B{K{Ju~VK3WqM8HNclyItt$jtqYCez=wWY0=Z*B~15Hu}^)~uS4d%JZ_49 z(q!LybHa`jdrJz~?j3QmX>rQuf>@J;34?c3q0yGk%xujSGP+JTPg0!wS{%pMUSgo^ zgmefLr?#1)*5#FR-Xg7L+j+pP=?&%g%KXW*tx(-kGBk>5?Cbd8h+rK&VczfBzT@60 zZ+3W@4tkz5LlRI>{Z{4U_HcDZn)T6uV(NSzEcN_Kjnib2bX7uwq%ik!@Xq)C`gd7z zNsEc_XjzQs0B77l;E`BXrbPV1{)NYNHTm95u-51`Vy}1Q)v38OA2ECO@If)B11+J8+ubqnaPhb(TbMYxDQn z1yea)$9QD6b5Rv~`@8O})k0c!UlBy7O(4>Uhzu0shD3bE_V_2;;SQe+1|L0!tqw4j z?uyrA_&?3OA@Ni6hg#6_LoKlLQ!P;U%j8@|q-Ese{o1aGHYdd8gNOnSnaea94ckBm zCWFXfdGn7Soia!ojrMJSo7Lvz6P1b5dKtWBAdLu{{|9UT0?lm44xq|0=0(Q#9~yoV zxFPYA02qS_$o!Fss(hLC!{}D$mH_ac{0QAIL}1JS{!eBF9DwRiK7i_v$v+7Gr^!GX zDDo9`if-TMbmM^@15;9DbAjw#Jq8)^>qYAM7r}2o0o;&)eaxhS96hbf2@w6j{d2~@ zkW9?j{(~`?{zDSD0EF|0DnR^KRT3E!DtUz85p4NvOG6UjKn9A*32B*qdFK{Mm^E+% zi1m+-pR6_7@3gIJzzzeH|9$4RAI{`vc>V(+K%X|Tg)ohH^*Ye-i|U^OTMqqAdvPOp zp)6#P`)ol>kkFj`}8&`T7Q6)4J;r`+`#T>3Bqg()#pWJ1C4I2p>Wpy7<&Hszd zEh7F^HWJRmhY%%OI6*WZoZlgQ_Y{c6ch~$y{y)=0b+ci-6MzEo-@Ny`0wO3#kiLL_ zb>=TH{^kjYuKcet`0nL@LceU-;BM%I2;KE75um}pmm>*)9aPBtH9!6`SHPi*fP4Ya zLg4^rnLx}y=s}uM*ZPYkS4#3f;73*unkHRkbM7z z(k(H->=!W*7YGY*>t9&DTLW={v3$2?kdwjUjkF9F-@%RE;_+SfJC6tdHPYwucG`f4 z!@uHs?q(yQNGpL57x@(;a2Nj{>gj-f`)^pj^Qa%QLl*9ah%szQ{rBL0@rPeho>xrS zeJDzD{}P8jZ8$8TLj3Aq2X9+8kj97sjeibWgy7|06XQ{j4%`$f&W2UFIqAc)+{FBh zg?HmAoQR5u%8aHA3)PW>2;awf8EQIaXlGD9Y#Ov+d=-(=a4?eOj>F*%-7m2PD$2YR zj;VF{z}5tf>zPpeL)D(WgiT>Avf&BoPhM?JFucE|VaL97c!GlYR#tR{t-}+{l`|j> z?Q>U;ocQ|DKXGGah9 z@=vvYb3&M?9fDP~?sRi4piTrn${6ZgP5cH) z`Z&|Wk_ISmj08f4Llrh=phbd=y?Zp22ar@<`ZiHoR!@)U(Hw`nQG4N8FRyG)Z~)CF zcM~8?h{9vHAVb{eUciQ|cN##3E<~K@Hi@Fig$UFzTaLLvZu;X@lo9K>1O7BHTW91!kH?9D$ortS~9t<80drDELIAGL9O(c600d`2O+f$vv5yP-FqK~#7F?VZ1K z8%LJM-y1+@%G#>M+FpAVnW`o4Nbi;iWtR!eHnW`VMWV9HBxRS0%r2Ak7l_I(6B%A4 zD7(xpP8tI` zdHy`?5l{yN>>KPGsz|ZXCE-ZDiK)^X8v1-3TH^jQySG$v&`|AtmZg`gi-nX%J z7Zy5SAmAKW`E$ENgXn!GzMm+=lnn~af|8xilo%}x&loDj(xH!snajcMPvf9w#*g3!jy z56`}%yzuW&oq*jr?(5NQGQ3ToIb=y8%A^_qcYvnI*yz@@$>%af^f0AO< zy3oTc^Ar29O#q}Pv{~v8w7S$P1? zQff=eP!$79vdX^NQdNa`7i7(nwZwn5$*pfSCAZWFcxCPCJ!1ZM0w7=h^2XcmkWFqq zBL%1s@KC(l1VABhM~jHP7qB}fV*WP*pip#(*lPi=zPItnzL5V)0F(lE-hBHH%T~nu zQF|k(yMz$IFjem(P zZv+hS0v-4zVlMcs(-OzD>y&c}9|4+#KWoN&OKN1ueH zw&^MLGK1VIk}etqfIeEXcHJ5-kS9h#vP(DU5qmv$DP+ z0`5?m6ci8VE?}R|d;2f>cWKV+&d0XU9qVqt4|lr=xXS@OKKqXL(!5_Q>+L%>IJ!?I zQq=iy?gAd(?e$>T81GxRW}&vBZZle<8`hNHgH_HLYi*6;$82ct`1xX%Yq@Phq94pR zR5pQmaQw+fcPU456|hf7MoHY~IIOO_+9$|;|JegjZSAj?77T6xSY?;WP*jM0y zua$A}T83rWbL9K6LkWostx)Zo5?V1G*yr`86)Y5i%er5pWqTgJ%}&CX^#u1QL$Vj}`o52uyou~H@imYvSm zIYusH3u=jEqRB^$xt&!ryi5cv)|UYA5KoJ1T3KmkVFCMWeF5+l(M%Rrcwqs<`T~%S zGhRFvUP!>Oz5t|$$=qD@qQgQ0hV=ztAr{U^rxvjD-;D?NE$3ixsi4+)e_z{Xq!+Qm zsRcY}P)EaM_JHZP1Zs)gNFx7P$O@--p(7pcv!VEf_n=x__)bT+6gKH^t)&vM+_KTq zN`~P=*OsWMV~vWIT>GgMq!KV^c+WL&5$zDD1#*#J8ts!#T1njK*aFt-K0EOm-Yly% zD<}uogW9mlO*@Gj9p8mk>OMyUz63nWo0UQw2OPc=m<{g#1#B8h&VTjwIs%^I zTF@$3M`u$)+KB?@hMKvmJpy1sG_0c_NMeDFlHuJA!uc;)7$*LbJZG9FrwLev3*GF) z0)xeg$bUmHO_RZtFRBpm=_xEQSR7{m*HOUq+lgPF^hJAc{4OZ~C6pi&j0y|9Jn8F+ z2YdriH8@b<$+3y=LbK8-gaA|(P7(tH0CX@p24)>eECA|)p(GYq$uSZDS)ioup?WTK zoY^q|R2kI*o>t%uKwUr*3)CJhm4}m1E#Q6=$6a7?v{W8WLbZU+04_9G94(cHlTa<- zX;-WONQB~J)5!u>P~0tOx%LRWXPNwGq9!MoQYt9!7MMt_>jOMOK@y9T2v`f&0{@Nx zSO6{k-=;CGlv0TWR?@o~c#D?)Z-%%x>Fd)$0j(KwXsEGpB&?9IJ)jKFC7cD0lk)dxVeSNY8RuTgXQ3L^lh3Jq1rfG7T zfP16_>jGUT08+5B*6xrJlDW{4A{W|F8;LBC3PlMllSIH5jINQL&ELR{25Hday-h2w znkeAYC0+fN&46wY07+pT@vm_7NjTA{P86_~flnh42ZN-z_*c(8;Hd_6YAL0bYAgrh zV2}{Iz7=_GJT;`9DquFOYW8mPB5e@>F$u`LPfD0I2RoSYBvpwlQuKy^auN60C>mZc zE1aDr;2!Csv-&69H%mY{T~dZI$VP)07(Ll%q5pp=1T2|oEuA@j z!kF7gW`S8)FKtVk`#ft3=j;ppMx7OIHD9MY1i&;RbB`2ZXm&Drj(~M#q6Id};u}yH z+N`gGXD5^Awbbd7GUN@CH;Mpw6=l}f5zN-$Oab?ov>hd#Vua?)D}g1FUjP%-CdznD(Sy{V!PowpXqrEt7WxJ%4 zR-ery0=33%;>_EmlkU84m@8n71s!8_R@U2arEAQ9%~Mj!;AI8^c5$#?D{L|MP-0n6 zR@SfH*XTN*!`*rDuMlrCgVs3soR&>sJV92vUaYQPy=_IH+56g$^G$I_t8_^*vI{pa znkNKmfp}a-Z`|wPAfD!!VzTny#y5&O7)&NG4~{?i=q`cEB1tQWd-b}`=k?D=hX+^U zd~fXGW;Uh$n6wk|ot5{l>N^hvv8aN09n9Uh-x^!MY-o?FfZ=V3xO!AZycQEsY-1VQ zg%&E|Mvs6yT^ZadgH2RcLA*)aXCcvi;7YjBBgCCv-}n&KTDtk;di#bk)v&yd1n#qt zNWhhGqkpC?ZWlzX6Dg5ovZo7G@d_!K`z$1Kp@r4;jV~&*+l|9!`}ot3b_jTnY`DWR z*$!2Rr0%nj$N~$Ma-+wQoAEXkW|GTa17UrH{hM4Pr_XSrQwc;0&~xpsyFWE z{o}(haaYyE7TA%()N4cHd=r^R67!=)Pw|LwSKr%sBpy-q#YEdjxVpTxA-#?in4b32Bm7Bbt7iYYK571jz0~zlRRa0&APV*3V9r7m6^IG;K#=whg|}( zaYsQ7x?wj(nQ7Ibnj&lH>?L1|bN6@3^V74k*51z83U`kW4>lzrGn_V%xvn@X`x|Q0AhLqxj{OpvERfhN-aYy>yhSNlNWjht|6snMELotS zLaea~%zYn@8DwX56CMM8Cfx<4J!slpRwFLVX;8;R(FO!Nou=U{i{w-m60oqk-rhBo z@ic@5MC|#k6tT)y#3tk*I512-&B7L|y0k>CGp05NHo<7jhRqna?W$U?>RD};ENXq- z-$4s9ENlCMvL-MO`ridRX%@HAt7UurmwZcunB@WiODQ8nx)6(6U!g$@^3_)_PTu_e zWl4c&>mnKc=f(y4>+ddK{_>mudGS2SQ{{Jh`>o6S*22lbxc7@p+->`2{>$-k_<|Jh z%~vm;zwzefi}n}q5J-hs-_H)ih0Br`w!lJeR(J?A?KUFbNxECP-bltg_1aR{E>|93nl#jp2ooFm=NfD@Bx< zQOQiet^s_MuTVxJPTJ#n@S22YNyU_q>K-a<*! zfQ4a!f0yz`n$pS5l?3>cbm8jVXo3}<1MeL@&;D+C<^mR)1-Yv{FprYN!@juE zY?3uD)48@C))tT#b{PfD3h32g$EAT1&iLhKQxp2vrp2!{GBF z;14KAaucv1?rK3r6rD7Et4b1amnw>E+NjL>8Cm;z-wV%Gz(P?)6ecqF(+u$*ig>fA zg%<=>U*M{T!Doi7r@>3wrku%Lzy-R}t>){LY9hOM3JoXXypu58t$L>px#LWLWIYve zH8ght3x#EVjk%r13Ja20Iywxu953aIRVBU;QX5kYXCb z^W7{i2#h*kT8nZsX&YO+0rVoGeHjMVKdo0Q9e3HEl9jqv3+@)VQKxS!o92gESK7_B z$@PA&>vFiTfQLKiu6($LY)h_HjC{20uJ`UQej?GAL(3DMeMh}I3HDWjKJ`qYtI8kF z+agn;g+hf|U}0sgE&ZIIQl2!dyNWiirI2@X2cIzm{^0Y^itQC%NDMrVi-+?*x*25K za2|lU*toZ7@d||tSa3%-`Q8lbB(2T@AT`W;c~)D^q7(rOx!(+e6$S+$Yq zr3qNhha348P;^$-+o{fl0f@tBmRFfc%hCiaxJ<9qisp6=&D@784RXV--LfyHlqz6B zDw8e~m+i|$VI#Ao#7Q*^!~ zn&_v$=amOQ4RTcEVa)p~-X*anQC0^@P*Xh2Hcvx^fCVSwk{hyvI>2|eh*wY}U}4yh zeG?-*K;}sAGQ+pD&1+UAU_lxJG$X!-{=*JlY`0nS2;T`QAMAZve zkmMHPVh{%x?*@ELTe4~zl@PEXZqV6le665iYN?RwECS`hym$7JuT^QhO{H3JOP?+K z>CWm}JCw?;VMP@vkiL(vxrA576=zh!>W)(x3p|b-2NW}`4EPVbW5=qv%&$_}AsEBV z;+D0>U0CB9GP1fA74C>iTHtYDjq6CYt?oFr7()eXToYC| z4_B1&JzuGlc!gRCc!U&xWIo6nlmyGLyv-^UWu&2&0v5!rmTn8&=WD2`)`u(FvBH&M z+HT@yO{uMbM;sl6q105%RWej^DPVZ*PeP$O3wK2A1w3LDA4ABVGE7iOoU8HLUtZKA z3!Q}F;@Gtr>n+1{)22r{1WMz)!Js6lXt$0r?mQsiDU5`?vexb})0QE#aC=*hs&Co* zOB6PLpbU`Y6v+&tE`h0d-&WQaq+RNOY1>-l>uJxCCG%Z}2J$QG8&B=04khK>O%~xk zM0^_$2sj0)+-pUh4i`nd7Gm=>{xdkVqTTPG(gV23$$)?tK& zNi|~SpW1gQF!!f^gSEEC@MAW#2Wy)i2sk6e>R78Rjo{Bazq=nlQEO zPIhAR2|W|hV{2_gSX%%900000000000000000000;FtVA#ht2v8mJ-W00000NkvXX Hu0mjfZ$b4` diff --git a/web/public/GithubDarkMode.png b/web/public/GithubDarkMode.png deleted file mode 100644 index 50b81752278d084ba9d449fff25f4051df162b0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4837 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 diff --git a/web/public/Gitlab.png b/web/public/Gitlab.png deleted file mode 100644 index b464c02221f10ceaa6752fe459a7ba890883031b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19675 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 diff --git a/web/public/Gmail.png b/web/public/Gmail.png deleted file mode 100644 index ece92556c2fb7e372d9e0c447580ea084cd7f755..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7186 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 INWy{t0EleDIRF3v diff --git a/web/public/Gong.png b/web/public/Gong.png deleted file mode 100644 index da6d2ed4b21676c35c34bbc0bcb2ae8e54e4af99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29413 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 F`G2Uk&Qt&Z diff --git a/web/public/Google.webp b/web/public/Google.webp deleted file mode 100644 index 7b903159b0c47a6c56d9d23773fd2c7510282ebd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6568 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