From db896255d625e2418787f1b53f0dac091c57df1e Mon Sep 17 00:00:00 2001 From: John Wang Date: Mon, 15 May 2023 08:51:32 +0800 Subject: [PATCH] Initial commit --- .github/workflows/build-api-image.sh | 61 + .github/workflows/build-api-image.yml | 43 + .github/workflows/build-web-image.sh | 60 + .github/workflows/build-web-image.yml | 43 + .../workflows/check_no_chinese_comments.py | 35 + .../workflows/check_no_chinese_comments.yml | 31 + .github/workflows/flake8.yml | 19 + .gitignore | 149 +++ AUTHORS | 6 + CONTRIBUTING.md | 56 + CONTRIBUTING_CN.md | 53 + LICENSE | 46 + README.md | 115 ++ README_CN.md | 114 ++ api/.dockerignore | 2 + api/.env.example | 85 ++ api/Dockerfile | 28 + api/README.md | 35 + api/app.py | 222 ++++ api/commands.py | 160 +++ api/config.py | 200 ++++ api/constants/__init__.py | 0 api/constants/model_template.py | 322 +++++ api/controllers/__init__.py | 4 + api/controllers/console/__init__.py | 20 + api/controllers/console/apikey.py | 175 +++ api/controllers/console/app/__init__.py | 22 + api/controllers/console/app/app.py | 518 ++++++++ api/controllers/console/app/completion.py | 206 ++++ api/controllers/console/app/conversation.py | 384 ++++++ api/controllers/console/app/error.py | 49 + api/controllers/console/app/explore.py | 209 ++++ api/controllers/console/app/message.py | 361 ++++++ api/controllers/console/app/model_config.py | 65 + api/controllers/console/app/site.py | 114 ++ api/controllers/console/app/statistic.py | 202 ++++ api/controllers/console/auth/login.py | 109 ++ api/controllers/console/auth/oauth.py | 126 ++ api/controllers/console/datasets/datasets.py | 281 +++++ .../console/datasets/datasets_document.py | 682 +++++++++++ .../console/datasets/datasets_segments.py | 203 ++++ api/controllers/console/datasets/error.py | 73 ++ api/controllers/console/datasets/file.py | 147 +++ .../console/datasets/hit_testing.py | 100 ++ api/controllers/console/error.py | 19 + api/controllers/console/setup.py | 93 ++ api/controllers/console/version.py | 39 + api/controllers/console/workspace/__init__.py | 0 api/controllers/console/workspace/account.py | 263 +++++ api/controllers/console/workspace/error.py | 31 + api/controllers/console/workspace/members.py | 141 +++ .../console/workspace/providers.py | 246 ++++ .../console/workspace/workspace.py | 97 ++ api/controllers/console/wraps.py | 43 + api/controllers/service_api/__init__.py | 12 + api/controllers/service_api/app/__init__.py | 27 + api/controllers/service_api/app/app.py | 43 + api/controllers/service_api/app/completion.py | 182 +++ .../service_api/app/conversation.py | 76 ++ api/controllers/service_api/app/error.py | 51 + api/controllers/service_api/app/message.py | 81 ++ .../service_api/dataset/__init__.py | 0 .../service_api/dataset/document.py | 129 ++ api/controllers/service_api/dataset/error.py | 20 + api/controllers/service_api/wraps.py | 95 ++ api/controllers/web/__init__.py | 10 + api/controllers/web/app.py | 42 + api/controllers/web/completion.py | 175 +++ api/controllers/web/conversation.py | 121 ++ api/controllers/web/error.py | 62 + api/controllers/web/message.py | 189 +++ api/controllers/web/saved_message.py | 74 ++ api/controllers/web/site.py | 73 ++ api/controllers/web/wraps.py | 107 ++ api/core/__init__.py | 52 + api/core/agent/agent_builder.py | 89 ++ .../agent_loop_gather_callback_handler.py | 178 +++ .../dataset_tool_callback_handler.py | 117 ++ .../callback_handler/entity/agent_loop.py | 23 + .../callback_handler/entity/chain_result.py | 16 + .../callback_handler/entity/dataset_query.py | 6 + .../callback_handler/entity/llm_message.py | 9 + .../index_tool_callback_handler.py | 38 + .../callback_handler/llm_callback_handler.py | 147 +++ .../main_chain_gather_callback_handler.py | 137 +++ .../std_out_callback_handler.py | 127 ++ api/core/chain/chain_builder.py | 34 + api/core/chain/main_chain_builder.py | 116 ++ .../chain/sensitive_word_avoidance_chain.py | 42 + api/core/chain/tool_chain.py | 42 + api/core/completion.py | 326 ++++++ api/core/constant/llm_constant.py | 84 ++ api/core/conversation_message_task.py | 388 ++++++ api/core/docstore/dataset_docstore.py | 190 +++ api/core/docstore/empty_docstore.py | 51 + api/core/embedding/openai_embedding.py | 176 +++ api/core/generator/llm_generator.py | 120 ++ api/core/index/index_builder.py | 45 + .../keyword_table/jieba_keyword_table.py | 159 +++ api/core/index/keyword_table/stopwords.py | 90 ++ api/core/index/keyword_table_index.py | 135 +++ api/core/index/query/synthesizer.py | 79 ++ api/core/index/readers/html_parser.py | 22 + api/core/index/readers/pdf_parser.py | 56 + api/core/index/vector_index.py | 136 +++ api/core/indexing_runner.py | 467 ++++++++ api/core/llm/error.py | 55 + api/core/llm/error_handle_wraps.py | 51 + api/core/llm/llm_builder.py | 103 ++ api/core/llm/moderation.py | 15 + api/core/llm/provider/anthropic_provider.py | 23 + api/core/llm/provider/azure_provider.py | 105 ++ api/core/llm/provider/base.py | 124 ++ api/core/llm/provider/errors.py | 2 + api/core/llm/provider/huggingface_provider.py | 22 + api/core/llm/provider/llm_provider_service.py | 53 + api/core/llm/provider/openai_provider.py | 44 + api/core/llm/streamable_azure_chat_open_ai.py | 89 ++ api/core/llm/streamable_chat_open_ai.py | 86 ++ api/core/llm/streamable_open_ai.py | 20 + api/core/llm/token_calculator.py | 41 + ...versation_token_db_buffer_shared_memory.py | 77 ++ ...on_token_db_string_buffer_shared_memory.py | 36 + .../suggested_questions_after_answer.py | 16 + api/core/prompt/prompt_builder.py | 37 + api/core/prompt/prompt_template.py | 37 + api/core/prompt/prompts.py | 63 + api/core/tool/dataset_tool_builder.py | 83 ++ api/core/tool/llama_index_tool.py | 43 + api/core/vector_store/base.py | 34 + .../qdrant_vector_store_client.py | 147 +++ api/core/vector_store/vector_store.py | 61 + .../vector_store/vector_store_index_query.py | 66 ++ .../weaviate_vector_store_client.py | 258 ++++ api/docker/entrypoint.sh | 24 + api/events/app_event.py | 10 + api/events/dataset_event.py | 4 + api/events/document_event.py | 4 + api/events/event_handlers/__init__.py | 9 + .../clean_when_dataset_deleted.py | 8 + .../clean_when_document_deleted.py | 9 + .../create_installed_app_when_app_created.py | 16 + .../create_provider_when_tenant_created.py | 9 + .../create_provider_when_tenant_updated.py | 9 + .../delete_installed_app_when_app_deleted.py | 12 + ...rsation_name_when_first_message_created.py | 29 + ...sation_summary_when_few_message_created.py | 14 + ...aset_join_when_app_model_config_updated.py | 66 ++ api/events/message_event.py | 4 + api/events/tenant_event.py | 7 + api/extensions/ext_celery.py | 23 + api/extensions/ext_database.py | 7 + api/extensions/ext_login.py | 7 + api/extensions/ext_migrate.py | 5 + api/extensions/ext_redis.py | 18 + api/extensions/ext_sentry.py | 20 + api/extensions/ext_session.py | 168 +++ api/extensions/ext_storage.py | 108 ++ api/extensions/ext_vector_store.py | 7 + api/libs/__init__.py | 1 + api/libs/ecc_aes.py | 82 ++ api/libs/exception.py | 17 + api/libs/external_api.py | 115 ++ api/libs/helper.py | 149 +++ api/libs/infinite_scroll_pagination.py | 7 + api/libs/oauth.py | 136 +++ api/libs/password.py | 26 + api/libs/rsa.py | 58 + api/migrations/README | 1 + api/migrations/alembic.ini | 50 + api/migrations/env.py | 113 ++ api/migrations/script.py.mako | 24 + api/migrations/versions/64b051264f32_init.py | 793 +++++++++++++ api/models/__init__.py | 1 + api/models/account.py | 180 +++ api/models/dataset.py | 415 +++++++ api/models/model.py | 622 ++++++++++ api/models/provider.py | 77 ++ api/models/task.py | 37 + api/models/web.py | 36 + api/requirements.txt | 32 + api/services/__init__.py | 2 + api/services/account_service.py | 382 ++++++ api/services/app_model_config_service.py | 292 +++++ api/services/completion_service.py | 506 ++++++++ api/services/conversation_service.py | 94 ++ api/services/dataset_service.py | 521 +++++++++ api/services/errors/__init__.py | 7 + api/services/errors/account.py | 53 + api/services/errors/app.py | 2 + api/services/errors/app_model_config.py | 5 + api/services/errors/base.py | 3 + api/services/errors/completion.py | 5 + api/services/errors/conversation.py | 13 + api/services/errors/dataset.py | 5 + api/services/errors/document.py | 5 + api/services/errors/file.py | 5 + api/services/errors/index.py | 5 + api/services/errors/message.py | 17 + api/services/hit_testing_service.py | 130 ++ api/services/message_service.py | 212 ++++ api/services/provider_service.py | 96 ++ api/services/saved_message_service.py | 66 ++ api/services/web_conversation_service.py | 74 ++ api/services/workspace_service.py | 49 + api/tasks/add_document_to_index_task.py | 99 ++ api/tasks/add_segment_to_index_task.py | 88 ++ api/tasks/clean_dataset_task.py | 77 ++ api/tasks/clean_document_task.py | 52 + api/tasks/document_indexing_task.py | 56 + .../generate_conversation_summary_task.py | 46 + api/tasks/recover_document_indexing_task.py | 51 + api/tasks/remove_document_from_index_task.py | 63 + api/tasks/remove_segment_from_index_task.py | 58 + api/tests/__init__.py | 0 api/tests/conftest.py | 50 + api/tests/test_controllers/__init__.py | 0 .../test_controllers/test_account_api.py.bak | 75 ++ api/tests/test_controllers/test_login.py | 108 ++ api/tests/test_controllers/test_setup.py | 80 ++ api/tests/test_factory.py | 22 + api/tests/test_helpers/__init__.py | 0 api/tests/test_libs/__init__.py | 0 api/tests/test_models/__init__.py | 0 api/tests/test_services/__init__.py | 0 docker/Dockerfile.base | 256 ++++ docker/docker-compose.middleware.yaml | 53 + docker/docker-compose.yaml | 213 ++++ docker/nginx/conf.d/default.conf | 24 + docker/nginx/nginx.conf | 32 + docker/nginx/proxy.conf | 8 + docker/volumes/db/scripts/init_extension.sh | 1 + images/describe-cn.jpg | Bin 0 -> 1230401 bytes images/describe-en.png | Bin 0 -> 1277834 bytes mock-server/.gitignore | 117 ++ mock-server/README.md | 1 + mock-server/api/apps.js | 551 +++++++++ mock-server/api/common.js | 38 + mock-server/api/datasets.js | 249 ++++ mock-server/api/debug.js | 119 ++ mock-server/api/demo.js | 15 + mock-server/app.js | 42 + mock-server/package.json | 26 + sdks/nodejs-client/.gitignore | 49 + sdks/nodejs-client/README.md | 46 + sdks/nodejs-client/index.js | 140 +++ sdks/nodejs-client/package.json | 20 + sdks/php-client/README.md | 51 + sdks/php-client/dify-client.php | 109 ++ sdks/python-client/LICENSE | 21 + sdks/python-client/MANIFEST.in | 1 + sdks/python-client/README.md | 100 ++ sdks/python-client/build.sh | 9 + sdks/python-client/dify_client/__init__.py | 1 + sdks/python-client/dify_client/client.py | 74 ++ sdks/python-client/setup.py | 28 + sdks/python-client/tests/__init__.py | 0 sdks/python-client/tests/test_client.py | 49 + web/.editorconfig | 22 + web/.eslintrc.json | 28 + web/.gitignore | 49 + web/Dockerfile | 29 + web/README.md | 39 + web/app/(commonLayout)/_layout-client.tsx | 85 ++ .../[appId]/configuration/page.tsx | 10 + .../[appId]/develop/page.tsx | 18 + .../app/(appDetailLayout)/[appId]/layout.tsx | 57 + .../(appDetailLayout)/[appId]/logs/page.tsx | 16 + .../[appId]/overview/cardView.tsx | 86 ++ .../[appId]/overview/chartView.tsx | 52 + .../[appId]/overview/page.tsx | 30 + .../[appId]/overview/welcome-banner.tsx | 200 ++++ .../[appId]/style.module.css | 5 + .../app/(appDetailLayout)/layout.tsx | 16 + web/app/(commonLayout)/apps/AppCard.tsx | 76 ++ web/app/(commonLayout)/apps/AppModeLabel.tsx | 26 + web/app/(commonLayout)/apps/Apps.tsx | 23 + web/app/(commonLayout)/apps/NewAppCard.tsx | 29 + web/app/(commonLayout)/apps/NewAppDialog.tsx | 193 +++ web/app/(commonLayout)/apps/assets/add.svg | 3 + .../(commonLayout)/apps/assets/chat-solid.svg | 4 + web/app/(commonLayout)/apps/assets/chat.svg | 3 + .../apps/assets/completion-solid.svg | 4 + .../(commonLayout)/apps/assets/completion.svg | 3 + web/app/(commonLayout)/apps/assets/delete.svg | 3 + .../(commonLayout)/apps/assets/discord.svg | 3 + web/app/(commonLayout)/apps/assets/github.svg | 17 + .../(commonLayout)/apps/assets/link-gray.svg | 3 + web/app/(commonLayout)/apps/assets/link.svg | 3 + .../apps/assets/right-arrow.svg | 3 + web/app/(commonLayout)/apps/page.tsx | 36 + .../[datasetId]/api/page.tsx | 11 + .../documents/[documentId]/page.tsx | 16 + .../[datasetId]/documents/create/page.tsx | 16 + .../[datasetId]/documents/page.tsx | 16 + .../[datasetId]/documents/style.module.css | 9 + .../[datasetId]/hitTesting/page.tsx | 16 + .../[datasetId]/layout.tsx | 169 +++ .../[datasetId]/settings/page.tsx | 23 + .../[datasetId]/style.module.css | 18 + .../datasets/(datasetDetailLayout)/layout.tsx | 16 + .../(commonLayout)/datasets/DatasetCard.tsx | 89 ++ .../(commonLayout)/datasets/DatasetFooter.tsx | 19 + web/app/(commonLayout)/datasets/Datasets.tsx | 27 + .../datasets/NewDatasetCard.tsx | 28 + .../datasets/assets/application.svg | 6 + .../(commonLayout)/datasets/assets/doc.svg | 3 + .../(commonLayout)/datasets/assets/text.svg | 3 + .../(commonLayout)/datasets/create/page.tsx | 12 + web/app/(commonLayout)/datasets/page.tsx | 23 + web/app/(commonLayout)/layout.tsx | 19 + web/app/(commonLayout)/list.module.css | 183 +++ .../assets/coming-soon.png | Bin 0 -> 2332 bytes .../plugins-coming-soon/assets/plugins-bg.png | Bin 0 -> 46264 bytes .../plugins-coming-soon/page.module.css | 32 + .../plugins-coming-soon/page.tsx | 19 + web/app/(shareLayout)/chat/[token]/page.tsx | 16 + .../(shareLayout)/completion/[token]/page.tsx | 13 + web/app/(shareLayout)/layout.tsx | 18 + web/app/api/hello/route.ts | 3 + web/app/components/app-sidebar/basic.tsx | 65 + web/app/components/app-sidebar/index.tsx | 39 + web/app/components/app-sidebar/navLink.tsx | 39 + .../components/app-sidebar/style.module.css | 3 + web/app/components/app/chat/icons/answer.svg | 3 + .../app/chat/icons/default-avatar.jpg | Bin 0 -> 2183 bytes web/app/components/app/chat/icons/edit.svg | 3 + .../components/app/chat/icons/question.svg | 3 + web/app/components/app/chat/icons/robot.svg | 10 + .../components/app/chat/icons/send-active.svg | 3 + web/app/components/app/chat/icons/send.svg | 3 + web/app/components/app/chat/icons/typing.svg | 19 + web/app/components/app/chat/icons/user.svg | 10 + web/app/components/app/chat/index.tsx | 559 +++++++++ .../app/chat/loading-anim/index.tsx | 16 + .../app/chat/loading-anim/style.module.css | 82 ++ web/app/components/app/chat/style.module.css | 91 ++ .../base/feature-panel/index.tsx | 54 + .../configuration/base/group-name/index.tsx | 23 + .../base/icons/more-like-this-icon.tsx | 13 + .../base/icons/remove-icon/index.tsx | 31 + .../base/icons/remove-icon/style.module.css | 0 .../suggested-questions-after-answer-icon.tsx | 11 + .../app/configuration/base/icons/var-icon.tsx | 11 + .../base/operation-btn/index.tsx | 39 + .../base/var-highlight/index.tsx | 36 + .../base/var-highlight/style.module.css | 3 + .../base/warning-mask/formatting-changed.tsx | 40 + .../base/warning-mask/has-not-set-api.tsx | 37 + .../configuration/base/warning-mask/index.tsx | 42 + .../base/warning-mask/style.module.css | 8 + .../app/configuration/config-model/index.tsx | 243 ++++ .../configuration/config-model/param-item.tsx | 45 + .../config-prompt/confirm-add-var/index.tsx | 72 ++ .../app/configuration/config-prompt/index.tsx | 95 ++ .../config-var/config-model/index.tsx | 115 ++ .../config-var/config-model/style.module.css | 8 + .../config-var/config-select/index.tsx | 64 + .../config-var/config-select/style.module.css | 20 + .../config-var/config-string/index.tsx | 38 + .../app/configuration/config-var/index.tsx | 228 ++++ .../config-var/input-type-icon.tsx | 34 + .../configuration/config-var/modal-foot.tsx | 23 + .../config-var/select-type-item/index.tsx | 61 + .../select-type-item/style.module.css | 38 + .../configuration/config-var/style.module.css | 12 + .../config/feature/add-feature-btn/index.tsx | 31 + .../choose-feature/feature-item/index.tsx | 42 + .../config/feature/choose-feature/index.tsx | 92 ++ .../config/feature/feature-group/index.tsx | 30 + .../config/feature/use-feature.tsx | 58 + .../app/configuration/config/index.tsx | 153 +++ .../configuration/ctrl-btn-group/index.tsx | 24 + .../ctrl-btn-group/style.module.css | 6 + .../dataset-config/card-item/index.tsx | 51 + .../dataset-config/card-item/style.module.css | 16 + .../configuration/dataset-config/index.tsx | 79 ++ .../dataset-config/select-dataset/index.tsx | 130 ++ .../select-dataset/style.module.css | 9 + .../dataset-config/type-icon/index.tsx | 33 + .../app/configuration/debug/index.tsx | 417 +++++++ .../features/chat-group/index.tsx | 40 + .../chat-group/opening-statement/index.tsx | 177 +++ .../index.tsx | 33 + .../experience-enchance-group/index.tsx | 21 + .../more-like-this/index.tsx | 50 + .../components/app/configuration/index.tsx | 317 +++++ .../prompt-value-panel/index.tsx | 210 ++++ .../app/configuration/toolbox/index.tsx | 26 + web/app/components/app/log/filter.tsx | 83 ++ web/app/components/app/log/index.tsx | 146 +++ web/app/components/app/log/list.tsx | 477 ++++++++ web/app/components/app/log/style.module.css | 9 + web/app/components/app/overview/appCard.tsx | 211 ++++ web/app/components/app/overview/appChart.tsx | 291 +++++ .../app/overview/customize/index.tsx | 108 ++ .../app/overview/settings/index.tsx | 145 +++ .../app/overview/settings/style.module.css | 23 + .../components/app/overview/share-link.tsx | 63 + web/app/components/app/overview/style.css | 13 + .../components/app/text-generate/index.tsx | 26 + .../app/text-generate/item/index.tsx | 242 ++++ .../app/text-generate/saved-items/index.tsx | 79 ++ .../saved-items/no-data/index.tsx | 50 + web/app/components/base/app-icon/index.tsx | 38 + .../components/base/app-icon/style.module.css | 15 + web/app/components/base/app-unavailable.tsx | 28 + .../base/auto-height-textarea/index.tsx | 75 ++ .../auto-height-textarea/style.module.scss | 0 web/app/components/base/avatar/index.tsx | 45 + web/app/components/base/block-input/index.tsx | 174 +++ web/app/components/base/button/index.tsx | 47 + web/app/components/base/confirm-ui/index.tsx | 51 + web/app/components/base/confirm/index.tsx | 79 ++ web/app/components/base/custom-icon/index.tsx | 16 + web/app/components/base/dialog/index.tsx | 86 ++ web/app/components/base/divider/index.tsx | 18 + .../components/base/divider/style.module.css | 9 + web/app/components/base/drawer/index.tsx | 75 ++ web/app/components/base/ga/index.tsx | 37 + web/app/components/base/input/index.tsx | 45 + .../components/base/input/style.module.css | 7 + web/app/components/base/loading/index.tsx | 29 + web/app/components/base/loading/style.css | 41 + web/app/components/base/markdown.tsx | 87 ++ web/app/components/base/modal/index.tsx | 73 ++ web/app/components/base/pagination/index.tsx | 52 + .../base/pagination/style.module.css | 3 + web/app/components/base/panel/index.tsx | 80 ++ web/app/components/base/popover/index.tsx | 98 ++ .../components/base/popover/style.module.css | 9 + .../base/portal-to-follow-elem/index.tsx | 84 ++ .../base/radio/component/group/index.tsx | 24 + .../base/radio/component/radio/index.tsx | 61 + .../components/base/radio/context/index.tsx | 6 + web/app/components/base/radio/index.tsx | 15 + .../components/base/radio/style.module.css | 13 + .../base/select-support-portal/index.tsx | 73 ++ web/app/components/base/select/index.tsx | 224 ++++ web/app/components/base/select/locale.tsx | 125 ++ web/app/components/base/slider/index.tsx | 24 + web/app/components/base/slider/style.css | 28 + web/app/components/base/spinner/index.tsx | 24 + web/app/components/base/switch/index.tsx | 55 + web/app/components/base/tab-header/index.tsx | 37 + .../base/tab-header/style.module.css | 9 + web/app/components/base/tag/index.tsx | 42 + web/app/components/base/toast/index.tsx | 131 +++ .../components/base/toast/style.module.css | 44 + web/app/components/base/tooltip/index.tsx | 49 + web/app/components/datasets/api/index.tsx | 11 + .../datasets/create/assets/Loading.svg | 16 + .../datasets/create/assets/alert-triangle.svg | 3 + .../create/assets/annotation-info.svg | 3 + .../create/assets/arrow-narrow-left.svg | 3 + .../datasets/create/assets/book-open-01.svg | 4 + .../datasets/create/assets/check.svg | 3 + .../datasets/create/assets/close.svg | 3 + .../datasets/create/assets/file.svg | 4 + .../datasets/create/assets/folder-plus.svg | 3 + .../datasets/create/assets/html.svg | 23 + .../datasets/create/assets/json.svg | 23 + .../components/datasets/create/assets/md.svg | 18 + .../datasets/create/assets/notion.svg | 12 + .../components/datasets/create/assets/pdf.svg | 22 + .../datasets/create/assets/piggy-bank-01.svg | 4 + .../datasets/create/assets/sliders-02.svg | 8 + .../datasets/create/assets/star-07.svg | 11 + .../datasets/create/assets/trash.svg | 3 + .../components/datasets/create/assets/txt.svg | 23 + .../datasets/create/assets/unknow.svg | 23 + .../create/assets/upload-cloud-01.svg | 4 + .../components/datasets/create/assets/web.svg | 4 + .../datasets/create/assets/zap-fast.svg | 6 + .../index.module.css | 38 + .../empty-dataset-creation-modal/index.tsx | 73 ++ .../create/file-preview/index.module.css | 46 + .../datasets/create/file-preview/index.tsx | 62 + .../create/file-uploader/index.module.css | 171 +++ .../datasets/create/file-uploader/index.tsx | 265 +++++ .../datasets/create/index.module.css | 0 web/app/components/datasets/create/index.tsx | 113 ++ .../datasets/create/step-one/index.module.css | 109 ++ .../datasets/create/step-one/index.tsx | 80 ++ .../create/step-three/index.module.css | 75 ++ .../datasets/create/step-three/index.tsx | 61 + .../datasets/create/step-two/index.module.css | 382 ++++++ .../datasets/create/step-two/index.tsx | 491 ++++++++ .../create/step-two/preview-item/index.tsx | 49 + .../create/steps-nav-bar/index.module.css | 106 ++ .../datasets/create/steps-nav-bar/index.tsx | 51 + .../stop-embedding-modal/index.module.css | 37 + .../create/stop-embedding-modal/index.tsx | 46 + .../datasets/documents/assets/action.svg | 5 + .../datasets/documents/assets/atSign.svg | 10 + .../datasets/documents/assets/bezierCurve.svg | 3 + .../datasets/documents/assets/bookOpen.svg | 3 + .../datasets/documents/assets/briefcase.svg | 3 + .../datasets/documents/assets/cardLoading.svg | 15 + .../datasets/documents/assets/file.svg | 3 + .../datasets/documents/assets/globe.svg | 10 + .../documents/assets/graduationHat.svg | 3 + .../datasets/documents/assets/hitLoading.svg | 15 + .../datasets/documents/assets/html.svg | 23 + .../datasets/documents/assets/json.svg | 23 + .../documents/assets/layoutRightClose.svg | 4 + .../documents/assets/layoutRightShow.svg | 4 + .../datasets/documents/assets/md.svg | 18 + .../documents/assets/messageTextCircle.svg | 3 + .../datasets/documents/assets/normal.svg | 4 + .../datasets/documents/assets/pdf.svg | 22 + .../datasets/documents/assets/star.svg | 11 + .../datasets/documents/assets/target.svg | 10 + .../datasets/documents/assets/txt.svg | 23 + .../datasets/documents/assets/typeSquare.svg | 3 + .../detail/completed/InfiniteVirtualList.tsx | 87 ++ .../detail/completed/SegmentCard.tsx | 166 +++ .../documents/detail/completed/index.tsx | 206 ++++ .../detail/completed/style.module.css | 130 ++ .../documents/detail/embedding/index.tsx | 268 +++++ .../detail/embedding/style.module.css | 59 + .../datasets/documents/detail/index.tsx | 121 ++ .../documents/detail/metadata/index.tsx | 363 ++++++ .../detail/metadata/style.module.css | 114 ++ .../documents/detail/style.module.css | 15 + .../components/datasets/documents/index.tsx | 135 +++ .../components/datasets/documents/list.tsx | 318 +++++ .../datasets/documents/style.module.css | 103 ++ .../datasets/hit-testing/assets/clock.svg | 3 + .../datasets/hit-testing/assets/grid.svg | 6 + .../datasets/hit-testing/assets/plugin.svg | 10 + .../datasets/hit-testing/hit-detail.tsx | 99 ++ .../components/datasets/hit-testing/index.tsx | 174 +++ .../datasets/hit-testing/style.module.css | 65 + .../datasets/hit-testing/textarea.tsx | 116 ++ .../datasets/settings/form/index.tsx | 127 ++ .../index-method-radio/assets/economy.svg | 5 + .../assets/high-quality.svg | 12 + .../index-method-radio/index.module.css | 38 + .../settings/index-method-radio/index.tsx | 64 + .../permissions-radio/assets/user.svg | 7 + .../permissions-radio/index.module.css | 30 + .../settings/permissions-radio/index.tsx | 57 + web/app/components/develop/code.tsx | 305 +++++ web/app/components/develop/doc.tsx | 33 + web/app/components/develop/index.tsx | 47 + web/app/components/develop/md.tsx | 140 +++ .../develop/secret-key/assets/copied.svg | 3 + .../develop/secret-key/assets/copy-hover.svg | 3 + .../develop/secret-key/assets/copy.svg | 3 + .../develop/secret-key/assets/trash-gray.svg | 3 + .../develop/secret-key/assets/trash-red.svg | 3 + .../develop/secret-key/input-copy.tsx | 71 ++ .../develop/secret-key/secret-key-button.tsx | 31 + .../secret-key/secret-key-generate.tsx | 41 + .../develop/secret-key/secret-key-modal.tsx | 165 +++ .../develop/secret-key/style.module.css | 61 + web/app/components/develop/tag.tsx | 65 + .../develop/template/template.en.mdx | 206 ++++ .../develop/template/template.zh.mdx | 206 ++++ .../develop/template/template_chat.en.mdx | 391 +++++++ .../develop/template/template_chat.zh.mdx | 391 +++++++ .../header/account-about/index.module.css | 18 + .../components/header/account-about/index.tsx | 91 ++ .../header/account-dropdown/index.tsx | 130 ++ .../workplace-selector/index.module.css | 5 + .../workplace-selector/index.tsx | 97 ++ .../Integrations-page/index.module.css | 24 + .../Integrations-page/index.tsx | 74 ++ .../account-page/index.module.css | 4 + .../account-setting/account-page/index.tsx | 130 ++ .../header/account-setting/collapse/index.tsx | 54 + .../header/account-setting/index.module.css | 5 + .../header/account-setting/index.tsx | 132 +++ .../language-page/index.module.css | 24 + .../account-setting/language-page/index.tsx | 72 ++ .../members-page/index.module.css | 9 + .../account-setting/members-page/index.tsx | 114 ++ .../invite-modal/index.module.css | 4 + .../members-page/invite-modal/index.tsx | 74 ++ .../invited-modal/index.module.css | 5 + .../members-page/invited-modal/index.tsx | 45 + .../members-page/operation/index.module.css | 3 + .../members-page/operation/index.tsx | 136 +++ .../provider-page/azure-provider/index.tsx | 75 ++ .../provider-page/index.module.css | 17 + .../account-setting/provider-page/index.tsx | 112 ++ .../openai-hosted-provider/index.module.css | 24 + .../openai-hosted-provider/index.tsx | 65 + .../openai-provider/index.module.css | 0 .../provider-page/openai-provider/index.tsx | 223 ++++ .../openai-provider/provider.tsx | 52 + .../provider-page/provider-input/index.tsx | 143 +++ .../provider-input/useValidateToken.ts | 46 + .../provider-item/index.module.css | 19 + .../provider-page/provider-item/index.tsx | 138 +++ web/app/components/header/app-back/index.tsx | 37 + .../components/header/app-selector/index.tsx | 104 ++ web/app/components/header/assets/alpha.svg | 4 + .../components/header/assets/anthropic.svg | 11 + web/app/components/header/assets/azure.svg | 4 + web/app/components/header/assets/beaker.svg | 3 + .../components/header/assets/bitbucket.svg | 10 + web/app/components/header/assets/github.svg | 17 + web/app/components/header/assets/google.svg | 13 + web/app/components/header/assets/gpt.svg | 4 + .../components/header/assets/hugging-face.svg | 21 + .../components/header/assets/logo-icon.png | Bin 0 -> 5958 bytes .../components/header/assets/logo-text.svg | 6 + web/app/components/header/assets/logo.png | Bin 0 -> 9137 bytes .../components/header/assets/salesforce.svg | 12 + web/app/components/header/assets/twitter.svg | 3 + web/app/components/header/index.module.css | 36 + web/app/components/header/index.tsx | 139 +++ web/app/components/header/indicator/index.tsx | 59 + .../components/header/nav/index.module.css | 0 web/app/components/header/nav/index.tsx | 73 ++ .../header/nav/nav-selector/index.tsx | 105 ++ web/app/components/i18n-server.tsx | 25 + web/app/components/i18n.tsx | 31 + web/app/components/locale-switcher.tsx | 23 + .../share/chat/config-scence/index.tsx | 13 + .../share/chat/hooks/use-conversation.ts | 66 ++ web/app/components/share/chat/index.tsx | 506 ++++++++ .../share/chat/sidebar/card.module.css | 3 + .../components/share/chat/sidebar/card.tsx | 19 + .../components/share/chat/sidebar/index.tsx | 83 ++ .../share/chat/value-panel/index.tsx | 79 ++ .../share/chat/value-panel/style.module.css | 3 + .../share/chat/welcome/icons/logo.png | Bin 0 -> 3921 bytes .../components/share/chat/welcome/index.tsx | 352 ++++++ .../share/chat/welcome/massive-component.tsx | 75 ++ .../share/chat/welcome/style.module.css | 29 + web/app/components/share/header.tsx | 44 + .../text-generation/config-scence/index.tsx | 100 ++ .../share/text-generation/history/index.tsx | 79 ++ .../share/text-generation/icons/app-icon.svg | 22 + .../share/text-generation/icons/star.svg | 5 + .../share/text-generation/index.tsx | 349 ++++++ .../share/text-generation/no-data/index.tsx | 26 + .../share/text-generation/result/header.tsx | 116 ++ .../share/text-generation/result/index.tsx | 34 + .../share/text-generation/style.module.css | 13 + web/app/components/with-i18n.tsx | 20 + web/app/install/installForm.tsx | 155 +++ web/app/install/page.tsx | 36 + web/app/layout.tsx | 32 + web/app/page.module.css | 266 +++++ web/app/page.tsx | 18 + web/app/signin/_header.tsx | 30 + web/app/signin/assets/background.png | Bin 0 -> 228976 bytes web/app/signin/assets/github.svg | 17 + web/app/signin/assets/google.svg | 13 + web/app/signin/assets/logo-icon.svg | 5 + web/app/signin/assets/logo-text.svg | 14 + web/app/signin/forms.tsx | 35 + web/app/signin/normalForm.tsx | 288 +++++ web/app/signin/oneMoreStep.tsx | 160 +++ web/app/signin/page.module.css | 19 + web/app/signin/page.tsx | 37 + web/app/styles/globals.css | 134 +++ web/app/styles/markdown.scss | 1042 +++++++++++++++++ web/config/index.ts | 104 ++ web/context/app-context.ts | 27 + web/context/dataset-detail.ts | 5 + web/context/datasets-context.tsx | 20 + web/context/debug-configuration.ts | 89 ++ web/context/i18n.ts | 18 + web/context/workspace-context.tsx | 35 + web/dictionaries/en.json | 27 + web/dictionaries/zh-Hans.json | 27 + web/docker/entrypoint.sh | 11 + web/docker/pm2.json | 12 + web/hooks/use-breakpoints.ts | 27 + web/hooks/use-copy-to-clipboard.ts | 29 + web/hooks/use-metadata.ts | 391 +++++++ web/i18n/client.ts | 16 + web/i18n/i18next-config.ts | 92 ++ web/i18n/i18next-serverside-config.ts | 26 + web/i18n/index.ts | 6 + web/i18n/lang/app-api.en.ts | 76 ++ web/i18n/lang/app-api.zh.ts | 76 ++ web/i18n/lang/app-debug.en.ts | 139 +++ web/i18n/lang/app-debug.zh.ts | 134 +++ web/i18n/lang/app-log.en.ts | 67 ++ web/i18n/lang/app-log.zh.ts | 67 ++ web/i18n/lang/app-overview.en.ts | 102 ++ web/i18n/lang/app-overview.zh.ts | 102 ++ web/i18n/lang/app.en.ts | 40 + web/i18n/lang/app.zh.ts | 39 + web/i18n/lang/common.en.ts | 205 ++++ web/i18n/lang/common.zh.ts | 206 ++++ web/i18n/lang/dataset-creation.en.ts | 108 ++ web/i18n/lang/dataset-creation.zh.ts | 108 ++ web/i18n/lang/dataset-documents.en.ts | 314 +++++ web/i18n/lang/dataset-documents.zh.ts | 313 +++++ web/i18n/lang/dataset-hit-testing.en.ts | 28 + web/i18n/lang/dataset-hit-testing.zh.ts | 28 + web/i18n/lang/dataset-settings.en.ts | 22 + web/i18n/lang/dataset-settings.zh.ts | 22 + web/i18n/lang/dataset.en.ts | 21 + web/i18n/lang/dataset.zh.ts | 21 + web/i18n/lang/layout.en.ts | 4 + web/i18n/lang/layout.zh.ts | 4 + web/i18n/lang/login.en.ts | 41 + web/i18n/lang/login.zh.ts | 41 + web/i18n/lang/register.en.ts | 4 + web/i18n/lang/register.zh.ts | 4 + web/i18n/lang/share-app.en.ts | 45 + web/i18n/lang/share-app.zh.ts | 41 + web/i18n/server.ts | 43 + web/middleware.ts | 39 + web/models/app.ts | 118 ++ web/models/common.ts | 92 ++ web/models/datasets.ts | 339 ++++++ web/models/debug.ts | 115 ++ web/models/history.ts | 11 + web/models/log.ts | 192 +++ web/models/share.ts | 19 + web/models/user.ts | 17 + web/next.config.js | 43 + web/package.json | 82 ++ web/postcss.config.js | 6 + web/public/favicon.ico | Bin 0 -> 15406 bytes web/service/apps.ts | 96 ++ web/service/base.ts | 353 ++++++ web/service/common.ts | 91 ++ web/service/datasets.ts | 127 ++ web/service/debug.ts | 40 + web/service/demo/index.tsx | 106 ++ web/service/log.ts | 59 + web/service/share.ts | 81 ++ web/tailwind.config.js | 70 ++ web/test/factories/index.ts | 66 ++ web/test/test_util.ts | 45 + web/tsconfig.json | 42 + web/types/app.ts | 227 ++++ web/typography.js | 357 ++++++ web/utils/format.ts | 33 + web/utils/index.ts | 24 + web/utils/language.ts | 19 + web/utils/model-config.ts | 63 + web/utils/timezone.ts | 330 ++++++ web/utils/var.ts | 47 + 744 files changed, 56028 insertions(+) create mode 100644 .github/workflows/build-api-image.sh create mode 100644 .github/workflows/build-api-image.yml create mode 100644 .github/workflows/build-web-image.sh create mode 100644 .github/workflows/build-web-image.yml create mode 100644 .github/workflows/check_no_chinese_comments.py create mode 100644 .github/workflows/check_no_chinese_comments.yml create mode 100644 .github/workflows/flake8.yml create mode 100644 .gitignore create mode 100644 AUTHORS create mode 100644 CONTRIBUTING.md create mode 100644 CONTRIBUTING_CN.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_CN.md create mode 100644 api/.dockerignore create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/README.md create mode 100644 api/app.py create mode 100644 api/commands.py create mode 100644 api/config.py create mode 100644 api/constants/__init__.py create mode 100644 api/constants/model_template.py create mode 100644 api/controllers/__init__.py create mode 100644 api/controllers/console/__init__.py create mode 100644 api/controllers/console/apikey.py create mode 100644 api/controllers/console/app/__init__.py create mode 100644 api/controllers/console/app/app.py create mode 100644 api/controllers/console/app/completion.py create mode 100644 api/controllers/console/app/conversation.py create mode 100644 api/controllers/console/app/error.py create mode 100644 api/controllers/console/app/explore.py create mode 100644 api/controllers/console/app/message.py create mode 100644 api/controllers/console/app/model_config.py create mode 100644 api/controllers/console/app/site.py create mode 100644 api/controllers/console/app/statistic.py create mode 100644 api/controllers/console/auth/login.py create mode 100644 api/controllers/console/auth/oauth.py create mode 100644 api/controllers/console/datasets/datasets.py create mode 100644 api/controllers/console/datasets/datasets_document.py create mode 100644 api/controllers/console/datasets/datasets_segments.py create mode 100644 api/controllers/console/datasets/error.py create mode 100644 api/controllers/console/datasets/file.py create mode 100644 api/controllers/console/datasets/hit_testing.py create mode 100644 api/controllers/console/error.py create mode 100644 api/controllers/console/setup.py create mode 100644 api/controllers/console/version.py create mode 100644 api/controllers/console/workspace/__init__.py create mode 100644 api/controllers/console/workspace/account.py create mode 100644 api/controllers/console/workspace/error.py create mode 100644 api/controllers/console/workspace/members.py create mode 100644 api/controllers/console/workspace/providers.py create mode 100644 api/controllers/console/workspace/workspace.py create mode 100644 api/controllers/console/wraps.py create mode 100644 api/controllers/service_api/__init__.py create mode 100644 api/controllers/service_api/app/__init__.py create mode 100644 api/controllers/service_api/app/app.py create mode 100644 api/controllers/service_api/app/completion.py create mode 100644 api/controllers/service_api/app/conversation.py create mode 100644 api/controllers/service_api/app/error.py create mode 100644 api/controllers/service_api/app/message.py create mode 100644 api/controllers/service_api/dataset/__init__.py create mode 100644 api/controllers/service_api/dataset/document.py create mode 100644 api/controllers/service_api/dataset/error.py create mode 100644 api/controllers/service_api/wraps.py create mode 100644 api/controllers/web/__init__.py create mode 100644 api/controllers/web/app.py create mode 100644 api/controllers/web/completion.py create mode 100644 api/controllers/web/conversation.py create mode 100644 api/controllers/web/error.py create mode 100644 api/controllers/web/message.py create mode 100644 api/controllers/web/saved_message.py create mode 100644 api/controllers/web/site.py create mode 100644 api/controllers/web/wraps.py create mode 100644 api/core/__init__.py create mode 100644 api/core/agent/agent_builder.py create mode 100644 api/core/callback_handler/agent_loop_gather_callback_handler.py create mode 100644 api/core/callback_handler/dataset_tool_callback_handler.py create mode 100644 api/core/callback_handler/entity/agent_loop.py create mode 100644 api/core/callback_handler/entity/chain_result.py create mode 100644 api/core/callback_handler/entity/dataset_query.py create mode 100644 api/core/callback_handler/entity/llm_message.py create mode 100644 api/core/callback_handler/index_tool_callback_handler.py create mode 100644 api/core/callback_handler/llm_callback_handler.py create mode 100644 api/core/callback_handler/main_chain_gather_callback_handler.py create mode 100644 api/core/callback_handler/std_out_callback_handler.py create mode 100644 api/core/chain/chain_builder.py create mode 100644 api/core/chain/main_chain_builder.py create mode 100644 api/core/chain/sensitive_word_avoidance_chain.py create mode 100644 api/core/chain/tool_chain.py create mode 100644 api/core/completion.py create mode 100644 api/core/constant/llm_constant.py create mode 100644 api/core/conversation_message_task.py create mode 100644 api/core/docstore/dataset_docstore.py create mode 100644 api/core/docstore/empty_docstore.py create mode 100644 api/core/embedding/openai_embedding.py create mode 100644 api/core/generator/llm_generator.py create mode 100644 api/core/index/index_builder.py create mode 100644 api/core/index/keyword_table/jieba_keyword_table.py create mode 100644 api/core/index/keyword_table/stopwords.py create mode 100644 api/core/index/keyword_table_index.py create mode 100644 api/core/index/query/synthesizer.py create mode 100644 api/core/index/readers/html_parser.py create mode 100644 api/core/index/readers/pdf_parser.py create mode 100644 api/core/index/vector_index.py create mode 100644 api/core/indexing_runner.py create mode 100644 api/core/llm/error.py create mode 100644 api/core/llm/error_handle_wraps.py create mode 100644 api/core/llm/llm_builder.py create mode 100644 api/core/llm/moderation.py create mode 100644 api/core/llm/provider/anthropic_provider.py create mode 100644 api/core/llm/provider/azure_provider.py create mode 100644 api/core/llm/provider/base.py create mode 100644 api/core/llm/provider/errors.py create mode 100644 api/core/llm/provider/huggingface_provider.py create mode 100644 api/core/llm/provider/llm_provider_service.py create mode 100644 api/core/llm/provider/openai_provider.py create mode 100644 api/core/llm/streamable_azure_chat_open_ai.py create mode 100644 api/core/llm/streamable_chat_open_ai.py create mode 100644 api/core/llm/streamable_open_ai.py create mode 100644 api/core/llm/token_calculator.py create mode 100644 api/core/memory/read_only_conversation_token_db_buffer_shared_memory.py create mode 100644 api/core/memory/read_only_conversation_token_db_string_buffer_shared_memory.py create mode 100644 api/core/prompt/output_parser/suggested_questions_after_answer.py create mode 100644 api/core/prompt/prompt_builder.py create mode 100644 api/core/prompt/prompt_template.py create mode 100644 api/core/prompt/prompts.py create mode 100644 api/core/tool/dataset_tool_builder.py create mode 100644 api/core/tool/llama_index_tool.py create mode 100644 api/core/vector_store/base.py create mode 100644 api/core/vector_store/qdrant_vector_store_client.py create mode 100644 api/core/vector_store/vector_store.py create mode 100644 api/core/vector_store/vector_store_index_query.py create mode 100644 api/core/vector_store/weaviate_vector_store_client.py create mode 100644 api/docker/entrypoint.sh create mode 100644 api/events/app_event.py create mode 100644 api/events/dataset_event.py create mode 100644 api/events/document_event.py create mode 100644 api/events/event_handlers/__init__.py create mode 100644 api/events/event_handlers/clean_when_dataset_deleted.py create mode 100644 api/events/event_handlers/clean_when_document_deleted.py create mode 100644 api/events/event_handlers/create_installed_app_when_app_created.py create mode 100644 api/events/event_handlers/create_provider_when_tenant_created.py create mode 100644 api/events/event_handlers/create_provider_when_tenant_updated.py create mode 100644 api/events/event_handlers/delete_installed_app_when_app_deleted.py create mode 100644 api/events/event_handlers/generate_conversation_name_when_first_message_created.py create mode 100644 api/events/event_handlers/generate_conversation_summary_when_few_message_created.py create mode 100644 api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py create mode 100644 api/events/message_event.py create mode 100644 api/events/tenant_event.py create mode 100644 api/extensions/ext_celery.py create mode 100644 api/extensions/ext_database.py create mode 100644 api/extensions/ext_login.py create mode 100644 api/extensions/ext_migrate.py create mode 100644 api/extensions/ext_redis.py create mode 100644 api/extensions/ext_sentry.py create mode 100644 api/extensions/ext_session.py create mode 100644 api/extensions/ext_storage.py create mode 100644 api/extensions/ext_vector_store.py create mode 100644 api/libs/__init__.py create mode 100644 api/libs/ecc_aes.py create mode 100644 api/libs/exception.py create mode 100644 api/libs/external_api.py create mode 100644 api/libs/helper.py create mode 100644 api/libs/infinite_scroll_pagination.py create mode 100644 api/libs/oauth.py create mode 100644 api/libs/password.py create mode 100644 api/libs/rsa.py create mode 100644 api/migrations/README create mode 100644 api/migrations/alembic.ini create mode 100644 api/migrations/env.py create mode 100644 api/migrations/script.py.mako create mode 100644 api/migrations/versions/64b051264f32_init.py create mode 100644 api/models/__init__.py create mode 100644 api/models/account.py create mode 100644 api/models/dataset.py create mode 100644 api/models/model.py create mode 100644 api/models/provider.py create mode 100644 api/models/task.py create mode 100644 api/models/web.py create mode 100644 api/requirements.txt create mode 100644 api/services/__init__.py create mode 100644 api/services/account_service.py create mode 100644 api/services/app_model_config_service.py create mode 100644 api/services/completion_service.py create mode 100644 api/services/conversation_service.py create mode 100644 api/services/dataset_service.py create mode 100644 api/services/errors/__init__.py create mode 100644 api/services/errors/account.py create mode 100644 api/services/errors/app.py create mode 100644 api/services/errors/app_model_config.py create mode 100644 api/services/errors/base.py create mode 100644 api/services/errors/completion.py create mode 100644 api/services/errors/conversation.py create mode 100644 api/services/errors/dataset.py create mode 100644 api/services/errors/document.py create mode 100644 api/services/errors/file.py create mode 100644 api/services/errors/index.py create mode 100644 api/services/errors/message.py create mode 100644 api/services/hit_testing_service.py create mode 100644 api/services/message_service.py create mode 100644 api/services/provider_service.py create mode 100644 api/services/saved_message_service.py create mode 100644 api/services/web_conversation_service.py create mode 100644 api/services/workspace_service.py create mode 100644 api/tasks/add_document_to_index_task.py create mode 100644 api/tasks/add_segment_to_index_task.py create mode 100644 api/tasks/clean_dataset_task.py create mode 100644 api/tasks/clean_document_task.py create mode 100644 api/tasks/document_indexing_task.py create mode 100644 api/tasks/generate_conversation_summary_task.py create mode 100644 api/tasks/recover_document_indexing_task.py create mode 100644 api/tasks/remove_document_from_index_task.py create mode 100644 api/tasks/remove_segment_from_index_task.py create mode 100644 api/tests/__init__.py create mode 100644 api/tests/conftest.py create mode 100644 api/tests/test_controllers/__init__.py create mode 100644 api/tests/test_controllers/test_account_api.py.bak create mode 100644 api/tests/test_controllers/test_login.py create mode 100644 api/tests/test_controllers/test_setup.py create mode 100644 api/tests/test_factory.py create mode 100644 api/tests/test_helpers/__init__.py create mode 100644 api/tests/test_libs/__init__.py create mode 100644 api/tests/test_models/__init__.py create mode 100644 api/tests/test_services/__init__.py create mode 100644 docker/Dockerfile.base create mode 100644 docker/docker-compose.middleware.yaml create mode 100644 docker/docker-compose.yaml create mode 100644 docker/nginx/conf.d/default.conf create mode 100644 docker/nginx/nginx.conf create mode 100644 docker/nginx/proxy.conf create mode 100644 docker/volumes/db/scripts/init_extension.sh create mode 100644 images/describe-cn.jpg create mode 100644 images/describe-en.png create mode 100644 mock-server/.gitignore create mode 100644 mock-server/README.md create mode 100644 mock-server/api/apps.js create mode 100644 mock-server/api/common.js create mode 100644 mock-server/api/datasets.js create mode 100644 mock-server/api/debug.js create mode 100644 mock-server/api/demo.js create mode 100644 mock-server/app.js create mode 100644 mock-server/package.json create mode 100644 sdks/nodejs-client/.gitignore create mode 100644 sdks/nodejs-client/README.md create mode 100644 sdks/nodejs-client/index.js create mode 100644 sdks/nodejs-client/package.json create mode 100644 sdks/php-client/README.md create mode 100644 sdks/php-client/dify-client.php create mode 100644 sdks/python-client/LICENSE create mode 100644 sdks/python-client/MANIFEST.in create mode 100644 sdks/python-client/README.md create mode 100755 sdks/python-client/build.sh create mode 100644 sdks/python-client/dify_client/__init__.py create mode 100644 sdks/python-client/dify_client/client.py create mode 100644 sdks/python-client/setup.py create mode 100644 sdks/python-client/tests/__init__.py create mode 100644 sdks/python-client/tests/test_client.py create mode 100644 web/.editorconfig create mode 100644 web/.eslintrc.json create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/app/(commonLayout)/_layout-client.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/configuration/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/develop/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/logs/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chartView.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/welcome-banner.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/style.module.css create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/layout.tsx create mode 100644 web/app/(commonLayout)/apps/AppCard.tsx create mode 100644 web/app/(commonLayout)/apps/AppModeLabel.tsx create mode 100644 web/app/(commonLayout)/apps/Apps.tsx create mode 100644 web/app/(commonLayout)/apps/NewAppCard.tsx create mode 100644 web/app/(commonLayout)/apps/NewAppDialog.tsx create mode 100644 web/app/(commonLayout)/apps/assets/add.svg create mode 100644 web/app/(commonLayout)/apps/assets/chat-solid.svg create mode 100644 web/app/(commonLayout)/apps/assets/chat.svg create mode 100644 web/app/(commonLayout)/apps/assets/completion-solid.svg create mode 100644 web/app/(commonLayout)/apps/assets/completion.svg create mode 100644 web/app/(commonLayout)/apps/assets/delete.svg create mode 100644 web/app/(commonLayout)/apps/assets/discord.svg create mode 100644 web/app/(commonLayout)/apps/assets/github.svg create mode 100644 web/app/(commonLayout)/apps/assets/link-gray.svg create mode 100644 web/app/(commonLayout)/apps/assets/link.svg create mode 100644 web/app/(commonLayout)/apps/assets/right-arrow.svg create mode 100644 web/app/(commonLayout)/apps/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/api/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/[documentId]/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/create/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/documents/style.module.css create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/hitTesting/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/settings/page.tsx create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/style.module.css create mode 100644 web/app/(commonLayout)/datasets/(datasetDetailLayout)/layout.tsx create mode 100644 web/app/(commonLayout)/datasets/DatasetCard.tsx create mode 100644 web/app/(commonLayout)/datasets/DatasetFooter.tsx create mode 100644 web/app/(commonLayout)/datasets/Datasets.tsx create mode 100644 web/app/(commonLayout)/datasets/NewDatasetCard.tsx create mode 100644 web/app/(commonLayout)/datasets/assets/application.svg create mode 100644 web/app/(commonLayout)/datasets/assets/doc.svg create mode 100644 web/app/(commonLayout)/datasets/assets/text.svg create mode 100644 web/app/(commonLayout)/datasets/create/page.tsx create mode 100644 web/app/(commonLayout)/datasets/page.tsx create mode 100644 web/app/(commonLayout)/layout.tsx create mode 100644 web/app/(commonLayout)/list.module.css create mode 100644 web/app/(commonLayout)/plugins-coming-soon/assets/coming-soon.png create mode 100644 web/app/(commonLayout)/plugins-coming-soon/assets/plugins-bg.png create mode 100644 web/app/(commonLayout)/plugins-coming-soon/page.module.css create mode 100644 web/app/(commonLayout)/plugins-coming-soon/page.tsx create mode 100644 web/app/(shareLayout)/chat/[token]/page.tsx create mode 100644 web/app/(shareLayout)/completion/[token]/page.tsx create mode 100644 web/app/(shareLayout)/layout.tsx create mode 100644 web/app/api/hello/route.ts create mode 100644 web/app/components/app-sidebar/basic.tsx create mode 100644 web/app/components/app-sidebar/index.tsx create mode 100644 web/app/components/app-sidebar/navLink.tsx create mode 100644 web/app/components/app-sidebar/style.module.css create mode 100644 web/app/components/app/chat/icons/answer.svg create mode 100644 web/app/components/app/chat/icons/default-avatar.jpg create mode 100644 web/app/components/app/chat/icons/edit.svg create mode 100644 web/app/components/app/chat/icons/question.svg create mode 100644 web/app/components/app/chat/icons/robot.svg create mode 100644 web/app/components/app/chat/icons/send-active.svg create mode 100644 web/app/components/app/chat/icons/send.svg create mode 100644 web/app/components/app/chat/icons/typing.svg create mode 100644 web/app/components/app/chat/icons/user.svg create mode 100644 web/app/components/app/chat/index.tsx create mode 100644 web/app/components/app/chat/loading-anim/index.tsx create mode 100644 web/app/components/app/chat/loading-anim/style.module.css create mode 100644 web/app/components/app/chat/style.module.css create mode 100644 web/app/components/app/configuration/base/feature-panel/index.tsx create mode 100644 web/app/components/app/configuration/base/group-name/index.tsx create mode 100644 web/app/components/app/configuration/base/icons/more-like-this-icon.tsx create mode 100644 web/app/components/app/configuration/base/icons/remove-icon/index.tsx create mode 100644 web/app/components/app/configuration/base/icons/remove-icon/style.module.css create mode 100644 web/app/components/app/configuration/base/icons/suggested-questions-after-answer-icon.tsx create mode 100644 web/app/components/app/configuration/base/icons/var-icon.tsx create mode 100644 web/app/components/app/configuration/base/operation-btn/index.tsx create mode 100644 web/app/components/app/configuration/base/var-highlight/index.tsx create mode 100644 web/app/components/app/configuration/base/var-highlight/style.module.css create mode 100644 web/app/components/app/configuration/base/warning-mask/formatting-changed.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/has-not-set-api.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/index.tsx create mode 100644 web/app/components/app/configuration/base/warning-mask/style.module.css create mode 100644 web/app/components/app/configuration/config-model/index.tsx create mode 100644 web/app/components/app/configuration/config-model/param-item.tsx create mode 100644 web/app/components/app/configuration/config-prompt/confirm-add-var/index.tsx create mode 100644 web/app/components/app/configuration/config-prompt/index.tsx create mode 100644 web/app/components/app/configuration/config-var/config-model/index.tsx create mode 100644 web/app/components/app/configuration/config-var/config-model/style.module.css create mode 100644 web/app/components/app/configuration/config-var/config-select/index.tsx create mode 100644 web/app/components/app/configuration/config-var/config-select/style.module.css create mode 100644 web/app/components/app/configuration/config-var/config-string/index.tsx create mode 100644 web/app/components/app/configuration/config-var/index.tsx create mode 100644 web/app/components/app/configuration/config-var/input-type-icon.tsx create mode 100644 web/app/components/app/configuration/config-var/modal-foot.tsx create mode 100644 web/app/components/app/configuration/config-var/select-type-item/index.tsx create mode 100644 web/app/components/app/configuration/config-var/select-type-item/style.module.css create mode 100644 web/app/components/app/configuration/config-var/style.module.css create mode 100644 web/app/components/app/configuration/config/feature/add-feature-btn/index.tsx create mode 100644 web/app/components/app/configuration/config/feature/choose-feature/feature-item/index.tsx create mode 100644 web/app/components/app/configuration/config/feature/choose-feature/index.tsx create mode 100644 web/app/components/app/configuration/config/feature/feature-group/index.tsx create mode 100644 web/app/components/app/configuration/config/feature/use-feature.tsx create mode 100644 web/app/components/app/configuration/config/index.tsx create mode 100644 web/app/components/app/configuration/ctrl-btn-group/index.tsx create mode 100644 web/app/components/app/configuration/ctrl-btn-group/style.module.css create mode 100644 web/app/components/app/configuration/dataset-config/card-item/index.tsx create mode 100644 web/app/components/app/configuration/dataset-config/card-item/style.module.css create mode 100644 web/app/components/app/configuration/dataset-config/index.tsx create mode 100644 web/app/components/app/configuration/dataset-config/select-dataset/index.tsx create mode 100644 web/app/components/app/configuration/dataset-config/select-dataset/style.module.css create mode 100644 web/app/components/app/configuration/dataset-config/type-icon/index.tsx create mode 100644 web/app/components/app/configuration/debug/index.tsx create mode 100644 web/app/components/app/configuration/features/chat-group/index.tsx create mode 100644 web/app/components/app/configuration/features/chat-group/opening-statement/index.tsx create mode 100644 web/app/components/app/configuration/features/chat-group/suggested-questions-after-answer/index.tsx create mode 100644 web/app/components/app/configuration/features/experience-enchance-group/index.tsx create mode 100644 web/app/components/app/configuration/features/experience-enchance-group/more-like-this/index.tsx create mode 100644 web/app/components/app/configuration/index.tsx create mode 100644 web/app/components/app/configuration/prompt-value-panel/index.tsx create mode 100644 web/app/components/app/configuration/toolbox/index.tsx create mode 100644 web/app/components/app/log/filter.tsx create mode 100644 web/app/components/app/log/index.tsx create mode 100644 web/app/components/app/log/list.tsx create mode 100644 web/app/components/app/log/style.module.css create mode 100644 web/app/components/app/overview/appCard.tsx create mode 100644 web/app/components/app/overview/appChart.tsx create mode 100644 web/app/components/app/overview/customize/index.tsx create mode 100644 web/app/components/app/overview/settings/index.tsx create mode 100644 web/app/components/app/overview/settings/style.module.css create mode 100644 web/app/components/app/overview/share-link.tsx create mode 100644 web/app/components/app/overview/style.css create mode 100644 web/app/components/app/text-generate/index.tsx create mode 100644 web/app/components/app/text-generate/item/index.tsx create mode 100644 web/app/components/app/text-generate/saved-items/index.tsx create mode 100644 web/app/components/app/text-generate/saved-items/no-data/index.tsx create mode 100644 web/app/components/base/app-icon/index.tsx create mode 100644 web/app/components/base/app-icon/style.module.css create mode 100644 web/app/components/base/app-unavailable.tsx create mode 100644 web/app/components/base/auto-height-textarea/index.tsx create mode 100644 web/app/components/base/auto-height-textarea/style.module.scss create mode 100644 web/app/components/base/avatar/index.tsx create mode 100644 web/app/components/base/block-input/index.tsx create mode 100644 web/app/components/base/button/index.tsx create mode 100644 web/app/components/base/confirm-ui/index.tsx create mode 100644 web/app/components/base/confirm/index.tsx create mode 100644 web/app/components/base/custom-icon/index.tsx create mode 100644 web/app/components/base/dialog/index.tsx create mode 100644 web/app/components/base/divider/index.tsx create mode 100644 web/app/components/base/divider/style.module.css create mode 100644 web/app/components/base/drawer/index.tsx create mode 100644 web/app/components/base/ga/index.tsx create mode 100644 web/app/components/base/input/index.tsx create mode 100644 web/app/components/base/input/style.module.css create mode 100644 web/app/components/base/loading/index.tsx create mode 100644 web/app/components/base/loading/style.css create mode 100644 web/app/components/base/markdown.tsx create mode 100644 web/app/components/base/modal/index.tsx create mode 100644 web/app/components/base/pagination/index.tsx create mode 100644 web/app/components/base/pagination/style.module.css create mode 100644 web/app/components/base/panel/index.tsx create mode 100644 web/app/components/base/popover/index.tsx create mode 100644 web/app/components/base/popover/style.module.css create mode 100644 web/app/components/base/portal-to-follow-elem/index.tsx create mode 100644 web/app/components/base/radio/component/group/index.tsx create mode 100644 web/app/components/base/radio/component/radio/index.tsx create mode 100644 web/app/components/base/radio/context/index.tsx create mode 100644 web/app/components/base/radio/index.tsx create mode 100644 web/app/components/base/radio/style.module.css create mode 100644 web/app/components/base/select-support-portal/index.tsx create mode 100644 web/app/components/base/select/index.tsx create mode 100644 web/app/components/base/select/locale.tsx create mode 100644 web/app/components/base/slider/index.tsx create mode 100644 web/app/components/base/slider/style.css create mode 100644 web/app/components/base/spinner/index.tsx create mode 100644 web/app/components/base/switch/index.tsx create mode 100644 web/app/components/base/tab-header/index.tsx create mode 100644 web/app/components/base/tab-header/style.module.css create mode 100644 web/app/components/base/tag/index.tsx create mode 100644 web/app/components/base/toast/index.tsx create mode 100644 web/app/components/base/toast/style.module.css create mode 100644 web/app/components/base/tooltip/index.tsx create mode 100644 web/app/components/datasets/api/index.tsx create mode 100644 web/app/components/datasets/create/assets/Loading.svg create mode 100644 web/app/components/datasets/create/assets/alert-triangle.svg create mode 100644 web/app/components/datasets/create/assets/annotation-info.svg create mode 100644 web/app/components/datasets/create/assets/arrow-narrow-left.svg create mode 100644 web/app/components/datasets/create/assets/book-open-01.svg create mode 100644 web/app/components/datasets/create/assets/check.svg create mode 100644 web/app/components/datasets/create/assets/close.svg create mode 100644 web/app/components/datasets/create/assets/file.svg create mode 100644 web/app/components/datasets/create/assets/folder-plus.svg create mode 100644 web/app/components/datasets/create/assets/html.svg create mode 100644 web/app/components/datasets/create/assets/json.svg create mode 100644 web/app/components/datasets/create/assets/md.svg create mode 100644 web/app/components/datasets/create/assets/notion.svg create mode 100644 web/app/components/datasets/create/assets/pdf.svg create mode 100644 web/app/components/datasets/create/assets/piggy-bank-01.svg create mode 100644 web/app/components/datasets/create/assets/sliders-02.svg create mode 100644 web/app/components/datasets/create/assets/star-07.svg create mode 100644 web/app/components/datasets/create/assets/trash.svg create mode 100644 web/app/components/datasets/create/assets/txt.svg create mode 100644 web/app/components/datasets/create/assets/unknow.svg create mode 100644 web/app/components/datasets/create/assets/upload-cloud-01.svg create mode 100644 web/app/components/datasets/create/assets/web.svg create mode 100644 web/app/components/datasets/create/assets/zap-fast.svg create mode 100644 web/app/components/datasets/create/empty-dataset-creation-modal/index.module.css create mode 100644 web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx create mode 100644 web/app/components/datasets/create/file-preview/index.module.css create mode 100644 web/app/components/datasets/create/file-preview/index.tsx create mode 100644 web/app/components/datasets/create/file-uploader/index.module.css create mode 100644 web/app/components/datasets/create/file-uploader/index.tsx create mode 100644 web/app/components/datasets/create/index.module.css create mode 100644 web/app/components/datasets/create/index.tsx create mode 100644 web/app/components/datasets/create/step-one/index.module.css create mode 100644 web/app/components/datasets/create/step-one/index.tsx create mode 100644 web/app/components/datasets/create/step-three/index.module.css create mode 100644 web/app/components/datasets/create/step-three/index.tsx create mode 100644 web/app/components/datasets/create/step-two/index.module.css create mode 100644 web/app/components/datasets/create/step-two/index.tsx create mode 100644 web/app/components/datasets/create/step-two/preview-item/index.tsx create mode 100644 web/app/components/datasets/create/steps-nav-bar/index.module.css create mode 100644 web/app/components/datasets/create/steps-nav-bar/index.tsx create mode 100644 web/app/components/datasets/create/stop-embedding-modal/index.module.css create mode 100644 web/app/components/datasets/create/stop-embedding-modal/index.tsx create mode 100644 web/app/components/datasets/documents/assets/action.svg create mode 100644 web/app/components/datasets/documents/assets/atSign.svg create mode 100644 web/app/components/datasets/documents/assets/bezierCurve.svg create mode 100644 web/app/components/datasets/documents/assets/bookOpen.svg create mode 100644 web/app/components/datasets/documents/assets/briefcase.svg create mode 100644 web/app/components/datasets/documents/assets/cardLoading.svg create mode 100644 web/app/components/datasets/documents/assets/file.svg create mode 100644 web/app/components/datasets/documents/assets/globe.svg create mode 100644 web/app/components/datasets/documents/assets/graduationHat.svg create mode 100644 web/app/components/datasets/documents/assets/hitLoading.svg create mode 100644 web/app/components/datasets/documents/assets/html.svg create mode 100644 web/app/components/datasets/documents/assets/json.svg create mode 100644 web/app/components/datasets/documents/assets/layoutRightClose.svg create mode 100644 web/app/components/datasets/documents/assets/layoutRightShow.svg create mode 100644 web/app/components/datasets/documents/assets/md.svg create mode 100644 web/app/components/datasets/documents/assets/messageTextCircle.svg create mode 100644 web/app/components/datasets/documents/assets/normal.svg create mode 100644 web/app/components/datasets/documents/assets/pdf.svg create mode 100644 web/app/components/datasets/documents/assets/star.svg create mode 100644 web/app/components/datasets/documents/assets/target.svg create mode 100644 web/app/components/datasets/documents/assets/txt.svg create mode 100644 web/app/components/datasets/documents/assets/typeSquare.svg create mode 100644 web/app/components/datasets/documents/detail/completed/InfiniteVirtualList.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/SegmentCard.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/index.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/style.module.css create mode 100644 web/app/components/datasets/documents/detail/embedding/index.tsx create mode 100644 web/app/components/datasets/documents/detail/embedding/style.module.css create mode 100644 web/app/components/datasets/documents/detail/index.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/index.tsx create mode 100644 web/app/components/datasets/documents/detail/metadata/style.module.css create mode 100644 web/app/components/datasets/documents/detail/style.module.css create mode 100644 web/app/components/datasets/documents/index.tsx create mode 100644 web/app/components/datasets/documents/list.tsx create mode 100644 web/app/components/datasets/documents/style.module.css create mode 100644 web/app/components/datasets/hit-testing/assets/clock.svg create mode 100644 web/app/components/datasets/hit-testing/assets/grid.svg create mode 100644 web/app/components/datasets/hit-testing/assets/plugin.svg create mode 100644 web/app/components/datasets/hit-testing/hit-detail.tsx create mode 100644 web/app/components/datasets/hit-testing/index.tsx create mode 100644 web/app/components/datasets/hit-testing/style.module.css create mode 100644 web/app/components/datasets/hit-testing/textarea.tsx create mode 100644 web/app/components/datasets/settings/form/index.tsx create mode 100644 web/app/components/datasets/settings/index-method-radio/assets/economy.svg create mode 100644 web/app/components/datasets/settings/index-method-radio/assets/high-quality.svg create mode 100644 web/app/components/datasets/settings/index-method-radio/index.module.css create mode 100644 web/app/components/datasets/settings/index-method-radio/index.tsx create mode 100644 web/app/components/datasets/settings/permissions-radio/assets/user.svg create mode 100644 web/app/components/datasets/settings/permissions-radio/index.module.css create mode 100644 web/app/components/datasets/settings/permissions-radio/index.tsx create mode 100644 web/app/components/develop/code.tsx create mode 100644 web/app/components/develop/doc.tsx create mode 100644 web/app/components/develop/index.tsx create mode 100644 web/app/components/develop/md.tsx create mode 100644 web/app/components/develop/secret-key/assets/copied.svg create mode 100644 web/app/components/develop/secret-key/assets/copy-hover.svg create mode 100644 web/app/components/develop/secret-key/assets/copy.svg create mode 100644 web/app/components/develop/secret-key/assets/trash-gray.svg create mode 100644 web/app/components/develop/secret-key/assets/trash-red.svg create mode 100644 web/app/components/develop/secret-key/input-copy.tsx create mode 100644 web/app/components/develop/secret-key/secret-key-button.tsx create mode 100644 web/app/components/develop/secret-key/secret-key-generate.tsx create mode 100644 web/app/components/develop/secret-key/secret-key-modal.tsx create mode 100644 web/app/components/develop/secret-key/style.module.css create mode 100644 web/app/components/develop/tag.tsx create mode 100644 web/app/components/develop/template/template.en.mdx create mode 100644 web/app/components/develop/template/template.zh.mdx create mode 100644 web/app/components/develop/template/template_chat.en.mdx create mode 100644 web/app/components/develop/template/template_chat.zh.mdx create mode 100644 web/app/components/header/account-about/index.module.css create mode 100644 web/app/components/header/account-about/index.tsx create mode 100644 web/app/components/header/account-dropdown/index.tsx create mode 100644 web/app/components/header/account-dropdown/workplace-selector/index.module.css create mode 100644 web/app/components/header/account-dropdown/workplace-selector/index.tsx create mode 100644 web/app/components/header/account-setting/Integrations-page/index.module.css create mode 100644 web/app/components/header/account-setting/Integrations-page/index.tsx create mode 100644 web/app/components/header/account-setting/account-page/index.module.css create mode 100644 web/app/components/header/account-setting/account-page/index.tsx create mode 100644 web/app/components/header/account-setting/collapse/index.tsx create mode 100644 web/app/components/header/account-setting/index.module.css create mode 100644 web/app/components/header/account-setting/index.tsx create mode 100644 web/app/components/header/account-setting/language-page/index.module.css create mode 100644 web/app/components/header/account-setting/language-page/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/invite-modal/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/invite-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/invited-modal/index.tsx create mode 100644 web/app/components/header/account-setting/members-page/operation/index.module.css create mode 100644 web/app/components/header/account-setting/members-page/operation/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/azure-provider/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/index.module.css create mode 100644 web/app/components/header/account-setting/provider-page/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/openai-hosted-provider/index.module.css create mode 100644 web/app/components/header/account-setting/provider-page/openai-hosted-provider/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/openai-provider/index.module.css create mode 100644 web/app/components/header/account-setting/provider-page/openai-provider/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/openai-provider/provider.tsx create mode 100644 web/app/components/header/account-setting/provider-page/provider-input/index.tsx create mode 100644 web/app/components/header/account-setting/provider-page/provider-input/useValidateToken.ts create mode 100644 web/app/components/header/account-setting/provider-page/provider-item/index.module.css create mode 100644 web/app/components/header/account-setting/provider-page/provider-item/index.tsx create mode 100644 web/app/components/header/app-back/index.tsx create mode 100644 web/app/components/header/app-selector/index.tsx create mode 100644 web/app/components/header/assets/alpha.svg create mode 100644 web/app/components/header/assets/anthropic.svg create mode 100644 web/app/components/header/assets/azure.svg create mode 100644 web/app/components/header/assets/beaker.svg create mode 100644 web/app/components/header/assets/bitbucket.svg create mode 100644 web/app/components/header/assets/github.svg create mode 100644 web/app/components/header/assets/google.svg create mode 100644 web/app/components/header/assets/gpt.svg create mode 100644 web/app/components/header/assets/hugging-face.svg create mode 100644 web/app/components/header/assets/logo-icon.png create mode 100644 web/app/components/header/assets/logo-text.svg create mode 100644 web/app/components/header/assets/logo.png create mode 100644 web/app/components/header/assets/salesforce.svg create mode 100644 web/app/components/header/assets/twitter.svg create mode 100644 web/app/components/header/index.module.css create mode 100644 web/app/components/header/index.tsx create mode 100644 web/app/components/header/indicator/index.tsx create mode 100644 web/app/components/header/nav/index.module.css create mode 100644 web/app/components/header/nav/index.tsx create mode 100644 web/app/components/header/nav/nav-selector/index.tsx create mode 100644 web/app/components/i18n-server.tsx create mode 100644 web/app/components/i18n.tsx create mode 100644 web/app/components/locale-switcher.tsx create mode 100644 web/app/components/share/chat/config-scence/index.tsx create mode 100644 web/app/components/share/chat/hooks/use-conversation.ts create mode 100644 web/app/components/share/chat/index.tsx create mode 100644 web/app/components/share/chat/sidebar/card.module.css create mode 100644 web/app/components/share/chat/sidebar/card.tsx create mode 100644 web/app/components/share/chat/sidebar/index.tsx create mode 100644 web/app/components/share/chat/value-panel/index.tsx create mode 100644 web/app/components/share/chat/value-panel/style.module.css create mode 100644 web/app/components/share/chat/welcome/icons/logo.png create mode 100644 web/app/components/share/chat/welcome/index.tsx create mode 100644 web/app/components/share/chat/welcome/massive-component.tsx create mode 100644 web/app/components/share/chat/welcome/style.module.css create mode 100644 web/app/components/share/header.tsx create mode 100644 web/app/components/share/text-generation/config-scence/index.tsx create mode 100644 web/app/components/share/text-generation/history/index.tsx create mode 100644 web/app/components/share/text-generation/icons/app-icon.svg create mode 100644 web/app/components/share/text-generation/icons/star.svg create mode 100644 web/app/components/share/text-generation/index.tsx create mode 100644 web/app/components/share/text-generation/no-data/index.tsx create mode 100644 web/app/components/share/text-generation/result/header.tsx create mode 100644 web/app/components/share/text-generation/result/index.tsx create mode 100644 web/app/components/share/text-generation/style.module.css create mode 100644 web/app/components/with-i18n.tsx create mode 100644 web/app/install/installForm.tsx create mode 100644 web/app/install/page.tsx create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.module.css create mode 100644 web/app/page.tsx create mode 100644 web/app/signin/_header.tsx create mode 100644 web/app/signin/assets/background.png create mode 100644 web/app/signin/assets/github.svg create mode 100644 web/app/signin/assets/google.svg create mode 100644 web/app/signin/assets/logo-icon.svg create mode 100644 web/app/signin/assets/logo-text.svg create mode 100644 web/app/signin/forms.tsx create mode 100644 web/app/signin/normalForm.tsx create mode 100644 web/app/signin/oneMoreStep.tsx create mode 100644 web/app/signin/page.module.css create mode 100644 web/app/signin/page.tsx create mode 100644 web/app/styles/globals.css create mode 100644 web/app/styles/markdown.scss create mode 100644 web/config/index.ts create mode 100644 web/context/app-context.ts create mode 100644 web/context/dataset-detail.ts create mode 100644 web/context/datasets-context.tsx create mode 100644 web/context/debug-configuration.ts create mode 100644 web/context/i18n.ts create mode 100644 web/context/workspace-context.tsx create mode 100644 web/dictionaries/en.json create mode 100644 web/dictionaries/zh-Hans.json create mode 100644 web/docker/entrypoint.sh create mode 100644 web/docker/pm2.json create mode 100644 web/hooks/use-breakpoints.ts create mode 100644 web/hooks/use-copy-to-clipboard.ts create mode 100644 web/hooks/use-metadata.ts create mode 100644 web/i18n/client.ts create mode 100644 web/i18n/i18next-config.ts create mode 100644 web/i18n/i18next-serverside-config.ts create mode 100644 web/i18n/index.ts create mode 100644 web/i18n/lang/app-api.en.ts create mode 100644 web/i18n/lang/app-api.zh.ts create mode 100644 web/i18n/lang/app-debug.en.ts create mode 100644 web/i18n/lang/app-debug.zh.ts create mode 100644 web/i18n/lang/app-log.en.ts create mode 100644 web/i18n/lang/app-log.zh.ts create mode 100644 web/i18n/lang/app-overview.en.ts create mode 100644 web/i18n/lang/app-overview.zh.ts create mode 100644 web/i18n/lang/app.en.ts create mode 100644 web/i18n/lang/app.zh.ts create mode 100644 web/i18n/lang/common.en.ts create mode 100644 web/i18n/lang/common.zh.ts create mode 100644 web/i18n/lang/dataset-creation.en.ts create mode 100644 web/i18n/lang/dataset-creation.zh.ts create mode 100644 web/i18n/lang/dataset-documents.en.ts create mode 100644 web/i18n/lang/dataset-documents.zh.ts create mode 100644 web/i18n/lang/dataset-hit-testing.en.ts create mode 100644 web/i18n/lang/dataset-hit-testing.zh.ts create mode 100644 web/i18n/lang/dataset-settings.en.ts create mode 100644 web/i18n/lang/dataset-settings.zh.ts create mode 100644 web/i18n/lang/dataset.en.ts create mode 100644 web/i18n/lang/dataset.zh.ts create mode 100644 web/i18n/lang/layout.en.ts create mode 100644 web/i18n/lang/layout.zh.ts create mode 100644 web/i18n/lang/login.en.ts create mode 100644 web/i18n/lang/login.zh.ts create mode 100644 web/i18n/lang/register.en.ts create mode 100644 web/i18n/lang/register.zh.ts create mode 100644 web/i18n/lang/share-app.en.ts create mode 100644 web/i18n/lang/share-app.zh.ts create mode 100644 web/i18n/server.ts create mode 100644 web/middleware.ts create mode 100644 web/models/app.ts create mode 100644 web/models/common.ts create mode 100644 web/models/datasets.ts create mode 100644 web/models/debug.ts create mode 100644 web/models/history.ts create mode 100644 web/models/log.ts create mode 100644 web/models/share.ts create mode 100644 web/models/user.ts create mode 100644 web/next.config.js create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/public/favicon.ico create mode 100644 web/service/apps.ts create mode 100644 web/service/base.ts create mode 100644 web/service/common.ts create mode 100644 web/service/datasets.ts create mode 100644 web/service/debug.ts create mode 100644 web/service/demo/index.tsx create mode 100644 web/service/log.ts create mode 100644 web/service/share.ts create mode 100644 web/tailwind.config.js create mode 100644 web/test/factories/index.ts create mode 100644 web/test/test_util.ts create mode 100644 web/tsconfig.json create mode 100644 web/types/app.ts create mode 100644 web/typography.js create mode 100644 web/utils/format.ts create mode 100644 web/utils/index.ts create mode 100644 web/utils/language.ts create mode 100644 web/utils/model-config.ts create mode 100644 web/utils/timezone.ts create mode 100644 web/utils/var.ts diff --git a/.github/workflows/build-api-image.sh b/.github/workflows/build-api-image.sh new file mode 100644 index 0000000000..b7c92525fb --- /dev/null +++ b/.github/workflows/build-api-image.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -eo pipefail + +SHA=$(git rev-parse HEAD) +REPO_NAME=langgenius/dify +API_REPO_NAME="${REPO_NAME}-api" + +if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + REFSPEC=$(echo "${GITHUB_HEAD_REF}" | sed 's/[^a-zA-Z0-9]/-/g' | head -c 40) + PR_NUM=$(echo "${GITHUB_REF}" | sed 's:refs/pull/::' | sed 's:/merge::') + LATEST_TAG="pr-${PR_NUM}" + CACHE_FROM_TAG="latest" +elif [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + REFSPEC=$(echo "${GITHUB_REF}" | sed 's:refs/tags/::' | head -c 40) + LATEST_TAG="${REFSPEC}" + CACHE_FROM_TAG="latest" +else + REFSPEC=$(echo "${GITHUB_REF}" | sed 's:refs/heads/::' | sed 's/[^a-zA-Z0-9]/-/g' | head -c 40) + LATEST_TAG="${REFSPEC}" + CACHE_FROM_TAG="${REFSPEC}" +fi + +if [[ "${REFSPEC}" == "main" ]]; then + LATEST_TAG="latest" + CACHE_FROM_TAG="latest" +fi + +echo "Pulling cache image ${API_REPO_NAME}:${CACHE_FROM_TAG}" +if docker pull "${API_REPO_NAME}:${CACHE_FROM_TAG}"; then + API_CACHE_FROM_SCRIPT="--cache-from ${API_REPO_NAME}:${CACHE_FROM_TAG}" +else + echo "WARNING: Failed to pull ${API_REPO_NAME}:${CACHE_FROM_TAG}, disable build image cache." + API_CACHE_FROM_SCRIPT="" +fi + + +cat</langgenius-gateway.git +``` + +### Install backend + +To learn how to install the backend application, please refer to the [Backend README](api/README.md). + +### Install frontend + +To learn how to install the frontend application, please refer to the [Frontend README](web/README.md). + +### Visit dify in your browser + +Finally, you can now visit [http://localhost:3000](http://localhost:3000) to view the [Dify](https://dify.ai) in local environment. + + +## Create a pull request + +After making your changes, open a pull request (PR). Once you submit your pull request, others from the Dify team/community will review it with you. + +Did you have an issue, like a merge conflict, or don't know how to open a pull request? Check out [GitHub's pull request tutorial](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests) on how to resolve merge conflicts and other issues. Once your PR has been merged, you will be proudly listed as a contributor in the [contributor chart](https://github.com/langgenius/langgenius-gateway/graphs/contributors). + +## Community channels + +Stuck somewhere? Have any questions? Join the [Discord Community Server](https://discord.gg/AhzKf7dNgk). We are here to help! diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md new file mode 100644 index 0000000000..51327d24b7 --- /dev/null +++ b/CONTRIBUTING_CN.md @@ -0,0 +1,53 @@ +# 贡献 + +感谢您对 [Dify](https://dify.ai) 的兴趣,并希望您能够做出贡献!在开始之前,请先阅读[行为准则](https://github.com/langgenius/.github/blob/main/CODE_OF_CONDUCT.md)并查看[现有问题](https://github.com/langgenius/dify/issues)。 +本文档介绍了如何设置开发环境以构建和测试 [Dify](https://dify.ai)。 + +### 安装依赖项 + +您需要在计算机上安装和配置以下依赖项才能构建 [Dify](https://dify.ai): + +- [Git](http://git-scm.com/) +- [Docker](https://www.docker.com/) +- [Docker Compose](https://docs.docker.com/compose/install/) +- [Node.js v18.x (LTS)](http://nodejs.org) +- [npm](https://www.npmjs.com/) 版本 8.x.x 或 [Yarn](https://yarnpkg.com/) +- [Python](https://www.python.org/) 版本 3.10.x + +## 本地开发 + +要设置一个可工作的开发环境,只需 fork 项目的 git 存储库,并使用适当的软件包管理器安装后端和前端依赖项,然后创建并运行 docker-compose 堆栈。 + +### Fork存储库 + +您需要 fork [存储库](https://github.com/langgenius/dify)。 + +### 克隆存储库 + +克隆您在 GitHub 上 fork 的存储库: + +``` +git clone git@github.com:/dify.git +``` + +### 安装后端 + +要了解如何安装后端应用程序,请参阅[后端 README](api/README.md)。 + +### 安装前端 + +要了解如何安装前端应用程序,请参阅[前端 README](web/README.md)。 + +### 在浏览器中访问 Dify + +最后,您现在可以访问 [http://localhost:3000](http://localhost:3000) 在本地环境中查看 [Dify](https://dify.ai)。 + +## 创建拉取请求 + +在进行更改后,打开一个拉取请求(PR)。提交拉取请求后,Dify 团队/社区的其他人将与您一起审查它。 + +如果遇到问题,比如合并冲突或不知道如何打开拉取请求,请查看 GitHub 的[拉取请求教程](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests),了解如何解决合并冲突和其他问题。一旦您的 PR 被合并,您将自豪地被列为[贡献者表](https://github.com/langgenius/dify/graphs/contributors)中的一员。 + +## 社区渠道 + +遇到困难了吗?有任何问题吗? 加入 [Discord Community Server](https://discord.gg/AhzKf7dNgk),我们将为您提供帮助。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..d5d166643f --- /dev/null +++ b/LICENSE @@ -0,0 +1,46 @@ +# Dify Open Source License + +The Dify project uses a combination of the Apache License 2.0, MIT License, and an additional agreement to protect against direct competition with Dify Cloud services. + +As a contributor, you should agree that your contributed code: +a. Might be subject to a more permissive open source license in the future. +b. Can be used for commercial purposes, such as Dify's cloud business. + +The following components are open source under the MIT license, allowing you to build and develop applications based on them: +- WebApp elements, e.g., web/app/components/share +- Derived WebApp Template projects + +The remaining parts of the project are open source under the Apache License 2.0. + +With the Apache License 2.0, MIT License, and this supplementary agreement, anyone can freely use, modify, and distribute Dify, provided that: + +- If you use Dify solely as a backend service for other applications, no authorization is needed for commercial or closed source purposes. +- If you wish to use Dify for commercial and closed source SaaS services similar to Dify Cloud, please contact us for authorization. + +The interactive design of this product is protected by appearance patent. + +© 2023 LangGenius, Inc. + +---------- + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +---------- +The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..169618b79f --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +![](./images/describe-en.png) +

+ English | + 简体中文 +

+ +[Website](http://dify.ai) • [Docs](https://docs.dify.ai) • [Twitter](https://twitter.com/dify_ai) + +**Dify** is an easy-to-use LLMOps platform designed to empower more people to create sustainable, AI-native applications. With visual orchestration for various application types, Dify offers out-of-the-box, ready-to-use applications that can also serve as Backend-as-a-Service APIs. Unify your development process with one API for plugins and datasets integration, and streamline your operations using a single interface for prompt engineering, visual analytics, and continuous improvement. + +Applications created with Dify include: + +Out-of-the-box web sites supporting form mode and chat conversation mode +A single API encompassing plugin capabilities, context enhancement, and more, saving you backend coding effort +Visual data analysis, log review, and annotation for applications +Dify is compatible with Langchain, meaning we'll gradually support multiple LLMs, currently supported: + +- GPT 3 (text-davinci-003) +- GPT 3.5 Turbo(ChatGPT) +- GPT-4 + +## Use Cloud Services + +Visit [Dify.ai](http://dify.ai) + +## Install the Community Edition + +### System Requirements + +Before installing Dify, make sure your machine meets the following minimum system requirements: + +- CPU >= 1 Core +- RAM >= 4GB + +### Quick Start + +The easiest way to start the Dify server is to run our [docker-compose.yml](docker/docker-compose.yaml) file. Before running the installation command, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine: + +```bash +cd docker +docker-compose up -d +``` + +After running, you can access the Dify console in your browser at [http://localhost](http://localhost) and start the initialization operation. + +### Configuration + +If you need to customize the configuration, please refer to the comments in our [docker-compose.yml](docker/docker-compose.yaml) file and manually set the environment configuration. After making the changes, please run 'docker-compose up -d' again. + +## Roadmap + +Features under development: + +- **Datasets**, supporting more datasets, e.g. syncing content from Notion or webpages +We will support more datasets, including text, webpages, and even Notion content. Users can build AI applications based on their own data sources. +- **Plugins**, introducing ChatGPT Plugin-standard plugins for applications, or using Dify-produced plugins +We will release plugins complying with ChatGPT standard, or Dify's own plugins to enable more capabilities in applications. +- **Open-source models**, e.g. adopting Llama as a model provider or for further fine-tuning +We will work with excellent open-source models like Llama, by providing them as model options in our platform, or using them for further fine-tuning. + + +## Q&A + +**Q: What can I do with Dify?** + +A: Dify is a simple yet powerful LLM development and operations tool. You can use it to build commercial-grade applications, personal assistants. If you want to develop your own applications, LangDifyGenius can save you backend work in integrating with OpenAI and offer visual operations capabilities, allowing you to continuously improve and train your GPT model. + +**Q: How do I use Dify to "train" my own model?** + +A: A valuable application consists of Prompt Engineering, context enhancement, and Fine-tuning. We've created a hybrid programming approach combining Prompts with programming languages (similar to a template engine), making it easy to accomplish long-text embedding or capturing subtitles from a user-input Youtube video - all of which will be submitted as context for LLMs to process. We place great emphasis on application operability, with data generated by users during App usage available for analysis, annotation, and continuous training. Without the right tools, these steps can be time-consuming. + +**Q: What do I need to prepare if I want to create my own application?** + +A: We assume you already have an OpenAI API Key; if not, please register for one. If you already have some content that can serve as training context, that's great! + +**Q: What interface languages are available?** + +A: English and Chinese are currently supported, and you can contribute language packs to us. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=langgenius/dify&type=Date)](https://star-history.com/#langgenius/dify&Date) + +## Contact Us + +If you have any questions, suggestions, or partnership inquiries, feel free to contact us through the following channels: + +- Submit an Issue or PR on our GitHub Repo +- Join the discussion in our [Discord](https://discord.gg/AhzKf7dNgk) Community +- Send an email to hello@dify.ai + +We're eager to assist you and together create more fun and useful AI applications! + +## Contributing + +To ensure proper review, all code contributions - including those from contributors with direct commit access - must be submitted via pull requests and approved by the core development team prior to being merged. + +We welcome all pull requests! If you'd like to help, check out the [Contribution Guide](CONTRIBUTING.md) for more information on how to get started. + +## Security + +To protect your privacy, please avoid posting security issues on GitHub. Instead, send your questions to security@dify.ai and we will provide you with a more detailed answer. + +## Citation + +This software uses the following open-source software: + +- Chase, H. (2022). LangChain [Computer software]. https://github.com/hwchase17/langchain +- Liu, J. (2022). LlamaIndex [Computer software]. doi: 10.5281/zenodo.1234. + +For more information, please refer to the official website or license text of the respective software. + +## License + +This repository is available under the [Dify Open Source License](LICENSE). diff --git a/README_CN.md b/README_CN.md new file mode 100644 index 0000000000..3788c857fc --- /dev/null +++ b/README_CN.md @@ -0,0 +1,114 @@ +![](./images/describe-cn.jpg) +

+ English | + 简体中文 +

+ + +[官方网站](http://dify.ai) • [文档](https://docs.dify.ai/v/zh-hans) • [Twitter](https://twitter.com/dify_ai) + +**Dify** 是一个易用的 LLMOps 平台,旨在让更多人可以创建可持续运营的原生 AI 应用。Dify 提供多种类型应用的可视化编排,应用可开箱即用,也能以“后端即服务”的 API 提供服务。 + +通过 Dify 创建的应用包含了: + +- 开箱即用的的 Web 站点,支持表单模式和聊天对话模式 +- 一套 API 即可包含插件、上下文增强等能力,替你省下了后端代码的编写工作 +- 可视化的对应用进行数据分析,查阅日志或进行标注 + +Dify 兼容 Langchain,这意味着我们将逐步支持多种 LLMs ,目前已支持: + +- GPT 3 (text-davinci-003) +- GPT 3.5 Turbo(ChatGPT) +- GPT-4 + +## 使用云服务 + +访问 [Dify.ai](http://cloud.dify.ai) + +## 安装社区版 + +### 系统要求 + +在安装 Dify 之前,请确保您的机器满足以下最低系统要求: + +- CPU >= 1 Core +- RAM >= 4GB + +### 快速启动 + +启动 Dify 服务器的最简单方法是运行我们的 [docker-compose.yml](docker/docker-compose.yaml) 文件。在运行安装命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/): + +```bash +cd docker +docker-compose up -d +``` + +运行后,可以在浏览器上访问 [http://localhost](http://localhost) 进入 Dify 控制台,并开始初始化操作。 + +### 配置 + +需要自定义配置,请参考我们的 [docker-compose.yml](docker/docker-compose.yaml) 文件中的注释,并手动设置环境配置,修改完毕后,请再次执行 `docker-compose up -d`。 + +## Roadmap + +我们正在开发中的功能: + +- **数据集**,支持更多的数据集,例如同步 Notion 或网页的内容 +我们将支持更多的数据集,包括文本、网页,甚至 Notion 内容。用户可以根据自己的数据源构建 AI 应用程序。 +- **插件**,推出符合 ChatGPT 标准的插件,或使用 Dify 产生的插件 +我们将发布符合 ChatGPT 标准的插件,或者 Dify 自己的插件,以在应用程序中启用更多功能。 +- **开源模型**,例如采用 Llama 作为模型提供者,或进行进一步的微调 +我们将与优秀的开源模型如 Llama 合作,通过在我们的平台中提供它们作为模型选项,或使用它们进行进一步的微调。 + +## Q&A + +**Q: 我能用 Dify 做什么?** + +A: Dify 是一个简单且能力丰富的 LLM 开发和运营工具。你可以用它搭建商用级应用,个人助理。如果你想自己开发应用,Dify 也能为你省下接入 OpenAI 的后端工作,使用我们逐步提供的可视化运营能力,你可以持续的改进和训练你的 GPT 模型。 + +**Q: 如何使用 Dify “训练”自己的模型?** + +A: 一个有价值的应用由 Prompt Engineering、上下文增强和 Fine-tune 三个环节组成。我们创造了一种 Prompt 结合编程语言的 Hybrid 编程方式(类似一个模版引擎),你可以轻松的完成长文本嵌入,或抓取用户输入的一个 Youtube 视频的字幕——这些都将作为上下文提交给 LLMs 进行计算。我们十分注重应用的可运营性,你的用户在使用 App 期间产生的数据,可进行分析、标记和持续训练。以上环节如果没有好的工具支持,可能会消耗你大量的时间。 + +**Q: 如果要创建一个自己的应用,我需要准备什么?** + +A: 我们假定你已经有了 OpenAI API Key,如果没有请去注册一个。如果你已经有了一些内容可以作为训练上下文,就太好了。 + +**Q: 提供哪些界面语言?** + +A: 现已支持英文与中文,你可以为我们贡献语言包。 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=langgenius/dify&type=Date)](https://star-history.com/#langgenius/dify&Date) + +## 联系我们 + +如果您有任何问题、建议或合作意向,欢迎通过以下方式联系我们: + +- 在我们的 [GitHub Repo](https://github.com/langgenius/dify) 上提交 Issue 或 PR +- 在我们的 [Discord 社区](https://discord.gg/AhzKf7dNgk) 上加入讨论 +- 发送邮件至 hello@dify.ai + +## 贡献代码 + +为了确保正确审查,所有代码贡献 - 包括来自具有直接提交更改权限的贡献者 - 都必须提交 PR 请求并在合并分支之前得到核心开发人员的批准。 + +我们欢迎所有人提交 PR!如果您愿意提供帮助,可以在 [贡献指南](CONTRIBUTING_CN.md) 中了解有关如何为项目做出贡献的更多信息。 + +## 安全 + +为了保护您的隐私,请避免在 GitHub 上发布安全问题。发送问题至 security@dify.ai,我们将为您做更细致的解答。 + +## Citation + +本软件使用了以下开源软件: + +- Chase, H. (2022). LangChain [Computer software]. https://github.com/hwchase17/langchain +- Liu, J. (2022). LlamaIndex [Computer software]. doi: 10.5281/zenodo.1234. + +更多信息,请参考相应软件的官方网站或许可证文本。 + +## License + +本仓库遵循 [Dify Open Source License](LICENSE) 开源协议。 diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000000..9b5050396d --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,2 @@ +.env +storage/privkeys/* \ No newline at end of file diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000000..5f307dc106 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,85 @@ +# Server Edition +EDITION=SELF_HOSTED + +# Your App secret key will be used for securely signing the session cookie +# Make sure you are changing this key for your deployment with a strong key. +# You can generate a strong key using `openssl rand -base64 42`. +# Alternatively you can set it with `SECRET_KEY` environment variable. +SECRET_KEY= + +# Console API base URL +CONSOLE_URL=http://127.0.0.1:5001 + +# Service API base URL +API_URL=http://127.0.0.1:5001 + +# Web APP base URL +APP_URL=http://127.0.0.1:5001 + +# celery configuration +CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1 + +# redis configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=difyai123456 +REDIS_DB=0 + +# PostgreSQL database configuration +DB_USERNAME=postgres +DB_PASSWORD=difyai123456 +DB_HOST=localhost +DB_PORT=5432 +DB_DATABASE=dify + +# Storage configuration +# use for store upload files, private keys... +# storage type: local, s3 +STORAGE_TYPE=local +STORAGE_LOCAL_PATH=storage +S3_ENDPOINT=https://your-bucket-name.storage.s3.clooudflare.com +S3_BUCKET_NAME=your-bucket-name +S3_ACCESS_KEY=your-access-key +S3_SECRET_KEY=your-secret-key +S3_REGION=your-region + +# CORS configuration +WEB_API_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,* +CONSOLE_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,* + +# Cookie configuration +COOKIE_HTTPONLY=true +COOKIE_SAMESITE=None +COOKIE_SECURE=true + +# Session configuration +SESSION_PERMANENT=true +SESSION_USE_SIGNER=true + +## support redis, sqlalchemy +SESSION_TYPE=redis + +# session redis configuration +SESSION_REDIS_HOST=localhost +SESSION_REDIS_PORT=6379 +SESSION_REDIS_PASSWORD=difyai123456 +SESSION_REDIS_DB=2 + +# Vector database configuration, support: weaviate, qdrant +VECTOR_STORE=weaviate + +# Weaviate configuration +WEAVIATE_ENDPOINT=http://localhost:8080 +WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_GRPC_ENABLED=false + +# Qdrant configuration, use `path:` prefix for local mode or `https://your-qdrant-cluster-url.qdrant.io` for remote mode +QDRANT_URL=path:storage/qdrant +QDRANT_API_KEY=your-qdrant-api-key + +# Sentry configuration +SENTRY_DSN= + +# DEBUG +DEBUG=false +SQLALCHEMY_ECHO=false diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000000..fb451129d1 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,28 @@ +FROM langgenius/base:1.0.0-bullseye-slim as langgenius-api + +LABEL maintainer="takatost@gmail.com" + +ENV FLASK_APP app.py +ENV EDITION SELF_HOSTED +ENV DEPLOY_ENV PRODUCTION +ENV CONSOLE_URL http://127.0.0.1:5001 +ENV API_URL http://127.0.0.1:5001 +ENV APP_URL http://127.0.0.1:5001 + +EXPOSE 5001 + +WORKDIR /app/api + +COPY requirements.txt /app/api/requirements.txt + +RUN pip install -r requirements.txt + +COPY . /app/api/ + +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ARG COMMIT_SHA +ENV COMMIT_SHA ${COMMIT_SHA} + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000000..97f09fc700 --- /dev/null +++ b/api/README.md @@ -0,0 +1,35 @@ +# Dify Backend API + +## Usage + +1. Start the docker-compose stack + + The backend require some middleware, including PostgreSQL, Redis, and Weaviate, which can be started together using `docker-compose`. + + ```bash + cd ../docker + docker-compose -f docker-compose.middleware.yaml up -d + cd ../api + ``` +2. Copy `.env.example` to `.env` +3. Generate a `SECRET_KEY` in the `.env` file. + + ```bash + openssl rand -base64 42 + ``` +4. Install dependencies + ```bash + pip install -r requirements.txt + ``` +5. Run migrate + + Before the first launch, migrate the database to the latest version. + + ```bash + flask db upgrade + ``` +6. Start backend: + ```bash + flask run --host 0.0.0.0 --port=5001 --debug + ``` +7. Setup your application by visiting http://localhost:5001/console/api/setup or other apis... diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000000..b3fcbc220a --- /dev/null +++ b/api/app.py @@ -0,0 +1,222 @@ +# -*- coding:utf-8 -*- +import os +if not os.environ.get("DEBUG") or os.environ.get("DEBUG").lower() != 'true': + from gevent import monkey + monkey.patch_all() + +import logging +import json +import threading + +from flask import Flask, request, Response, session +import flask_login +from flask_cors import CORS + +from extensions import ext_session, ext_celery, ext_sentry, ext_redis, ext_login, ext_vector_store, ext_migrate, \ + ext_database, ext_storage +from extensions.ext_database import db +from extensions.ext_login import login_manager + +# DO NOT REMOVE BELOW +from models import model, account, dataset, web, task +from events import event_handlers +# DO NOT REMOVE ABOVE + +import core +from config import Config, CloudEditionConfig +from commands import register_commands +from models.account import TenantAccountJoin +from models.model import Account, EndUser, App + +import warnings +warnings.simplefilter("ignore", ResourceWarning) + + +class DifyApp(Flask): + pass + +# ------------- +# Configuration +# ------------- + + +config_type = os.getenv('EDITION', default='SELF_HOSTED') # ce edition first + +# ---------------------------- +# Application Factory Function +# ---------------------------- + + +def create_app(test_config=None) -> Flask: + app = DifyApp(__name__) + + if test_config: + app.config.from_object(test_config) + else: + if config_type == "CLOUD": + app.config.from_object(CloudEditionConfig()) + else: + app.config.from_object(Config()) + + app.secret_key = app.config['SECRET_KEY'] + + logging.basicConfig(level=app.config.get('LOG_LEVEL', 'INFO')) + + initialize_extensions(app) + register_blueprints(app) + register_commands(app) + + core.init_app(app) + + return app + + +def initialize_extensions(app): + # Since the application instance is now created, pass it to each Flask + # extension instance to bind it to the Flask application instance (app) + ext_database.init_app(app) + ext_migrate.init(app, db) + ext_redis.init_app(app) + ext_vector_store.init_app(app) + ext_storage.init_app(app) + ext_celery.init_app(app) + ext_session.init_app(app) + ext_login.init_app(app) + ext_sentry.init_app(app) + + +# Flask-Login configuration +@login_manager.user_loader +def load_user(user_id): + """Load user based on the user_id.""" + if request.blueprint == 'console': + # Check if the user_id contains a dot, indicating the old format + if '.' in user_id: + tenant_id, account_id = user_id.split('.') + else: + account_id = user_id + + account = db.session.query(Account).filter(Account.id == account_id).first() + + if account: + workspace_id = session.get('workspace_id') + if workspace_id: + tenant_account_join = db.session.query(TenantAccountJoin).filter( + TenantAccountJoin.account_id == account.id, + TenantAccountJoin.tenant_id == workspace_id + ).first() + + if not tenant_account_join: + tenant_account_join = db.session.query(TenantAccountJoin).filter( + TenantAccountJoin.account_id == account.id).first() + + if tenant_account_join: + account.current_tenant_id = tenant_account_join.tenant_id + session['workspace_id'] = account.current_tenant_id + else: + account.current_tenant_id = workspace_id + else: + tenant_account_join = db.session.query(TenantAccountJoin).filter( + TenantAccountJoin.account_id == account.id).first() + if tenant_account_join: + account.current_tenant_id = tenant_account_join.tenant_id + session['workspace_id'] = account.current_tenant_id + + # Log in the user with the updated user_id + flask_login.login_user(account, remember=True) + + return account + else: + return None + + +@login_manager.unauthorized_handler +def unauthorized_handler(): + """Handle unauthorized requests.""" + return Response(json.dumps({ + 'code': 'unauthorized', + 'message': "Unauthorized." + }), status=401, content_type="application/json") + + +# register blueprint routers +def register_blueprints(app): + from controllers.service_api import bp as service_api_bp + from controllers.web import bp as web_bp + from controllers.console import bp as console_app_bp + + app.register_blueprint(service_api_bp) + + CORS(web_bp, + resources={ + r"/*": {"origins": app.config['WEB_API_CORS_ALLOW_ORIGINS']}}, + supports_credentials=True, + allow_headers=['Content-Type', 'Authorization'], + methods=['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'PATCH'], + expose_headers=['X-Version', 'X-Env'] + ) + + app.register_blueprint(web_bp) + + CORS(console_app_bp, + resources={ + r"/*": {"origins": app.config['CONSOLE_CORS_ALLOW_ORIGINS']}}, + supports_credentials=True, + allow_headers=['Content-Type', 'Authorization'], + methods=['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'PATCH'], + expose_headers=['X-Version', 'X-Env'] + ) + + app.register_blueprint(console_app_bp) + + +# create app +app = create_app() +celery = app.extensions["celery"] + + +if app.config['TESTING']: + print("App is running in TESTING mode") + + +@app.after_request +def after_request(response): + """Add Version headers to the response.""" + response.headers.add('X-Version', app.config['CURRENT_VERSION']) + response.headers.add('X-Env', app.config['DEPLOY_ENV']) + return response + + +@app.route('/health') +def health(): + return Response(json.dumps({ + 'status': 'ok', + 'version': app.config['CURRENT_VERSION'] + }), status=200, content_type="application/json") + + +@app.route('/threads') +def threads(): + num_threads = threading.active_count() + threads = threading.enumerate() + + thread_list = [] + for thread in threads: + thread_name = thread.name + thread_id = thread.ident + is_alive = thread.is_alive() + + thread_list.append({ + 'name': thread_name, + 'id': thread_id, + 'is_alive': is_alive + }) + + return { + 'thread_num': num_threads, + 'threads': thread_list + } + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5001) diff --git a/api/commands.py b/api/commands.py new file mode 100644 index 0000000000..b67b4f8676 --- /dev/null +++ b/api/commands.py @@ -0,0 +1,160 @@ +import datetime +import json +import random +import string + +import click + +from libs.password import password_pattern, valid_password, hash_password +from libs.helper import email as email_validate +from extensions.ext_database import db +from models.account import InvitationCode +from models.model import Account, AppModelConfig, ApiToken, Site, App, RecommendedApp +import secrets +import base64 + + +@click.command('reset-password', help='Reset the account password.') +@click.option('--email', prompt=True, help='The email address of the account whose password you need to reset') +@click.option('--new-password', prompt=True, help='the new password.') +@click.option('--password-confirm', prompt=True, help='the new password confirm.') +def reset_password(email, new_password, password_confirm): + if str(new_password).strip() != str(password_confirm).strip(): + click.echo(click.style('sorry. The two passwords do not match.', fg='red')) + return + account = db.session.query(Account). \ + filter(Account.email == email). \ + one_or_none() + if not account: + click.echo(click.style('sorry. the account: [{}] not exist .'.format(email), fg='red')) + return + try: + valid_password(new_password) + except: + click.echo( + click.style('sorry. The passwords must match {} '.format(password_pattern), fg='red')) + return + + # generate password salt + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + # encrypt password with salt + password_hashed = hash_password(new_password, salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + account.password = base64_password_hashed + account.password_salt = base64_salt + db.session.commit() + click.echo(click.style('Congratulations!, password has been reset.', fg='green')) + + +@click.command('reset-email', help='Reset the account email.') +@click.option('--email', prompt=True, help='The old email address of the account whose email you need to reset') +@click.option('--new-email', prompt=True, help='the new email.') +@click.option('--email-confirm', prompt=True, help='the new email confirm.') +def reset_email(email, new_email, email_confirm): + if str(new_email).strip() != str(email_confirm).strip(): + click.echo(click.style('Sorry, new email and confirm email do not match.', fg='red')) + return + account = db.session.query(Account). \ + filter(Account.email == email). \ + one_or_none() + if not account: + click.echo(click.style('sorry. the account: [{}] not exist .'.format(email), fg='red')) + return + try: + email_validate(new_email) + except: + click.echo( + click.style('sorry. {} is not a valid email. '.format(email), fg='red')) + return + + account.email = new_email + db.session.commit() + click.echo(click.style('Congratulations!, email has been reset.', fg='green')) + + +@click.command('generate-invitation-codes', help='Generate invitation codes.') +@click.option('--batch', help='The batch of invitation codes.') +@click.option('--count', prompt=True, help='Invitation codes count.') +def generate_invitation_codes(batch, count): + if not batch: + now = datetime.datetime.now() + batch = now.strftime('%Y%m%d%H%M%S') + + if not count or int(count) <= 0: + click.echo(click.style('sorry. the count must be greater than 0.', fg='red')) + return + + count = int(count) + + click.echo('Start generate {} invitation codes for batch {}.'.format(count, batch)) + + codes = '' + for i in range(count): + code = generate_invitation_code() + invitation_code = InvitationCode( + code=code, + batch=batch + ) + db.session.add(invitation_code) + click.echo(code) + + codes += code + "\n" + db.session.commit() + + filename = 'storage/invitation-codes-{}.txt'.format(batch) + + with open(filename, 'w') as f: + f.write(codes) + + click.echo(click.style( + 'Congratulations! Generated {} invitation codes for batch {} and saved to the file \'{}\''.format(count, batch, + filename), + fg='green')) + + +def generate_invitation_code(): + code = generate_upper_string() + while db.session.query(InvitationCode).filter(InvitationCode.code == code).count() > 0: + code = generate_upper_string() + + return code + + +def generate_upper_string(): + letters_digits = string.ascii_uppercase + string.digits + result = "" + for i in range(8): + result += random.choice(letters_digits) + + return result + + +@click.command('gen-recommended-apps', help='Number of records to generate') +def generate_recommended_apps(): + print('Generating recommended app data...') + apps = App.query.all() + for app in apps: + recommended_app = RecommendedApp( + app_id=app.id, + description={ + 'en': 'Description for ' + app.name, + 'zh': '描述 ' + app.name + }, + copyright='Copyright ' + str(random.randint(1990, 2020)), + privacy_policy='https://privacypolicy.example.com', + category=random.choice(['Games', 'News', 'Music', 'Sports']), + position=random.randint(1, 100), + install_count=random.randint(100, 100000) + ) + db.session.add(recommended_app) + db.session.commit() + print('Done!') + + +def register_commands(app): + app.cli.add_command(reset_password) + app.cli.add_command(reset_email) + app.cli.add_command(generate_invitation_codes) + app.cli.add_command(generate_recommended_apps) diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000000..04c44f2447 --- /dev/null +++ b/api/config.py @@ -0,0 +1,200 @@ +# -*- coding:utf-8 -*- +import os +from datetime import timedelta + +import dotenv + +from extensions.ext_database import db +from extensions.ext_redis import redis_client + +dotenv.load_dotenv() + +DEFAULTS = { + 'COOKIE_HTTPONLY': 'True', + 'COOKIE_SECURE': 'True', + 'COOKIE_SAMESITE': 'None', + 'DB_USERNAME': 'postgres', + 'DB_PASSWORD': '', + 'DB_HOST': 'localhost', + 'DB_PORT': '5432', + 'DB_DATABASE': 'dify', + 'REDIS_HOST': 'localhost', + 'REDIS_PORT': '6379', + 'REDIS_DB': '0', + 'SESSION_REDIS_HOST': 'localhost', + 'SESSION_REDIS_PORT': '6379', + 'SESSION_REDIS_DB': '2', + 'OAUTH_REDIRECT_PATH': '/console/api/oauth/authorize', + 'OAUTH_REDIRECT_INDEX_PATH': '/', + 'CONSOLE_URL': 'https://cloud.dify.ai', + 'API_URL': 'https://api.dify.ai', + 'APP_URL': 'https://udify.app', + 'STORAGE_TYPE': 'local', + 'STORAGE_LOCAL_PATH': 'storage', + 'CHECK_UPDATE_URL': 'https://updates.dify.ai', + 'SESSION_TYPE': 'sqlalchemy', + 'SESSION_PERMANENT': 'True', + 'SESSION_USE_SIGNER': 'True', + 'DEPLOY_ENV': 'PRODUCTION', + 'SQLALCHEMY_POOL_SIZE': 30, + 'SQLALCHEMY_ECHO': 'False', + 'SENTRY_TRACES_SAMPLE_RATE': 1.0, + 'SENTRY_PROFILES_SAMPLE_RATE': 1.0, + 'WEAVIATE_GRPC_ENABLED': 'True', + 'CELERY_BACKEND': 'database', + 'PDF_PREVIEW': 'True', + 'LOG_LEVEL': 'INFO', +} + + +def get_env(key): + return os.environ.get(key, DEFAULTS.get(key)) + + +def get_bool_env(key): + return get_env(key).lower() == 'true' + + +def get_cors_allow_origins(env, default): + cors_allow_origins = [] + if get_env(env): + for origin in get_env(env).split(','): + cors_allow_origins.append(origin) + else: + cors_allow_origins = [default] + + return cors_allow_origins + + +class Config: + """Application configuration class.""" + + def __init__(self): + # app settings + self.CONSOLE_URL = get_env('CONSOLE_URL') + self.API_URL = get_env('API_URL') + self.APP_URL = get_env('APP_URL') + self.CURRENT_VERSION = "0.2.0" + self.COMMIT_SHA = get_env('COMMIT_SHA') + self.EDITION = "SELF_HOSTED" + self.DEPLOY_ENV = get_env('DEPLOY_ENV') + self.TESTING = False + self.LOG_LEVEL = get_env('LOG_LEVEL') + self.PDF_PREVIEW = get_bool_env('PDF_PREVIEW') + + # Your App secret key will be used for securely signing the session cookie + # Make sure you are changing this key for your deployment with a strong key. + # You can generate a strong key using `openssl rand -base64 42`. + # Alternatively you can set it with `SECRET_KEY` environment variable. + self.SECRET_KEY = get_env('SECRET_KEY') + + # cookie settings + self.REMEMBER_COOKIE_HTTPONLY = get_bool_env('COOKIE_HTTPONLY') + self.SESSION_COOKIE_HTTPONLY = get_bool_env('COOKIE_HTTPONLY') + self.REMEMBER_COOKIE_SAMESITE = get_env('COOKIE_SAMESITE') + self.SESSION_COOKIE_SAMESITE = get_env('COOKIE_SAMESITE') + self.REMEMBER_COOKIE_SECURE = get_bool_env('COOKIE_SECURE') + self.SESSION_COOKIE_SECURE = get_bool_env('COOKIE_SECURE') + self.PERMANENT_SESSION_LIFETIME = timedelta(days=7) + + # session settings, only support sqlalchemy, redis + self.SESSION_TYPE = get_env('SESSION_TYPE') + self.SESSION_PERMANENT = get_bool_env('SESSION_PERMANENT') + self.SESSION_USE_SIGNER = get_bool_env('SESSION_USE_SIGNER') + + # redis settings + self.REDIS_HOST = get_env('REDIS_HOST') + self.REDIS_PORT = get_env('REDIS_PORT') + self.REDIS_PASSWORD = get_env('REDIS_PASSWORD') + self.REDIS_DB = get_env('REDIS_DB') + + # session redis settings + self.SESSION_REDIS_HOST = get_env('SESSION_REDIS_HOST') + self.SESSION_REDIS_PORT = get_env('SESSION_REDIS_PORT') + self.SESSION_REDIS_PASSWORD = get_env('SESSION_REDIS_PASSWORD') + self.SESSION_REDIS_DB = get_env('SESSION_REDIS_DB') + + # storage settings + self.STORAGE_TYPE = get_env('STORAGE_TYPE') + self.STORAGE_LOCAL_PATH = get_env('STORAGE_LOCAL_PATH') + self.S3_ENDPOINT = get_env('S3_ENDPOINT') + self.S3_BUCKET_NAME = get_env('S3_BUCKET_NAME') + self.S3_ACCESS_KEY = get_env('S3_ACCESS_KEY') + self.S3_SECRET_KEY = get_env('S3_SECRET_KEY') + self.S3_REGION = get_env('S3_REGION') + + # vector store settings, only support weaviate, qdrant + self.VECTOR_STORE = get_env('VECTOR_STORE') + + # weaviate settings + self.WEAVIATE_ENDPOINT = get_env('WEAVIATE_ENDPOINT') + self.WEAVIATE_API_KEY = get_env('WEAVIATE_API_KEY') + self.WEAVIATE_GRPC_ENABLED = get_bool_env('WEAVIATE_GRPC_ENABLED') + + # qdrant settings + self.QDRANT_URL = get_env('QDRANT_URL') + self.QDRANT_API_KEY = get_env('QDRANT_API_KEY') + + # cors settings + self.CONSOLE_CORS_ALLOW_ORIGINS = get_cors_allow_origins( + 'CONSOLE_CORS_ALLOW_ORIGINS', self.CONSOLE_URL) + self.WEB_API_CORS_ALLOW_ORIGINS = get_cors_allow_origins( + 'WEB_API_CORS_ALLOW_ORIGINS', '*') + + # sentry settings + self.SENTRY_DSN = get_env('SENTRY_DSN') + self.SENTRY_TRACES_SAMPLE_RATE = float(get_env('SENTRY_TRACES_SAMPLE_RATE')) + self.SENTRY_PROFILES_SAMPLE_RATE = float(get_env('SENTRY_PROFILES_SAMPLE_RATE')) + + # check update url + self.CHECK_UPDATE_URL = get_env('CHECK_UPDATE_URL') + + # database settings + db_credentials = { + key: get_env(key) for key in + ['DB_USERNAME', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_DATABASE'] + } + + self.SQLALCHEMY_DATABASE_URI = f"postgresql://{db_credentials['DB_USERNAME']}:{db_credentials['DB_PASSWORD']}@{db_credentials['DB_HOST']}:{db_credentials['DB_PORT']}/{db_credentials['DB_DATABASE']}" + self.SQLALCHEMY_ENGINE_OPTIONS = {'pool_size': int(get_env('SQLALCHEMY_POOL_SIZE'))} + + self.SQLALCHEMY_ECHO = get_bool_env('SQLALCHEMY_ECHO') + + # celery settings + self.CELERY_BROKER_URL = get_env('CELERY_BROKER_URL') + self.CELERY_BACKEND = get_env('CELERY_BACKEND') + self.CELERY_RESULT_BACKEND = 'db+{}'.format(self.SQLALCHEMY_DATABASE_URI) \ + if self.CELERY_BACKEND == 'database' else self.CELERY_BROKER_URL + + # hosted provider credentials + self.OPENAI_API_KEY = get_env('OPENAI_API_KEY') + + +class CloudEditionConfig(Config): + + def __init__(self): + super().__init__() + + self.EDITION = "CLOUD" + + self.GITHUB_CLIENT_ID = get_env('GITHUB_CLIENT_ID') + self.GITHUB_CLIENT_SECRET = get_env('GITHUB_CLIENT_SECRET') + self.GOOGLE_CLIENT_ID = get_env('GOOGLE_CLIENT_ID') + self.GOOGLE_CLIENT_SECRET = get_env('GOOGLE_CLIENT_SECRET') + self.OAUTH_REDIRECT_PATH = get_env('OAUTH_REDIRECT_PATH') + + +class TestConfig(Config): + + def __init__(self): + super().__init__() + + self.EDITION = "SELF_HOSTED" + self.TESTING = True + + db_credentials = { + key: get_env(key) for key in ['DB_USERNAME', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT'] + } + + # use a different database for testing: dify_test + self.SQLALCHEMY_DATABASE_URI = f"postgresql://{db_credentials['DB_USERNAME']}:{db_credentials['DB_PASSWORD']}@{db_credentials['DB_HOST']}:{db_credentials['DB_PORT']}/dify_test" diff --git a/api/constants/__init__.py b/api/constants/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/constants/model_template.py b/api/constants/model_template.py new file mode 100644 index 0000000000..f8d7e0b74a --- /dev/null +++ b/api/constants/model_template.py @@ -0,0 +1,322 @@ +import json + +from models.model import AppModelConfig, App + +model_templates = { + # completion default mode + 'completion_default': { + 'app': { + 'mode': 'completion', + 'enable_site': True, + 'enable_api': True, + 'is_demo': False, + 'api_rpm': 0, + 'api_rph': 0, + 'status': 'normal' + }, + 'model_config': { + 'provider': 'openai', + 'model_id': 'text-davinci-003', + 'configs': { + 'prompt_template': '', + 'prompt_variables': [], + 'completion_params': { + 'max_token': 512, + 'temperature': 1, + 'top_p': 1, + 'presence_penalty': 0, + 'frequency_penalty': 0, + } + }, + 'model': json.dumps({ + "provider": "openai", + "name": "text-davinci-003", + "completion_params": { + "max_tokens": 512, + "temperature": 1, + "top_p": 1, + "presence_penalty": 0, + "frequency_penalty": 0 + } + }) + } + }, + + # chat default mode + 'chat_default': { + 'app': { + 'mode': 'chat', + 'enable_site': True, + 'enable_api': True, + 'is_demo': False, + 'api_rpm': 0, + 'api_rph': 0, + 'status': 'normal' + }, + 'model_config': { + 'provider': 'openai', + 'model_id': 'gpt-3.5-turbo', + 'configs': { + 'prompt_template': '', + 'prompt_variables': [], + 'completion_params': { + 'max_token': 512, + 'temperature': 1, + 'top_p': 1, + 'presence_penalty': 0, + 'frequency_penalty': 0, + } + }, + 'model': json.dumps({ + "provider": "openai", + "name": "gpt-3.5-turbo", + "completion_params": { + "max_tokens": 512, + "temperature": 1, + "top_p": 1, + "presence_penalty": 0, + "frequency_penalty": 0 + } + }) + } + }, +} + + +demo_model_templates = { + 'en-US': [ + { + 'name': 'Translation Assistant', + 'icon': '', + 'icon_background': '', + 'description': 'A multilingual translator that provides translation capabilities in multiple languages, translating user input into the language they need.', + 'mode': 'completion', + 'model_config': AppModelConfig( + provider='openai', + model_id='text-davinci-003', + configs={ + 'prompt_template': "Please translate the following text into {{target_language}}:\n", + 'prompt_variables': [ + { + "key": "target_language", + "name": "Target Language", + "description": "The language you want to translate into.", + "type": "select", + "default": "Chinese", + 'options': [ + 'Chinese', + 'English', + 'Japanese', + 'French', + 'Russian', + 'German', + 'Spanish', + 'Korean', + 'Italian', + ] + } + ], + 'completion_params': { + 'max_token': 1000, + 'temperature': 0, + 'top_p': 0, + 'presence_penalty': 0.1, + 'frequency_penalty': 0.1, + } + }, + opening_statement='', + suggested_questions=None, + pre_prompt="Please translate the following text into {{target_language}}:\n", + model=json.dumps({ + "provider": "openai", + "name": "text-davinci-003", + "completion_params": { + "max_tokens": 1000, + "temperature": 0, + "top_p": 0, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + }), + user_input_form=json.dumps([ + { + "select": { + "label": "Target Language", + "variable": "target_language", + "description": "The language you want to translate into.", + "default": "Chinese", + "required": True, + 'options': [ + 'Chinese', + 'English', + 'Japanese', + 'French', + 'Russian', + 'German', + 'Spanish', + 'Korean', + 'Italian', + ] + } + } + ]) + ) + }, + { + 'name': 'AI Front-end Interviewer', + 'icon': '', + 'icon_background': '', + 'description': 'A simulated front-end interviewer that tests the skill level of front-end development through questioning.', + 'mode': 'chat', + 'model_config': AppModelConfig( + provider='openai', + model_id='gpt-3.5-turbo', + configs={ + 'introduction': 'Hi, welcome to our interview. I am the interviewer for this technology company, and I will test your web front-end development skills. Next, I will ask you some technical questions. Please answer them as thoroughly as possible. ', + 'prompt_template': "You will play the role of an interviewer for a technology company, examining the user's web front-end development skills and posing 5-10 sharp technical questions.\n\nPlease note:\n- Only ask one question at a time.\n- After the user answers a question, ask the next question directly, without trying to correct any mistakes made by the candidate.\n- If you think the user has not answered correctly for several consecutive questions, ask fewer questions.\n- After asking the last question, you can ask this question: Why did you leave your last job? After the user answers this question, please express your understanding and support.\n", + 'prompt_variables': [], + 'completion_params': { + 'max_token': 300, + 'temperature': 0.8, + 'top_p': 0.9, + 'presence_penalty': 0.1, + 'frequency_penalty': 0.1, + } + }, + opening_statement='Hi, welcome to our interview. I am the interviewer for this technology company, and I will test your web front-end development skills. Next, I will ask you some technical questions. Please answer them as thoroughly as possible. ', + suggested_questions=None, + pre_prompt="You will play the role of an interviewer for a technology company, examining the user's web front-end development skills and posing 5-10 sharp technical questions.\n\nPlease note:\n- Only ask one question at a time.\n- After the user answers a question, ask the next question directly, without trying to correct any mistakes made by the candidate.\n- If you think the user has not answered correctly for several consecutive questions, ask fewer questions.\n- After asking the last question, you can ask this question: Why did you leave your last job? After the user answers this question, please express your understanding and support.\n", + model=json.dumps({ + "provider": "openai", + "name": "gpt-3.5-turbo", + "completion_params": { + "max_tokens": 300, + "temperature": 0.8, + "top_p": 0.9, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + }), + user_input_form=None + ) + } + ], + + 'zh-Hans': [ + { + 'name': '翻译助手', + 'icon': '', + 'icon_background': '', + 'description': '一个多语言翻译器,提供多种语言翻译能力,将用户输入的文本翻译成他们需要的语言。', + 'mode': 'completion', + 'model_config': AppModelConfig( + provider='openai', + model_id='text-davinci-003', + configs={ + 'prompt_template': "请将以下文本翻译为{{target_language}}:\n", + 'prompt_variables': [ + { + "key": "target_language", + "name": "目标语言", + "description": "翻译的目标语言", + "type": "select", + "default": "中文", + "options": [ + "中文", + "英文", + "日语", + "法语", + "俄语", + "德语", + "西班牙语", + "韩语", + "意大利语", + ] + } + ], + 'completion_params': { + 'max_token': 1000, + 'temperature': 0, + 'top_p': 0, + 'presence_penalty': 0.1, + 'frequency_penalty': 0.1, + } + }, + opening_statement='', + suggested_questions=None, + pre_prompt="请将以下文本翻译为{{target_language}}:\n", + model=json.dumps({ + "provider": "openai", + "name": "text-davinci-003", + "completion_params": { + "max_tokens": 1000, + "temperature": 0, + "top_p": 0, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + }), + user_input_form=json.dumps([ + { + "select": { + "label": "目标语言", + "variable": "target_language", + "description": "翻译的目标语言", + "default": "中文", + "required": True, + 'options': [ + "中文", + "英文", + "日语", + "法语", + "俄语", + "德语", + "西班牙语", + "韩语", + "意大利语", + ] + } + } + ]) + ) + }, + { + 'name': 'AI 前端面试官', + 'icon': '', + 'icon_background': '', + 'description': '一个模拟的前端面试官,通过提问的方式对前端开发的技能水平进行检验。', + 'mode': 'chat', + 'model_config': AppModelConfig( + provider='openai', + model_id='gpt-3.5-turbo', + configs={ + 'introduction': '你好,欢迎来参加我们的面试,我是这家科技公司的面试官,我将考察你的 Web 前端开发技能。接下来我会向您提出一些技术问题,请您尽可能详尽地回答。', + 'prompt_template': "你将扮演一个科技公司的面试官,考察用户作为候选人的 Web 前端开发水平,提出 5-10 个犀利的技术问题。\n\n请注意:\n- 每次只问一个问题\n- 用户回答问题后请直接问下一个问题,而不要试图纠正候选人的错误;\n- 如果你认为用户连续几次回答的都不对,就少问一点;\n- 问完最后一个问题后,你可以问这样一个问题:上一份工作为什么离职?用户回答该问题后,请表示理解与支持。\n", + 'prompt_variables': [], + 'completion_params': { + 'max_token': 300, + 'temperature': 0.8, + 'top_p': 0.9, + 'presence_penalty': 0.1, + 'frequency_penalty': 0.1, + } + }, + opening_statement='你好,欢迎来参加我们的面试,我是这家科技公司的面试官,我将考察你的 Web 前端开发技能。接下来我会向您提出一些技术问题,请您尽可能详尽地回答。', + suggested_questions=None, + pre_prompt="你将扮演一个科技公司的面试官,考察用户作为候选人的 Web 前端开发水平,提出 5-10 个犀利的技术问题。\n\n请注意:\n- 每次只问一个问题\n- 用户回答问题后请直接问下一个问题,而不要试图纠正候选人的错误;\n- 如果你认为用户连续几次回答的都不对,就少问一点;\n- 问完最后一个问题后,你可以问这样一个问题:上一份工作为什么离职?用户回答该问题后,请表示理解与支持。\n", + model=json.dumps({ + "provider": "openai", + "name": "gpt-3.5-turbo", + "completion_params": { + "max_tokens": 300, + "temperature": 0.8, + "top_p": 0.9, + "presence_penalty": 0.1, + "frequency_penalty": 0.1 + } + }), + user_input_form=None + ) + } + ], +} diff --git a/api/controllers/__init__.py b/api/controllers/__init__.py new file mode 100644 index 0000000000..2c0485b18d --- /dev/null +++ b/api/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding:utf-8 -*- + + + diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py new file mode 100644 index 0000000000..971e489971 --- /dev/null +++ b/api/controllers/console/__init__.py @@ -0,0 +1,20 @@ +from flask import Blueprint + +from libs.external_api import ExternalApi + +bp = Blueprint('console', __name__, url_prefix='/console/api') +api = ExternalApi(bp) + +# Import app controllers +from .app import app, site, explore, completion, model_config, statistic, conversation, message + +# Import auth controllers +from .auth import login, oauth + +# Import datasets controllers +from .datasets import datasets, datasets_document, datasets_segments, file, hit_testing + +# Import other controllers +from . import setup, version, apikey + +from .workspace import workspace, members, providers, account diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py new file mode 100644 index 0000000000..e576a4d848 --- /dev/null +++ b/api/controllers/console/apikey.py @@ -0,0 +1,175 @@ +from flask_login import login_required, current_user +import flask_restful +from flask_restful import Resource, fields, marshal_with +from werkzeug.exceptions import Forbidden + +from extensions.ext_database import db +from models.model import App, ApiToken +from models.dataset import Dataset + +from . import api +from .setup import setup_required +from .wraps import account_initialization_required +from libs.helper import TimestampField + +api_key_fields = { + 'id': fields.String, + 'type': fields.String, + 'token': fields.String, + 'last_used_at': TimestampField, + 'created_at': TimestampField +} + +api_key_list = { + 'data': fields.List(fields.Nested(api_key_fields), attribute="items") +} + + +def _get_resource(resource_id, tenant_id, resource_model): + resource = resource_model.query.filter_by( + id=resource_id, tenant_id=tenant_id + ).first() + + if resource is None: + flask_restful.abort( + 404, message=f"{resource_model.__name__} not found.") + + return resource + + +class BaseApiKeyListResource(Resource): + method_decorators = [account_initialization_required, login_required, setup_required] + + resource_type = None + resource_model = None + resource_id_field = None + token_prefix = None + max_keys = 10 + + @marshal_with(api_key_list) + def get(self, resource_id): + resource_id = str(resource_id) + _get_resource(resource_id, current_user.current_tenant_id, + self.resource_model) + keys = db.session.query(ApiToken). \ + filter(ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id). \ + all() + return {"items": keys} + + @marshal_with(api_key_fields) + def post(self, resource_id): + resource_id = str(resource_id) + _get_resource(resource_id, current_user.current_tenant_id, + self.resource_model) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + current_key_count = db.session.query(ApiToken). \ + filter(ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id). \ + count() + + if current_key_count >= self.max_keys: + flask_restful.abort( + 400, + message=f"Cannot create more than {self.max_keys} API keys for this resource type.", + code='max_keys_exceeded' + ) + + key = ApiToken.generate_api_key(self.token_prefix, 24) + api_token = ApiToken() + setattr(api_token, self.resource_id_field, resource_id) + api_token.token = key + api_token.type = self.resource_type + db.session.add(api_token) + db.session.commit() + return api_token, 201 + + +class BaseApiKeyResource(Resource): + method_decorators = [account_initialization_required, login_required, setup_required] + + resource_type = None + resource_model = None + resource_id_field = None + + def delete(self, resource_id, api_key_id): + resource_id = str(resource_id) + api_key_id = str(api_key_id) + _get_resource(resource_id, current_user.current_tenant_id, + self.resource_model) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + key = db.session.query(ApiToken). \ + filter(getattr(ApiToken, self.resource_id_field) == resource_id, ApiToken.type == self.resource_type, ApiToken.id == api_key_id). \ + first() + + if key is None: + flask_restful.abort(404, message='API key not found') + + db.session.query(ApiToken).filter(ApiToken.id == api_key_id).delete() + db.session.commit() + + return {'result': 'success'}, 204 + + +class AppApiKeyListResource(BaseApiKeyListResource): + + def after_request(self, resp): + resp.headers['Access-Control-Allow-Origin'] = '*' + resp.headers['Access-Control-Allow-Credentials'] = 'true' + return resp + + resource_type = 'app' + resource_model = App + resource_id_field = 'app_id' + token_prefix = 'app-' + + +class AppApiKeyResource(BaseApiKeyResource): + + def after_request(self, resp): + resp.headers['Access-Control-Allow-Origin'] = '*' + resp.headers['Access-Control-Allow-Credentials'] = 'true' + return resp + + resource_type = 'app' + resource_model = App + resource_id_field = 'app_id' + + +class DatasetApiKeyListResource(BaseApiKeyListResource): + + def after_request(self, resp): + resp.headers['Access-Control-Allow-Origin'] = '*' + resp.headers['Access-Control-Allow-Credentials'] = 'true' + return resp + + resource_type = 'dataset' + resource_model = Dataset + resource_id_field = 'dataset_id' + token_prefix = 'ds-' + + +class DatasetApiKeyResource(BaseApiKeyResource): + + def after_request(self, resp): + resp.headers['Access-Control-Allow-Origin'] = '*' + resp.headers['Access-Control-Allow-Credentials'] = 'true' + return resp + resource_type = 'dataset' + resource_model = Dataset + resource_id_field = 'dataset_id' + + +api.add_resource(AppApiKeyListResource, '/apps//api-keys') +api.add_resource(AppApiKeyResource, + '/apps//api-keys/') +api.add_resource(DatasetApiKeyListResource, + '/datasets//api-keys') +api.add_resource(DatasetApiKeyResource, + '/datasets//api-keys/') diff --git a/api/controllers/console/app/__init__.py b/api/controllers/console/app/__init__.py new file mode 100644 index 0000000000..1f22ab30c6 --- /dev/null +++ b/api/controllers/console/app/__init__.py @@ -0,0 +1,22 @@ +from flask_login import current_user +from werkzeug.exceptions import NotFound + +from controllers.console.app.error import AppUnavailableError +from extensions.ext_database import db +from models.model import App + + +def _get_app(app_id, mode=None): + app = db.session.query(App).filter( + App.id == app_id, + App.tenant_id == current_user.current_tenant_id, + App.status == 'normal' + ).first() + + if not app: + raise NotFound("App not found") + + if mode and app.mode != mode: + raise AppUnavailableError() + + return app diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py new file mode 100644 index 0000000000..fbb28fb4ae --- /dev/null +++ b/api/controllers/console/app/app.py @@ -0,0 +1,518 @@ +# -*- coding:utf-8 -*- +import json +from datetime import datetime + +import flask +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal_with, abort, inputs +from werkzeug.exceptions import Unauthorized, Forbidden + +from constants.model_template import model_templates, demo_model_templates +from controllers.console import api +from controllers.console.app.error import AppNotFoundError, ProviderNotInitializeError, ProviderQuotaExceededError, \ + CompletionRequestError, ProviderModelCurrentlyNotSupportError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.generator.llm_generator import LLMGenerator +from core.llm.error import ProviderTokenNotInitError, QuotaExceededError, LLMBadRequestError, LLMAPIConnectionError, \ + LLMAPIUnavailableError, LLMRateLimitError, LLMAuthorizationError, ModelCurrentlyNotSupportError +from events.app_event import app_was_created, app_was_deleted +from libs.helper import TimestampField +from extensions.ext_database import db +from models.model import App, AppModelConfig, Site, InstalledApp +from services.account_service import TenantService +from services.app_model_config_service import AppModelConfigService + +model_config_fields = { + 'opening_statement': fields.String, + 'suggested_questions': fields.Raw(attribute='suggested_questions_list'), + 'suggested_questions_after_answer': fields.Raw(attribute='suggested_questions_after_answer_dict'), + 'more_like_this': fields.Raw(attribute='more_like_this_dict'), + 'model': fields.Raw(attribute='model_dict'), + 'user_input_form': fields.Raw(attribute='user_input_form_list'), + 'pre_prompt': fields.String, + 'agent_mode': fields.Raw(attribute='agent_mode_dict'), +} + +app_detail_fields = { + 'id': fields.String, + 'name': fields.String, + 'mode': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'enable_site': fields.Boolean, + 'enable_api': fields.Boolean, + 'api_rpm': fields.Integer, + 'api_rph': fields.Integer, + 'is_demo': fields.Boolean, + 'model_config': fields.Nested(model_config_fields, attribute='app_model_config'), + 'created_at': TimestampField +} + + +def _get_app(app_id, tenant_id): + app = db.session.query(App).filter(App.id == app_id, App.tenant_id == tenant_id).first() + if not app: + raise AppNotFoundError + return app + + +class AppListApi(Resource): + prompt_config_fields = { + 'prompt_template': fields.String, + } + + model_config_partial_fields = { + 'model': fields.Raw(attribute='model_dict'), + 'pre_prompt': fields.String, + } + + app_partial_fields = { + 'id': fields.String, + 'name': fields.String, + 'mode': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'enable_site': fields.Boolean, + 'enable_api': fields.Boolean, + 'is_demo': fields.Boolean, + 'model_config': fields.Nested(model_config_partial_fields, attribute='app_model_config'), + 'created_at': TimestampField + } + + app_pagination_fields = { + 'page': fields.Integer, + 'limit': fields.Integer(attribute='per_page'), + 'total': fields.Integer, + 'has_more': fields.Boolean(attribute='has_next'), + 'data': fields.List(fields.Nested(app_partial_fields), attribute='items') + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_pagination_fields) + def get(self): + """Get app list""" + parser = reqparse.RequestParser() + parser.add_argument('page', type=inputs.int_range(1, 99999), required=False, default=1, location='args') + parser.add_argument('limit', type=inputs.int_range(1, 100), required=False, default=20, location='args') + args = parser.parse_args() + + app_models = db.paginate( + db.select(App).where(App.tenant_id == current_user.current_tenant_id).order_by(App.created_at.desc()), + page=args['page'], + per_page=args['limit'], + error_out=False) + + return app_models + + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self): + """Create app""" + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, location='json') + parser.add_argument('mode', type=str, choices=['completion', 'chat'], location='json') + parser.add_argument('icon', type=str, location='json') + parser.add_argument('icon_background', type=str, location='json') + parser.add_argument('model_config', type=dict, location='json') + args = parser.parse_args() + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + if args['model_config'] is not None: + # validate config + model_configuration = AppModelConfigService.validate_configuration( + account=current_user, + config=args['model_config'], + mode=args['mode'] + ) + + app = App( + enable_site=True, + enable_api=True, + is_demo=False, + api_rpm=0, + api_rph=0, + status='normal' + ) + + app_model_config = AppModelConfig( + provider="", + model_id="", + configs={}, + opening_statement=model_configuration['opening_statement'], + suggested_questions=json.dumps(model_configuration['suggested_questions']), + suggested_questions_after_answer=json.dumps(model_configuration['suggested_questions_after_answer']), + more_like_this=json.dumps(model_configuration['more_like_this']), + model=json.dumps(model_configuration['model']), + user_input_form=json.dumps(model_configuration['user_input_form']), + pre_prompt=model_configuration['pre_prompt'], + agent_mode=json.dumps(model_configuration['agent_mode']), + ) + else: + if 'mode' not in args or args['mode'] is None: + abort(400, message="mode is required") + + model_config_template = model_templates[args['mode'] + '_default'] + + app = App(**model_config_template['app']) + app_model_config = AppModelConfig(**model_config_template['model_config']) + + app.name = args['name'] + app.mode = args['mode'] + app.icon = args['icon'] + app.icon_background = args['icon_background'] + app.tenant_id = current_user.current_tenant_id + + db.session.add(app) + db.session.flush() + + app_model_config.app_id = app.id + db.session.add(app_model_config) + db.session.flush() + + app.app_model_config_id = app_model_config.id + + account = current_user + + site = Site( + app_id=app.id, + title=app.name, + default_language=account.interface_language, + customize_token_strategy='not_allow', + code=Site.generate_code(16) + ) + + db.session.add(site) + db.session.commit() + + app_was_created.send(app) + + return app, 201 + + +class AppTemplateApi(Resource): + template_fields = { + 'name': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'description': fields.String, + 'mode': fields.String, + 'model_config': fields.Nested(model_config_fields), + } + + template_list_fields = { + 'data': fields.List(fields.Nested(template_fields)), + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(template_list_fields) + def get(self): + """Get app demo templates""" + account = current_user + interface_language = account.interface_language + + return {'data': demo_model_templates.get(interface_language)} + + +class AppApi(Resource): + site_fields = { + 'access_token': fields.String(attribute='code'), + 'code': fields.String, + 'title': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'description': fields.String, + 'default_language': fields.String, + 'customize_domain': fields.String, + 'copyright': fields.String, + 'privacy_policy': fields.String, + 'customize_token_strategy': fields.String, + 'prompt_public': fields.Boolean, + 'app_base_url': fields.String, + } + + app_detail_fields_with_site = { + 'id': fields.String, + 'name': fields.String, + 'mode': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'enable_site': fields.Boolean, + 'enable_api': fields.Boolean, + 'api_rpm': fields.Integer, + 'api_rph': fields.Integer, + 'is_demo': fields.Boolean, + 'model_config': fields.Nested(model_config_fields, attribute='app_model_config'), + 'site': fields.Nested(site_fields), + 'api_base_url': fields.String, + 'created_at': TimestampField + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields_with_site) + def get(self, app_id): + """Get app detail""" + app_id = str(app_id) + app = _get_app(app_id, current_user.current_tenant_id) + + return app + + @setup_required + @login_required + @account_initialization_required + def delete(self, app_id): + """Delete app""" + app_id = str(app_id) + app = _get_app(app_id, current_user.current_tenant_id) + + db.session.delete(app) + db.session.commit() + + # todo delete related data?? + # model_config, site, api_token, conversation, message, message_feedback, message_annotation + + app_was_deleted.send(app) + + return {'result': 'success'}, 204 + + +class AppNameApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, location='json') + args = parser.parse_args() + + app = db.get_or_404(App, str(app_id)) + if app.tenant_id != flask.session.get('tenant_id'): + raise Unauthorized() + + app.name = args.get('name') + app.updated_at = datetime.utcnow() + db.session.commit() + return app + + +class AppIconApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument('icon', type=str, location='json') + parser.add_argument('icon_background', type=str, location='json') + args = parser.parse_args() + + app = db.get_or_404(App, str(app_id)) + if app.tenant_id != flask.session.get('tenant_id'): + raise Unauthorized() + + app.icon = args.get('icon') + app.icon_background = args.get('icon_background') + app.updated_at = datetime.utcnow() + db.session.commit() + + return app + + +class AppSiteStatus(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + parser = reqparse.RequestParser() + parser.add_argument('enable_site', type=bool, required=True, location='json') + args = parser.parse_args() + app_id = str(app_id) + app = db.session.query(App).filter(App.id == app_id, App.tenant_id == current_user.current_tenant_id).first() + if not app: + raise AppNotFoundError + + if args.get('enable_site') == app.enable_site: + return app + + app.enable_site = args.get('enable_site') + app.updated_at = datetime.utcnow() + db.session.commit() + return app + + +class AppApiStatus(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + parser = reqparse.RequestParser() + parser.add_argument('enable_api', type=bool, required=True, location='json') + args = parser.parse_args() + + app_id = str(app_id) + app = _get_app(app_id, current_user.current_tenant_id) + + if args.get('enable_api') == app.enable_api: + return app + + app.enable_api = args.get('enable_api') + app.updated_at = datetime.utcnow() + db.session.commit() + return app + + +class AppRateLimit(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + parser = reqparse.RequestParser() + parser.add_argument('api_rpm', type=inputs.natural, required=False, location='json') + parser.add_argument('api_rph', type=inputs.natural, required=False, location='json') + args = parser.parse_args() + + app_id = str(app_id) + app = _get_app(app_id, current_user.current_tenant_id) + + if args.get('api_rpm'): + app.api_rpm = args.get('api_rpm') + if args.get('api_rph'): + app.api_rph = args.get('api_rph') + app.updated_at = datetime.utcnow() + db.session.commit() + return app + + +class AppCopy(Resource): + @staticmethod + def create_app_copy(app): + copy_app = App( + name=app.name + ' copy', + icon=app.icon, + icon_background=app.icon_background, + tenant_id=app.tenant_id, + mode=app.mode, + app_model_config_id=app.app_model_config_id, + enable_site=app.enable_site, + enable_api=app.enable_api, + api_rpm=app.api_rpm, + api_rph=app.api_rph + ) + return copy_app + + @staticmethod + def create_app_model_config_copy(app_config, copy_app_id): + copy_app_model_config = AppModelConfig( + app_id=copy_app_id, + provider=app_config.provider, + model_id=app_config.model_id, + configs=app_config.configs, + opening_statement=app_config.opening_statement, + suggested_questions=app_config.suggested_questions, + suggested_questions_after_answer=app_config.suggested_questions_after_answer, + more_like_this=app_config.more_like_this, + model=app_config.model, + user_input_form=app_config.user_input_form, + pre_prompt=app_config.pre_prompt, + agent_mode=app_config.agent_mode + ) + return copy_app_model_config + + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_detail_fields) + def post(self, app_id): + app_id = str(app_id) + app = _get_app(app_id, current_user.current_tenant_id) + + copy_app = self.create_app_copy(app) + db.session.add(copy_app) + + app_config = db.session.query(AppModelConfig). \ + filter(AppModelConfig.app_id == app_id). \ + one_or_none() + + if app_config: + copy_app_model_config = self.create_app_model_config_copy(app_config, copy_app.id) + db.session.add(copy_app_model_config) + db.session.commit() + copy_app.app_model_config_id = copy_app_model_config.id + db.session.commit() + + return copy_app, 201 + + +class AppExport(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + # todo + pass + + +class IntroductionGenerateApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('prompt_template', type=str, required=True, location='json') + args = parser.parse_args() + + account = current_user + + try: + answer = LLMGenerator.generate_introduction( + account.current_tenant_id, + args['prompt_template'] + ) + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + + return {'introduction': answer} + + +api.add_resource(AppListApi, '/apps') +api.add_resource(AppTemplateApi, '/app-templates') +api.add_resource(AppApi, '/apps/') +api.add_resource(AppCopy, '/apps//copy') +api.add_resource(AppNameApi, '/apps//name') +api.add_resource(AppSiteStatus, '/apps//site-enable') +api.add_resource(AppApiStatus, '/apps//api-enable') +api.add_resource(AppRateLimit, '/apps//rate-limit') +api.add_resource(IntroductionGenerateApi, '/introduction-generate') diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py new file mode 100644 index 0000000000..552271a9ec --- /dev/null +++ b/api/controllers/console/app/completion.py @@ -0,0 +1,206 @@ +# -*- coding:utf-8 -*- +import json +import logging +from typing import Generator, Union + +import flask_login +from flask import Response, stream_with_context +from flask_login import login_required +from werkzeug.exceptions import InternalServerError, NotFound + +import services +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.app.error import ConversationCompletedError, AppUnavailableError, \ + ProviderNotInitializeError, CompletionRequestError, ProviderQuotaExceededError, \ + ProviderModelCurrentlyNotSupportError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.conversation_message_task import PubHandler +from core.llm.error import LLMBadRequestError, LLMAPIUnavailableError, LLMAuthorizationError, LLMAPIConnectionError, \ + LLMRateLimitError, ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError +from libs.helper import uuid_value +from flask_restful import Resource, reqparse + +from services.completion_service import CompletionService + + +# define completion message api for user +class CompletionMessageApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + app_id = str(app_id) + + # get app info + app_model = _get_app(app_id, 'completion') + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, location='json') + parser.add_argument('model_config', type=dict, required=True, location='json') + args = parser.parse_args() + + account = flask_login.current_user + + try: + response = CompletionService.completion( + app_model=app_model, + user=account, + args=args, + from_source='console', + streaming=True, + is_model_config_override=True + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +class CompletionMessageStopApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, app_id, task_id): + app_id = str(app_id) + + # get app info + _get_app(app_id, 'completion') + + account = flask_login.current_user + + PubHandler.stop(account, task_id) + + return {'result': 'success'}, 200 + + +class ChatMessageApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + app_id = str(app_id) + + # get app info + app_model = _get_app(app_id, 'chat') + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, required=True, location='json') + parser.add_argument('model_config', type=dict, required=True, location='json') + parser.add_argument('conversation_id', type=uuid_value, location='json') + args = parser.parse_args() + + account = flask_login.current_user + + try: + response = CompletionService.completion( + app_model=app_model, + user=account, + args=args, + from_source='console', + streaming=True, + is_model_config_override=True + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +def compact_response(response: Union[dict | Generator]) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype='application/json') + else: + def generate() -> Generator: + try: + for chunk in response: + yield chunk + except services.errors.conversation.ConversationNotExistsError: + yield "data: " + json.dumps(api.handle_error(NotFound("Conversation Not Exists.")).get_json()) + "\n\n" + except services.errors.conversation.ConversationCompletedError: + yield "data: " + json.dumps(api.handle_error(ConversationCompletedError()).get_json()) + "\n\n" + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + yield "data: " + json.dumps(api.handle_error(AppUnavailableError()).get_json()) + "\n\n" + except ProviderTokenNotInitError: + yield "data: " + json.dumps(api.handle_error(ProviderNotInitializeError()).get_json()) + "\n\n" + except QuotaExceededError: + yield "data: " + json.dumps(api.handle_error(ProviderQuotaExceededError()).get_json()) + "\n\n" + except ModelCurrentlyNotSupportError: + yield "data: " + json.dumps(api.handle_error(ProviderModelCurrentlyNotSupportError()).get_json()) + "\n\n" + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + yield "data: " + json.dumps(api.handle_error(CompletionRequestError(str(e))).get_json()) + "\n\n" + except ValueError as e: + yield "data: " + json.dumps(api.handle_error(e).get_json()) + "\n\n" + except Exception: + logging.exception("internal server error.") + yield "data: " + json.dumps(api.handle_error(InternalServerError()).get_json()) + "\n\n" + + return Response(stream_with_context(generate()), status=200, + mimetype='text/event-stream') + + +class ChatMessageStopApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, app_id, task_id): + app_id = str(app_id) + + # get app info + _get_app(app_id, 'chat') + + account = flask_login.current_user + + PubHandler.stop(account, task_id) + + return {'result': 'success'}, 200 + + +api.add_resource(CompletionMessageApi, '/apps//completion-messages') +api.add_resource(CompletionMessageStopApi, '/apps//completion-messages//stop') +api.add_resource(ChatMessageApi, '/apps//chat-messages') +api.add_resource(ChatMessageStopApi, '/apps//chat-messages//stop') diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py new file mode 100644 index 0000000000..62752deaac --- /dev/null +++ b/api/controllers/console/app/conversation.py @@ -0,0 +1,384 @@ +from datetime import datetime + +import pytz +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal_with +from flask_restful.inputs import int_range +from sqlalchemy import or_, func +from sqlalchemy.orm import joinedload +from werkzeug.exceptions import NotFound + +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import TimestampField, datetime_string, uuid_value +from extensions.ext_database import db +from models.model import Message, MessageAnnotation, Conversation + +account_fields = { + 'id': fields.String, + 'name': fields.String, + 'email': fields.String +} + +feedback_fields = { + 'rating': fields.String, + 'content': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account': fields.Nested(account_fields, allow_null=True), +} + +annotation_fields = { + 'content': fields.String, + 'account': fields.Nested(account_fields, allow_null=True), + 'created_at': TimestampField +} + +message_detail_fields = { + 'id': fields.String, + 'conversation_id': fields.String, + 'inputs': fields.Raw, + 'query': fields.String, + 'message': fields.Raw, + 'message_tokens': fields.Integer, + 'answer': fields.String, + 'answer_tokens': fields.Integer, + 'provider_response_latency': fields.Integer, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'feedbacks': fields.List(fields.Nested(feedback_fields)), + 'annotation': fields.Nested(annotation_fields, allow_null=True), + 'created_at': TimestampField +} + +feedback_stat_fields = { + 'like': fields.Integer, + 'dislike': fields.Integer +} + +model_config_fields = { + 'opening_statement': fields.String, + 'suggested_questions': fields.Raw, + 'model': fields.Raw, + 'user_input_form': fields.Raw, + 'pre_prompt': fields.String, + 'agent_mode': fields.Raw, +} + + +class CompletionConversationApi(Resource): + class MessageTextField(fields.Raw): + def format(self, value): + return value[0]['text'] if value else '' + + simple_configs_fields = { + 'prompt_template': fields.String, + } + + simple_model_config_fields = { + 'model': fields.Raw(attribute='model_dict'), + 'pre_prompt': fields.String, + } + + simple_message_detail_fields = { + 'inputs': fields.Raw, + 'query': fields.String, + 'message': MessageTextField, + 'answer': fields.String, + } + + conversation_fields = { + 'id': fields.String, + 'status': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'read_at': TimestampField, + 'created_at': TimestampField, + 'annotation': fields.Nested(annotation_fields, allow_null=True), + 'model_config': fields.Nested(simple_model_config_fields), + 'user_feedback_stats': fields.Nested(feedback_stat_fields), + 'admin_feedback_stats': fields.Nested(feedback_stat_fields), + 'message': fields.Nested(simple_message_detail_fields, attribute='first_message') + } + + conversation_pagination_fields = { + 'page': fields.Integer, + 'limit': fields.Integer(attribute='per_page'), + 'total': fields.Integer, + 'has_more': fields.Boolean(attribute='has_next'), + 'data': fields.List(fields.Nested(conversation_fields), attribute='items') + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(conversation_pagination_fields) + def get(self, app_id): + app_id = str(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('keyword', type=str, location='args') + parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('end', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('annotation_status', type=str, + choices=['annotated', 'not_annotated', 'all'], default='all', location='args') + parser.add_argument('page', type=int_range(1, 99999), default=1, location='args') + parser.add_argument('limit', type=int_range(1, 100), default=20, location='args') + args = parser.parse_args() + + # get app info + app = _get_app(app_id, 'completion') + + query = db.select(Conversation).where(Conversation.app_id == app.id, Conversation.mode == 'completion') + + if args['keyword']: + query = query.join( + Message, Message.conversation_id == Conversation.id + ).filter( + or_( + Message.query.ilike('%{}%'.format(args['keyword'])), + Message.answer.ilike('%{}%'.format(args['keyword'])) + ) + ) + + account = current_user + timezone = pytz.timezone(account.timezone) + utc_timezone = pytz.utc + + if args['start']: + start_datetime = datetime.strptime(args['start'], '%Y-%m-%d %H:%M') + start_datetime = start_datetime.replace(second=0) + + start_datetime_timezone = timezone.localize(start_datetime) + start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) + + query = query.where(Conversation.created_at >= start_datetime_utc) + + if args['end']: + end_datetime = datetime.strptime(args['end'], '%Y-%m-%d %H:%M') + end_datetime = end_datetime.replace(second=0) + + end_datetime_timezone = timezone.localize(end_datetime) + end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + + query = query.where(Conversation.created_at < end_datetime_utc) + + if args['annotation_status'] == "annotated": + query = query.options(joinedload(Conversation.message_annotations)).join( + MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id + ) + elif args['annotation_status'] == "not_annotated": + query = query.outerjoin( + MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id + ).group_by(Conversation.id).having(func.count(MessageAnnotation.id) == 0) + + query = query.order_by(Conversation.created_at.desc()) + + conversations = db.paginate( + query, + page=args['page'], + per_page=args['limit'], + error_out=False + ) + + return conversations + + +class CompletionConversationDetailApi(Resource): + conversation_detail_fields = { + 'id': fields.String, + 'status': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'created_at': TimestampField, + 'model_config': fields.Nested(model_config_fields), + 'message': fields.Nested(message_detail_fields, attribute='first_message'), + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(conversation_detail_fields) + def get(self, app_id, conversation_id): + app_id = str(app_id) + conversation_id = str(conversation_id) + + return _get_conversation(app_id, conversation_id, 'completion') + + +class ChatConversationApi(Resource): + simple_configs_fields = { + 'prompt_template': fields.String, + } + + simple_model_config_fields = { + 'model': fields.Raw(attribute='model_dict'), + 'pre_prompt': fields.String, + } + + conversation_fields = { + 'id': fields.String, + 'status': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'summary': fields.String(attribute='summary_or_query'), + 'read_at': TimestampField, + 'created_at': TimestampField, + 'annotated': fields.Boolean, + 'model_config': fields.Nested(simple_model_config_fields), + 'message_count': fields.Integer, + 'user_feedback_stats': fields.Nested(feedback_stat_fields), + 'admin_feedback_stats': fields.Nested(feedback_stat_fields) + } + + conversation_pagination_fields = { + 'page': fields.Integer, + 'limit': fields.Integer(attribute='per_page'), + 'total': fields.Integer, + 'has_more': fields.Boolean(attribute='has_next'), + 'data': fields.List(fields.Nested(conversation_fields), attribute='items') + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(conversation_pagination_fields) + def get(self, app_id): + app_id = str(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('keyword', type=str, location='args') + parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('end', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('annotation_status', type=str, + choices=['annotated', 'not_annotated', 'all'], default='all', location='args') + parser.add_argument('message_count_gte', type=int_range(1, 99999), required=False, location='args') + parser.add_argument('page', type=int_range(1, 99999), required=False, default=1, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + args = parser.parse_args() + + # get app info + app = _get_app(app_id, 'chat') + + query = db.select(Conversation).where(Conversation.app_id == app.id, Conversation.mode == 'chat') + + if args['keyword']: + query = query.join( + Message, Message.conversation_id == Conversation.id + ).filter( + or_( + Message.query.ilike('%{}%'.format(args['keyword'])), + Message.answer.ilike('%{}%'.format(args['keyword'])), + Conversation.name.ilike('%{}%'.format(args['keyword'])), + Conversation.introduction.ilike('%{}%'.format(args['keyword'])), + ), + + ) + + account = current_user + timezone = pytz.timezone(account.timezone) + utc_timezone = pytz.utc + + if args['start']: + start_datetime = datetime.strptime(args['start'], '%Y-%m-%d %H:%M') + start_datetime = start_datetime.replace(second=0) + + start_datetime_timezone = timezone.localize(start_datetime) + start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) + + query = query.where(Conversation.created_at >= start_datetime_utc) + + if args['end']: + end_datetime = datetime.strptime(args['end'], '%Y-%m-%d %H:%M') + end_datetime = end_datetime.replace(second=0) + + end_datetime_timezone = timezone.localize(end_datetime) + end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + + query = query.where(Conversation.created_at < end_datetime_utc) + + if args['annotation_status'] == "annotated": + query = query.options(joinedload(Conversation.message_annotations)).join( + MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id + ) + elif args['annotation_status'] == "not_annotated": + query = query.outerjoin( + MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id + ).group_by(Conversation.id).having(func.count(MessageAnnotation.id) == 0) + + if args['message_count_gte'] and args['message_count_gte'] >= 1: + query = ( + query.options(joinedload(Conversation.messages)) + .join(Message, Message.conversation_id == Conversation.id) + .group_by(Conversation.id) + .having(func.count(Message.id) >= args['message_count_gte']) + ) + + query = query.order_by(Conversation.created_at.desc()) + + conversations = db.paginate( + query, + page=args['page'], + per_page=args['limit'], + error_out=False + ) + + return conversations + + +class ChatConversationDetailApi(Resource): + conversation_detail_fields = { + 'id': fields.String, + 'status': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'created_at': TimestampField, + 'annotated': fields.Boolean, + 'model_config': fields.Nested(model_config_fields), + 'message_count': fields.Integer, + 'user_feedback_stats': fields.Nested(feedback_stat_fields), + 'admin_feedback_stats': fields.Nested(feedback_stat_fields) + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(conversation_detail_fields) + def get(self, app_id, conversation_id): + app_id = str(app_id) + conversation_id = str(conversation_id) + + return _get_conversation(app_id, conversation_id, 'chat') + + + + +api.add_resource(CompletionConversationApi, '/apps//completion-conversations') +api.add_resource(CompletionConversationDetailApi, '/apps//completion-conversations/') +api.add_resource(ChatConversationApi, '/apps//chat-conversations') +api.add_resource(ChatConversationDetailApi, '/apps//chat-conversations/') + + +def _get_conversation(app_id, conversation_id, mode): + # get app info + app = _get_app(app_id, mode) + + conversation = db.session.query(Conversation) \ + .filter(Conversation.id == conversation_id, Conversation.app_id == app.id).first() + + if not conversation: + raise NotFound("Conversation Not Exists.") + + if not conversation.read_at: + conversation.read_at = datetime.utcnow() + conversation.read_account_id = current_user.id + db.session.commit() + + return conversation diff --git a/api/controllers/console/app/error.py b/api/controllers/console/app/error.py new file mode 100644 index 0000000000..c19f054be4 --- /dev/null +++ b/api/controllers/console/app/error.py @@ -0,0 +1,49 @@ +from libs.exception import BaseHTTPException + + +class AppNotFoundError(BaseHTTPException): + error_code = 'app_not_found' + description = "App not found." + code = 404 + + +class ProviderNotInitializeError(BaseHTTPException): + error_code = 'provider_not_initialize' + description = "Provider Token not initialize." + code = 400 + + +class ProviderQuotaExceededError(BaseHTTPException): + error_code = 'provider_quota_exceeded' + description = "Provider quota exceeded." + code = 400 + + +class ProviderModelCurrentlyNotSupportError(BaseHTTPException): + error_code = 'model_currently_not_support' + description = "GPT-4 currently not support." + code = 400 + + +class ConversationCompletedError(BaseHTTPException): + error_code = 'conversation_completed' + description = "Conversation was completed." + code = 400 + + +class AppUnavailableError(BaseHTTPException): + error_code = 'app_unavailable' + description = "App unavailable." + code = 400 + + +class CompletionRequestError(BaseHTTPException): + error_code = 'completion_request_error' + description = "Completion request failed." + code = 400 + + +class AppMoreLikeThisDisabledError(BaseHTTPException): + error_code = 'app_more_like_this_disabled' + description = "More like this disabled." + code = 403 diff --git a/api/controllers/console/app/explore.py b/api/controllers/console/app/explore.py new file mode 100644 index 0000000000..eeec2ddc24 --- /dev/null +++ b/api/controllers/console/app/explore.py @@ -0,0 +1,209 @@ +# -*- coding:utf-8 -*- +from datetime import datetime + +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal_with, abort, inputs +from sqlalchemy import and_ + +from controllers.console import api +from extensions.ext_database import db +from models.model import Tenant, App, InstalledApp, RecommendedApp +from services.account_service import TenantService + +app_fields = { + 'id': fields.String, + 'name': fields.String, + 'mode': fields.String, + 'icon': fields.String, + 'icon_background': fields.String +} + +installed_app_fields = { + 'id': fields.String, + 'app': fields.Nested(app_fields, attribute='app'), + 'app_owner_tenant_id': fields.String, + 'is_pinned': fields.Boolean, + 'last_used_at': fields.DateTime, + 'editable': fields.Boolean +} + +installed_app_list_fields = { + 'installed_apps': fields.List(fields.Nested(installed_app_fields)) +} + +recommended_app_fields = { + 'app': fields.Nested(app_fields, attribute='app'), + 'app_id': fields.String, + 'description': fields.String(attribute='description'), + 'copyright': fields.String, + 'privacy_policy': fields.String, + 'category': fields.String, + 'position': fields.Integer, + 'is_listed': fields.Boolean, + 'install_count': fields.Integer, + 'installed': fields.Boolean, + 'editable': fields.Boolean +} + +recommended_app_list_fields = { + 'recommended_apps': fields.List(fields.Nested(recommended_app_fields)), + 'categories': fields.List(fields.String) +} + + +class InstalledAppsListResource(Resource): + @login_required + @marshal_with(installed_app_list_fields) + def get(self): + current_tenant_id = Tenant.query.first().id + installed_apps = db.session.query(InstalledApp).filter( + InstalledApp.tenant_id == current_tenant_id + ).all() + + current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant) + installed_apps = [ + { + **installed_app, + "editable": current_user.role in ["owner", "admin"], + } + for installed_app in installed_apps + ] + installed_apps.sort(key=lambda app: (-app.is_pinned, app.last_used_at)) + + return {'installed_apps': installed_apps} + + @login_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('app_id', type=str, required=True, help='Invalid app_id') + args = parser.parse_args() + + current_tenant_id = Tenant.query.first().id + app = App.query.get(args['app_id']) + if app is None: + abort(404, message='App not found') + recommended_app = RecommendedApp.query.filter(RecommendedApp.app_id == args['app_id']).first() + if recommended_app is None: + abort(404, message='App not found') + if not app.is_public: + abort(403, message="You can't install a non-public app") + + installed_app = InstalledApp.query.filter(and_( + InstalledApp.app_id == args['app_id'], + InstalledApp.tenant_id == current_tenant_id + )).first() + + if installed_app is None: + # todo: position + recommended_app.install_count += 1 + + new_installed_app = InstalledApp( + app_id=args['app_id'], + tenant_id=current_tenant_id, + is_pinned=False, + last_used_at=datetime.utcnow() + ) + db.session.add(new_installed_app) + db.session.commit() + + return {'message': 'App installed successfully'} + + +class InstalledAppResource(Resource): + + @login_required + def delete(self, installed_app_id): + + installed_app = InstalledApp.query.filter(and_( + InstalledApp.id == str(installed_app_id), + InstalledApp.tenant_id == current_user.current_tenant_id + )).first() + + if installed_app is None: + abort(404, message='App not found') + + if installed_app.app_owner_tenant_id == current_user.current_tenant_id: + abort(400, message="You can't uninstall an app owned by the current tenant") + + db.session.delete(installed_app) + db.session.commit() + + return {'result': 'success', 'message': 'App uninstalled successfully'} + + @login_required + def patch(self, installed_app_id): + parser = reqparse.RequestParser() + parser.add_argument('is_pinned', type=inputs.boolean) + args = parser.parse_args() + + current_tenant_id = Tenant.query.first().id + installed_app = InstalledApp.query.filter(and_( + InstalledApp.id == str(installed_app_id), + InstalledApp.tenant_id == current_tenant_id + )).first() + + if installed_app is None: + abort(404, message='Installed app not found') + + commit_args = False + if 'is_pinned' in args: + installed_app.is_pinned = args['is_pinned'] + commit_args = True + + if commit_args: + db.session.commit() + + return {'result': 'success', 'message': 'App info updated successfully'} + + +class RecommendedAppsResource(Resource): + @login_required + @marshal_with(recommended_app_list_fields) + def get(self): + recommended_apps = db.session.query(RecommendedApp).filter( + RecommendedApp.is_listed == True + ).all() + + categories = set() + current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant) + recommended_apps_result = [] + for recommended_app in recommended_apps: + installed = db.session.query(InstalledApp).filter( + and_( + InstalledApp.app_id == recommended_app.app_id, + InstalledApp.tenant_id == current_user.current_tenant_id + ) + ).first() is not None + + language_prefix = current_user.interface_language.split('-')[0] + desc = None + if recommended_app.description: + if language_prefix in recommended_app.description: + desc = recommended_app.description[language_prefix] + elif 'en' in recommended_app.description: + desc = recommended_app.description['en'] + + recommended_app_result = { + 'id': recommended_app.id, + 'app': recommended_app.app, + 'app_id': recommended_app.app_id, + 'description': desc, + 'copyright': recommended_app.copyright, + 'privacy_policy': recommended_app.privacy_policy, + 'category': recommended_app.category, + 'position': recommended_app.position, + 'is_listed': recommended_app.is_listed, + 'install_count': recommended_app.install_count, + 'installed': installed, + 'editable': current_user.role in ['owner', 'admin'], + } + recommended_apps_result.append(recommended_app_result) + + categories.add(recommended_app.category) # add category to categories + + return {'recommended_apps': recommended_apps_result, 'categories': list(categories)} + + +api.add_resource(InstalledAppsListResource, '/installed-apps') +api.add_resource(InstalledAppResource, '/installed-apps/') +api.add_resource(RecommendedAppsResource, '/explore/apps') diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py new file mode 100644 index 0000000000..27698d965c --- /dev/null +++ b/api/controllers/console/app/message.py @@ -0,0 +1,361 @@ +import json +import logging +from typing import Union, Generator + +from flask import Response, stream_with_context +from flask_login import current_user, login_required +from flask_restful import Resource, reqparse, marshal_with, fields +from flask_restful.inputs import int_range +from werkzeug.exceptions import InternalServerError, NotFound + +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.app.error import CompletionRequestError, ProviderNotInitializeError, \ + AppMoreLikeThisDisabledError, ProviderQuotaExceededError, ProviderModelCurrentlyNotSupportError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.llm.error import LLMRateLimitError, LLMBadRequestError, LLMAuthorizationError, LLMAPIConnectionError, \ + ProviderTokenNotInitError, LLMAPIUnavailableError, QuotaExceededError, ModelCurrentlyNotSupportError +from libs.helper import uuid_value, TimestampField +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from extensions.ext_database import db +from models.model import MessageAnnotation, Conversation, Message, MessageFeedback +from services.completion_service import CompletionService +from services.errors.app import MoreLikeThisDisabledError +from services.errors.conversation import ConversationNotExistsError +from services.errors.message import MessageNotExistsError +from services.message_service import MessageService + + +class ChatMessageApi(Resource): + account_fields = { + 'id': fields.String, + 'name': fields.String, + 'email': fields.String + } + + feedback_fields = { + 'rating': fields.String, + 'content': fields.String, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account': fields.Nested(account_fields, allow_null=True), + } + + annotation_fields = { + 'content': fields.String, + 'account': fields.Nested(account_fields, allow_null=True), + 'created_at': TimestampField + } + + message_detail_fields = { + 'id': fields.String, + 'conversation_id': fields.String, + 'inputs': fields.Raw, + 'query': fields.String, + 'message': fields.Raw, + 'message_tokens': fields.Integer, + 'answer': fields.String, + 'answer_tokens': fields.Integer, + 'provider_response_latency': fields.Integer, + 'from_source': fields.String, + 'from_end_user_id': fields.String, + 'from_account_id': fields.String, + 'feedbacks': fields.List(fields.Nested(feedback_fields)), + 'annotation': fields.Nested(annotation_fields, allow_null=True), + 'created_at': TimestampField + } + + message_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(message_detail_fields)) + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(message_infinite_scroll_pagination_fields) + def get(self, app_id): + app_id = str(app_id) + + # get app info + app = _get_app(app_id, 'chat') + + parser = reqparse.RequestParser() + parser.add_argument('conversation_id', required=True, type=uuid_value, location='args') + parser.add_argument('first_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + args = parser.parse_args() + + conversation = db.session.query(Conversation).filter( + Conversation.id == args['conversation_id'], + Conversation.app_id == app.id + ).first() + + if not conversation: + raise NotFound("Conversation Not Exists.") + + if args['first_id']: + first_message = db.session.query(Message) \ + .filter(Message.conversation_id == conversation.id, Message.id == args['first_id']).first() + + if not first_message: + raise NotFound("First message not found") + + history_messages = db.session.query(Message).filter( + Message.conversation_id == conversation.id, + Message.created_at < first_message.created_at, + Message.id != first_message.id + ) \ + .order_by(Message.created_at.desc()).limit(args['limit']).all() + else: + history_messages = db.session.query(Message).filter(Message.conversation_id == conversation.id) \ + .order_by(Message.created_at.desc()).limit(args['limit']).all() + + has_more = False + if len(history_messages) == args['limit']: + current_page_first_message = history_messages[-1] + rest_count = db.session.query(Message).filter( + Message.conversation_id == conversation.id, + Message.created_at < current_page_first_message.created_at, + Message.id != current_page_first_message.id + ).count() + + if rest_count > 0: + has_more = True + + history_messages = list(reversed(history_messages)) + + return InfiniteScrollPagination( + data=history_messages, + limit=args['limit'], + has_more=has_more + ) + + +class MessageFeedbackApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + app_id = str(app_id) + + # get app info + app = _get_app(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('message_id', required=True, type=uuid_value, location='json') + parser.add_argument('rating', type=str, choices=['like', 'dislike', None], location='json') + args = parser.parse_args() + + message_id = str(args['message_id']) + + message = db.session.query(Message).filter( + Message.id == message_id, + Message.app_id == app.id + ).first() + + if not message: + raise NotFound("Message Not Exists.") + + feedback = message.admin_feedback + + if not args['rating'] and feedback: + db.session.delete(feedback) + elif args['rating'] and feedback: + feedback.rating = args['rating'] + elif not args['rating'] and not feedback: + raise ValueError('rating cannot be None when feedback not exists') + else: + feedback = MessageFeedback( + app_id=app.id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=args['rating'], + from_source='admin', + from_account_id=current_user.id + ) + db.session.add(feedback) + + db.session.commit() + + return {'result': 'success'} + + +class MessageAnnotationApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + app_id = str(app_id) + + # get app info + app = _get_app(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('message_id', required=True, type=uuid_value, location='json') + parser.add_argument('content', type=str, location='json') + args = parser.parse_args() + + message_id = str(args['message_id']) + + message = db.session.query(Message).filter( + Message.id == message_id, + Message.app_id == app.id + ).first() + + if not message: + raise NotFound("Message Not Exists.") + + annotation = message.annotation + + if annotation: + annotation.content = args['content'] + else: + annotation = MessageAnnotation( + app_id=app.id, + conversation_id=message.conversation_id, + message_id=message.id, + content=args['content'], + account_id=current_user.id + ) + db.session.add(annotation) + + db.session.commit() + + return {'result': 'success'} + + +class MessageAnnotationCountApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, app_id): + app_id = str(app_id) + + # get app info + app = _get_app(app_id) + + count = db.session.query(MessageAnnotation).filter( + MessageAnnotation.app_id == app.id + ).count() + + return {'count': count} + + +class MessageMoreLikeThisApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, app_id, message_id): + app_id = str(app_id) + message_id = str(message_id) + + parser = reqparse.RequestParser() + parser.add_argument('response_mode', type=str, required=True, choices=['blocking', 'streaming'], location='args') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + # get app info + app_model = _get_app(app_id, 'completion') + + try: + response = CompletionService.generate_more_like_this(app_model, current_user, message_id, streaming) + return compact_response(response) + except MessageNotExistsError: + raise NotFound("Message Not Exists.") + except MoreLikeThisDisabledError: + raise AppMoreLikeThisDisabledError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +def compact_response(response: Union[dict | Generator]) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype='application/json') + else: + def generate() -> Generator: + try: + for chunk in response: + yield chunk + except MessageNotExistsError: + yield "data: " + json.dumps(api.handle_error(NotFound("Message Not Exists.")).get_json()) + "\n\n" + except MoreLikeThisDisabledError: + yield "data: " + json.dumps(api.handle_error(AppMoreLikeThisDisabledError()).get_json()) + "\n\n" + except ProviderTokenNotInitError: + yield "data: " + json.dumps(api.handle_error(ProviderNotInitializeError()).get_json()) + "\n\n" + except QuotaExceededError: + yield "data: " + json.dumps(api.handle_error(ProviderQuotaExceededError()).get_json()) + "\n\n" + except ModelCurrentlyNotSupportError: + yield "data: " + json.dumps(api.handle_error(ProviderModelCurrentlyNotSupportError()).get_json()) + "\n\n" + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + yield "data: " + json.dumps(api.handle_error(CompletionRequestError(str(e))).get_json()) + "\n\n" + except ValueError as e: + yield "data: " + json.dumps(api.handle_error(e).get_json()) + "\n\n" + except Exception: + logging.exception("internal server error.") + yield "data: " + json.dumps(api.handle_error(InternalServerError()).get_json()) + "\n\n" + + return Response(stream_with_context(generate()), status=200, + mimetype='text/event-stream') + + +class MessageSuggestedQuestionApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, app_id, message_id): + app_id = str(app_id) + message_id = str(message_id) + + # get app info + app_model = _get_app(app_id, 'chat') + + try: + questions = MessageService.get_suggested_questions_after_answer( + app_model=app_model, + user=current_user, + message_id=message_id, + check_enabled=False + ) + except MessageNotExistsError: + raise NotFound("Message not found") + except ConversationNotExistsError: + raise NotFound("Conversation not found") + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except Exception: + logging.exception("internal server error.") + raise InternalServerError() + + return {'data': questions} + + +api.add_resource(MessageMoreLikeThisApi, '/apps//completion-messages//more-like-this') +api.add_resource(MessageSuggestedQuestionApi, '/apps//chat-messages//suggested-questions') +api.add_resource(ChatMessageApi, '/apps//chat-messages', endpoint='chat_messages') +api.add_resource(MessageFeedbackApi, '/apps//feedbacks') +api.add_resource(MessageAnnotationApi, '/apps//annotations') +api.add_resource(MessageAnnotationCountApi, '/apps//annotations/count') diff --git a/api/controllers/console/app/model_config.py b/api/controllers/console/app/model_config.py new file mode 100644 index 0000000000..adc10d6609 --- /dev/null +++ b/api/controllers/console/app/model_config.py @@ -0,0 +1,65 @@ +# -*- coding:utf-8 -*- +import json + +from flask import request +from flask_restful import Resource +from flask_login import login_required, current_user + +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from events.app_event import app_model_config_was_updated +from extensions.ext_database import db +from models.model import AppModelConfig +from services.app_model_config_service import AppModelConfigService + + +class ModelConfigResource(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, app_id): + """Modify app model config""" + app_id = str(app_id) + + app_model = _get_app(app_id) + + # validate config + model_configuration = AppModelConfigService.validate_configuration( + account=current_user, + config=request.json, + mode=app_model.mode + ) + + new_app_model_config = AppModelConfig( + app_id=app_model.id, + provider="", + model_id="", + configs={}, + opening_statement=model_configuration['opening_statement'], + suggested_questions=json.dumps(model_configuration['suggested_questions']), + suggested_questions_after_answer=json.dumps(model_configuration['suggested_questions_after_answer']), + more_like_this=json.dumps(model_configuration['more_like_this']), + model=json.dumps(model_configuration['model']), + user_input_form=json.dumps(model_configuration['user_input_form']), + pre_prompt=model_configuration['pre_prompt'], + agent_mode=json.dumps(model_configuration['agent_mode']), + ) + + db.session.add(new_app_model_config) + db.session.flush() + + app_model.app_model_config_id = new_app_model_config.id + db.session.commit() + + app_model_config_was_updated.send( + app_model, + app_model_config=new_app_model_config + ) + + return {'result': 'success'} + + +api.add_resource(ModelConfigResource, '/apps//model-config') diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py new file mode 100644 index 0000000000..2e0e00a881 --- /dev/null +++ b/api/controllers/console/app/site.py @@ -0,0 +1,114 @@ +# -*- coding:utf-8 -*- +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal_with +from werkzeug.exceptions import NotFound, Forbidden + +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import supported_language +from extensions.ext_database import db +from models.model import Site + +app_site_fields = { + 'app_id': fields.String, + 'access_token': fields.String(attribute='code'), + 'code': fields.String, + 'title': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'description': fields.String, + 'default_language': fields.String, + 'customize_domain': fields.String, + 'copyright': fields.String, + 'privacy_policy': fields.String, + 'customize_token_strategy': fields.String, + 'prompt_public': fields.Boolean +} + + +def parse_app_site_args(): + parser = reqparse.RequestParser() + parser.add_argument('title', type=str, required=False, location='json') + parser.add_argument('icon', type=str, required=False, location='json') + parser.add_argument('icon_background', type=str, required=False, location='json') + parser.add_argument('description', type=str, required=False, location='json') + parser.add_argument('default_language', type=supported_language, required=False, location='json') + parser.add_argument('customize_domain', type=str, required=False, location='json') + parser.add_argument('copyright', type=str, required=False, location='json') + parser.add_argument('privacy_policy', type=str, required=False, location='json') + parser.add_argument('customize_token_strategy', type=str, choices=['must', 'allow', 'not_allow'], + required=False, + location='json') + parser.add_argument('prompt_public', type=bool, required=False, location='json') + return parser.parse_args() + + +class AppSite(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_site_fields) + def post(self, app_id): + args = parse_app_site_args() + + app_id = str(app_id) + app_model = _get_app(app_id) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + site = db.session.query(Site). \ + filter(Site.app_id == app_model.id). \ + one_or_404() + + for attr_name in [ + 'title', + 'icon', + 'icon_background', + 'description', + 'default_language', + 'customize_domain', + 'copyright', + 'privacy_policy', + 'customize_token_strategy', + 'prompt_public' + ]: + value = args.get(attr_name) + if value is not None: + setattr(site, attr_name, value) + + db.session.commit() + + return site + + +class AppSiteAccessTokenReset(Resource): + + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_site_fields) + def post(self, app_id): + app_id = str(app_id) + app_model = _get_app(app_id) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + site = db.session.query(Site).filter(Site.app_id == app_model.id).first() + + if not site: + raise NotFound + + site.code = Site.generate_code(16) + db.session.commit() + + return site + + +api.add_resource(AppSite, '/apps//site') +api.add_resource(AppSiteAccessTokenReset, '/apps//site/access-token-reset') diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py new file mode 100644 index 0000000000..37c3500448 --- /dev/null +++ b/api/controllers/console/app/statistic.py @@ -0,0 +1,202 @@ +# -*- coding:utf-8 -*- +from datetime import datetime + +import pytz +from flask import jsonify +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse + +from controllers.console import api +from controllers.console.app import _get_app +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import datetime_string +from extensions.ext_database import db + + +class DailyConversationStatistic(Resource): + + @setup_required + @login_required + @account_initialization_required + def get(self, app_id): + account = current_user + app_id = str(app_id) + app_model = _get_app(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('end', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + args = parser.parse_args() + + sql_query = ''' + SELECT date(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, count(distinct messages.conversation_id) AS conversation_count + FROM messages where app_id = :app_id + ''' + arg_dict = {'tz': account.timezone, 'app_id': app_model.id} + + timezone = pytz.timezone(account.timezone) + utc_timezone = pytz.utc + + if args['start']: + start_datetime = datetime.strptime(args['start'], '%Y-%m-%d %H:%M') + start_datetime = start_datetime.replace(second=0) + + start_datetime_timezone = timezone.localize(start_datetime) + start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at >= :start' + arg_dict['start'] = start_datetime_utc + + if args['end']: + end_datetime = datetime.strptime(args['end'], '%Y-%m-%d %H:%M') + end_datetime = end_datetime.replace(second=0) + + end_datetime_timezone = timezone.localize(end_datetime) + end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at < :end' + arg_dict['end'] = end_datetime_utc + + sql_query += ' GROUP BY date order by date' + rs = db.session.execute(sql_query, arg_dict) + + response_date = [] + + for i in rs: + response_date.append({ + 'date': str(i.date), + 'conversation_count': i.conversation_count + }) + + return jsonify({ + 'data': response_date + }) + + +class DailyTerminalsStatistic(Resource): + + @setup_required + @login_required + @account_initialization_required + def get(self, app_id): + account = current_user + app_id = str(app_id) + app_model = _get_app(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('end', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + args = parser.parse_args() + + sql_query = ''' + SELECT date(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, count(distinct messages.from_end_user_id) AS terminal_count + FROM messages where app_id = :app_id + ''' + arg_dict = {'tz': account.timezone, 'app_id': app_model.id} + + timezone = pytz.timezone(account.timezone) + utc_timezone = pytz.utc + + if args['start']: + start_datetime = datetime.strptime(args['start'], '%Y-%m-%d %H:%M') + start_datetime = start_datetime.replace(second=0) + + start_datetime_timezone = timezone.localize(start_datetime) + start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at >= :start' + arg_dict['start'] = start_datetime_utc + + if args['end']: + end_datetime = datetime.strptime(args['end'], '%Y-%m-%d %H:%M') + end_datetime = end_datetime.replace(second=0) + + end_datetime_timezone = timezone.localize(end_datetime) + end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at < :end' + arg_dict['end'] = end_datetime_utc + + sql_query += ' GROUP BY date order by date' + rs = db.session.execute(sql_query, arg_dict) + + response_date = [] + + for i in rs: + response_date.append({ + 'date': str(i.date), + 'terminal_count': i.terminal_count + }) + + return jsonify({ + 'data': response_date + }) + + +class DailyTokenCostStatistic(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, app_id): + account = current_user + app_id = str(app_id) + app_model = _get_app(app_id) + + parser = reqparse.RequestParser() + parser.add_argument('start', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + parser.add_argument('end', type=datetime_string('%Y-%m-%d %H:%M'), location='args') + args = parser.parse_args() + + sql_query = ''' + SELECT date(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date, + (sum(messages.message_tokens) + sum(messages.answer_tokens)) as token_count, + sum(total_price) as total_price + FROM messages where app_id = :app_id + ''' + arg_dict = {'tz': account.timezone, 'app_id': app_model.id} + + timezone = pytz.timezone(account.timezone) + utc_timezone = pytz.utc + + if args['start']: + start_datetime = datetime.strptime(args['start'], '%Y-%m-%d %H:%M') + start_datetime = start_datetime.replace(second=0) + + start_datetime_timezone = timezone.localize(start_datetime) + start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at >= :start' + arg_dict['start'] = start_datetime_utc + + if args['end']: + end_datetime = datetime.strptime(args['end'], '%Y-%m-%d %H:%M') + end_datetime = end_datetime.replace(second=0) + + end_datetime_timezone = timezone.localize(end_datetime) + end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone) + + sql_query += ' and created_at < :end' + arg_dict['end'] = end_datetime_utc + + sql_query += ' GROUP BY date order by date' + rs = db.session.execute(sql_query, arg_dict) + + response_date = [] + + for i in rs: + response_date.append({ + 'date': str(i.date), + 'token_count': i.token_count, + 'total_price': i.total_price, + 'currency': 'USD' + }) + + return jsonify({ + 'data': response_date + }) + + +api.add_resource(DailyConversationStatistic, '/apps//statistics/daily-conversations') +api.add_resource(DailyTerminalsStatistic, '/apps//statistics/daily-end-users') +api.add_resource(DailyTokenCostStatistic, '/apps//statistics/token-costs') diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py new file mode 100644 index 0000000000..89f5d91789 --- /dev/null +++ b/api/controllers/console/auth/login.py @@ -0,0 +1,109 @@ +# -*- coding:utf-8 -*- +import flask +import flask_login +from flask import request, current_app +from flask_restful import Resource, reqparse + +import services +from controllers.console import api +from controllers.console.error import AccountNotLinkTenantError +from controllers.console.setup import setup_required +from libs.helper import email +from libs.password import valid_password +from services.account_service import AccountService, TenantService + + +class LoginApi(Resource): + """Resource for user login.""" + + @setup_required + def post(self): + """Authenticate user and login.""" + parser = reqparse.RequestParser() + parser.add_argument('email', type=email, required=True, location='json') + parser.add_argument('password', type=valid_password, required=True, location='json') + parser.add_argument('remember_me', type=bool, required=False, default=False, location='json') + args = parser.parse_args() + + # todo: Verify the recaptcha + + try: + account = AccountService.authenticate(args['email'], args['password']) + except services.errors.account.AccountLoginError: + return {'code': 'unauthorized', 'message': 'Invalid email or password'}, 401 + + try: + TenantService.switch_tenant(account) + except Exception: + raise AccountNotLinkTenantError("Account not link tenant") + + flask_login.login_user(account, remember=args['remember_me']) + AccountService.update_last_login(account, request) + + # todo: return the user info + + return {'result': 'success'} + + +class LogoutApi(Resource): + + @setup_required + def get(self): + flask.session.pop('workspace_id', None) + flask_login.logout_user() + return {'result': 'success'} + + +class ResetPasswordApi(Resource): + @setup_required + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('email', type=email, required=True, location='json') + args = parser.parse_args() + + # import mailchimp_transactional as MailchimpTransactional + # from mailchimp_transactional.api_client import ApiClientError + + account = {'email': args['email']} + # account = AccountService.get_by_email(args['email']) + # if account is None: + # raise ValueError('Email not found') + # new_password = AccountService.generate_password() + # AccountService.update_password(account, new_password) + + # todo: Send email + MAILCHIMP_API_KEY = current_app.config['MAILCHIMP_TRANSACTIONAL_API_KEY'] + # mailchimp = MailchimpTransactional(MAILCHIMP_API_KEY) + + message = { + 'from_email': 'noreply@example.com', + 'to': [{'email': account.email}], + 'subject': 'Reset your Dify password', + 'html': """ +

Dear User,

+

The Dify team has generated a new password for you, details as follows:

+

{new_password}

+

Please change your password to log in as soon as possible.

+

Regards,

+

The Dify Team

+ """ + } + + # response = mailchimp.messages.send({ + # 'message': message, + # # required for transactional email + # ' settings': { + # 'sandbox_mode': current_app.config['MAILCHIMP_SANDBOX_MODE'], + # }, + # }) + + # Check if MSG was sent + # if response.status_code != 200: + # # handle error + # pass + + return {'result': 'success'} + + +api.add_resource(LoginApi, '/login') +api.add_resource(LogoutApi, '/logout') diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py new file mode 100644 index 0000000000..ababc30de9 --- /dev/null +++ b/api/controllers/console/auth/oauth.py @@ -0,0 +1,126 @@ +import logging +from datetime import datetime +from typing import Optional + +import flask_login +import requests +from flask import request, redirect, current_app, session +from flask_restful import Resource + +from libs.oauth import OAuthUserInfo, GitHubOAuth, GoogleOAuth +from extensions.ext_database import db +from models.account import Account, AccountStatus +from services.account_service import AccountService, RegisterService +from .. import api + + +def get_oauth_providers(): + with current_app.app_context(): + github_oauth = GitHubOAuth(client_id=current_app.config.get('GITHUB_CLIENT_ID'), + client_secret=current_app.config.get( + 'GITHUB_CLIENT_SECRET'), + redirect_uri=current_app.config.get( + 'CONSOLE_URL') + '/console/api/oauth/authorize/github') + + google_oauth = GoogleOAuth(client_id=current_app.config.get('GOOGLE_CLIENT_ID'), + client_secret=current_app.config.get( + 'GOOGLE_CLIENT_SECRET'), + redirect_uri=current_app.config.get( + 'CONSOLE_URL') + '/console/api/oauth/authorize/google') + + OAUTH_PROVIDERS = { + 'github': github_oauth, + 'google': google_oauth + } + return OAUTH_PROVIDERS + + +class OAuthLogin(Resource): + def get(self, provider: str): + OAUTH_PROVIDERS = get_oauth_providers() + with current_app.app_context(): + oauth_provider = OAUTH_PROVIDERS.get(provider) + print(vars(oauth_provider)) + if not oauth_provider: + return {'error': 'Invalid provider'}, 400 + + auth_url = oauth_provider.get_authorization_url() + return redirect(auth_url) + + +class OAuthCallback(Resource): + def get(self, provider: str): + OAUTH_PROVIDERS = get_oauth_providers() + with current_app.app_context(): + oauth_provider = OAUTH_PROVIDERS.get(provider) + if not oauth_provider: + return {'error': 'Invalid provider'}, 400 + + code = request.args.get('code') + try: + token = oauth_provider.get_access_token(code) + user_info = oauth_provider.get_user_info(token) + except requests.exceptions.HTTPError as e: + logging.exception( + f"An error occurred during the OAuth process with {provider}: {e.response.text}") + return {'error': 'OAuth process failed'}, 400 + + account = _generate_account(provider, user_info) + # Check account status + if account.status == AccountStatus.BANNED.value or account.status == AccountStatus.CLOSED.value: + return {'error': 'Account is banned or closed.'}, 403 + + if account.status == AccountStatus.PENDING.value: + account.status = AccountStatus.ACTIVE.value + account.initialized_at = datetime.utcnow() + db.session.commit() + + # login user + session.clear() + flask_login.login_user(account, remember=True) + AccountService.update_last_login(account, request) + + return redirect(f'{current_app.config.get("CONSOLE_URL")}?oauth_login=success') + + +def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) -> Optional[Account]: + account = Account.get_by_openid(provider, user_info.id) + + if not account: + account = Account.query.filter_by(email=user_info.email).first() + + return account + + +def _generate_account(provider: str, user_info: OAuthUserInfo): + # Get account by openid or email. + account = _get_account_by_openid_or_email(provider, user_info) + + if not account: + # Create account + account_name = user_info.name if user_info.name else 'Dify' + account = RegisterService.register( + email=user_info.email, + name=account_name, + password=None, + open_id=user_info.id, + provider=provider + ) + + # Set interface language + preferred_lang = request.accept_languages.best_match(['zh', 'en']) + if preferred_lang == 'zh': + interface_language = 'zh-Hans' + else: + interface_language = 'en-US' + account.interface_language = interface_language + db.session.commit() + + # Link account + AccountService.link_account_integrate(provider, user_info.id, account) + + return account + + +api.add_resource(OAuthLogin, '/oauth/login/') +api.add_resource(OAuthCallback, '/oauth/authorize/') diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py new file mode 100644 index 0000000000..04a4a44840 --- /dev/null +++ b/api/controllers/console/datasets/datasets.py @@ -0,0 +1,281 @@ +# -*- coding:utf-8 -*- +from flask import request +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal, marshal_with +from werkzeug.exceptions import NotFound, Forbidden + +import services +from controllers.console import api +from controllers.console.datasets.error import DatasetNameDuplicateError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.indexing_runner import IndexingRunner +from libs.helper import TimestampField +from extensions.ext_database import db +from models.model import UploadFile +from services.dataset_service import DatasetService + +dataset_detail_fields = { + 'id': fields.String, + 'name': fields.String, + 'description': fields.String, + 'provider': fields.String, + 'permission': fields.String, + 'data_source_type': fields.String, + 'indexing_technique': fields.String, + 'app_count': fields.Integer, + 'document_count': fields.Integer, + 'word_count': fields.Integer, + 'created_by': fields.String, + 'created_at': TimestampField, + 'updated_by': fields.String, + 'updated_at': TimestampField, +} + +dataset_query_detail_fields = { + "id": fields.String, + "content": fields.String, + "source": fields.String, + "source_app_id": fields.String, + "created_by_role": fields.String, + "created_by": fields.String, + "created_at": TimestampField +} + + +def _validate_name(name): + if not name or len(name) < 1 or len(name) > 40: + raise ValueError('Name must be between 1 to 40 characters.') + return name + + +def _validate_description_length(description): + if len(description) > 200: + raise ValueError('Description cannot exceed 200 characters.') + return description + + +class DatasetListApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def get(self): + page = request.args.get('page', default=1, type=int) + limit = request.args.get('limit', default=20, type=int) + ids = request.args.getlist('ids') + provider = request.args.get('provider', default="vendor") + if ids: + datasets, total = DatasetService.get_datasets_by_ids(ids, current_user.current_tenant_id) + else: + datasets, total = DatasetService.get_datasets(page, limit, provider, + current_user.current_tenant_id, current_user) + + response = { + 'data': marshal(datasets, dataset_detail_fields), + 'has_more': len(datasets) == limit, + 'limit': limit, + 'total': total, + 'page': page + } + return response, 200 + + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('name', nullable=False, required=True, + help='type is required. Name must be between 1 to 40 characters.', + type=_validate_name) + parser.add_argument('indexing_technique', type=str, location='json', + choices=('high_quality', 'economy'), + help='Invalid indexing technique.') + args = parser.parse_args() + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + try: + dataset = DatasetService.create_empty_dataset( + tenant_id=current_user.current_tenant_id, + name=args['name'], + indexing_technique=args['indexing_technique'], + account=current_user + ) + except services.errors.dataset.DatasetNameDuplicateError: + raise DatasetNameDuplicateError() + + return marshal(dataset, dataset_detail_fields), 201 + + +class DatasetApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id): + dataset_id_str = str(dataset_id) + dataset = DatasetService.get_dataset(dataset_id_str) + if dataset is None: + raise NotFound("Dataset not found.") + + try: + DatasetService.check_dataset_permission( + dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + return marshal(dataset, dataset_detail_fields), 200 + + @setup_required + @login_required + @account_initialization_required + def patch(self, dataset_id): + dataset_id_str = str(dataset_id) + + parser = reqparse.RequestParser() + parser.add_argument('name', nullable=False, + help='type is required. Name must be between 1 to 40 characters.', + type=_validate_name) + parser.add_argument('description', + location='json', store_missing=False, + type=_validate_description_length) + parser.add_argument('indexing_technique', type=str, location='json', + choices=('high_quality', 'economy'), + help='Invalid indexing technique.') + parser.add_argument('permission', type=str, location='json', choices=( + 'only_me', 'all_team_members'), help='Invalid permission.') + args = parser.parse_args() + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + dataset = DatasetService.update_dataset( + dataset_id_str, args, current_user) + + if dataset is None: + raise NotFound("Dataset not found.") + + return marshal(dataset, dataset_detail_fields), 200 + + @setup_required + @login_required + @account_initialization_required + def delete(self, dataset_id): + dataset_id_str = str(dataset_id) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + if DatasetService.delete_dataset(dataset_id_str, current_user): + return {'result': 'success'}, 204 + else: + raise NotFound("Dataset not found.") + + +class DatasetQueryApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id): + dataset_id_str = str(dataset_id) + dataset = DatasetService.get_dataset(dataset_id_str) + if dataset is None: + raise NotFound("Dataset not found.") + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + page = request.args.get('page', default=1, type=int) + limit = request.args.get('limit', default=20, type=int) + + dataset_queries, total = DatasetService.get_dataset_queries( + dataset_id=dataset.id, + page=page, + per_page=limit + ) + + response = { + 'data': marshal(dataset_queries, dataset_query_detail_fields), + 'has_more': len(dataset_queries) == limit, + 'limit': limit, + 'total': total, + 'page': page + } + return response, 200 + + +class DatasetIndexingEstimateApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self): + segment_rule = request.get_json() + file_detail = db.session.query(UploadFile).filter( + UploadFile.tenant_id == current_user.current_tenant_id, + UploadFile.id == segment_rule["file_id"] + ).first() + + if file_detail is None: + raise NotFound("File not found.") + + indexing_runner = IndexingRunner() + response = indexing_runner.indexing_estimate(file_detail, segment_rule['process_rule']) + return response, 200 + + +class DatasetRelatedAppListApi(Resource): + app_detail_kernel_fields = { + 'id': fields.String, + 'name': fields.String, + 'mode': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + } + + related_app_list = { + 'data': fields.List(fields.Nested(app_detail_kernel_fields)), + 'total': fields.Integer, + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(related_app_list) + def get(self, dataset_id): + dataset_id_str = str(dataset_id) + dataset = DatasetService.get_dataset(dataset_id_str) + if dataset is None: + raise NotFound("Dataset not found.") + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + app_dataset_joins = DatasetService.get_related_apps(dataset.id) + + related_apps = [] + for app_dataset_join in app_dataset_joins: + app_model = app_dataset_join.app + if app_model: + related_apps.append(app_model) + + return { + 'data': related_apps, + 'total': len(related_apps) + }, 200 + + +api.add_resource(DatasetListApi, '/datasets') +api.add_resource(DatasetApi, '/datasets/') +api.add_resource(DatasetQueryApi, '/datasets//queries') +api.add_resource(DatasetIndexingEstimateApi, '/datasets/file-indexing-estimate') +api.add_resource(DatasetRelatedAppListApi, '/datasets//related-apps') diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py new file mode 100644 index 0000000000..79e52d565b --- /dev/null +++ b/api/controllers/console/datasets/datasets_document.py @@ -0,0 +1,682 @@ +# -*- coding:utf-8 -*- +import random +from datetime import datetime + +from flask import request +from flask_login import login_required, current_user +from flask_restful import Resource, fields, marshal, marshal_with, reqparse +from sqlalchemy import desc, asc +from werkzeug.exceptions import NotFound, Forbidden + +import services +from controllers.console import api +from controllers.console.app.error import ProviderNotInitializeError +from controllers.console.datasets.error import DocumentAlreadyFinishedError, InvalidActionError, DocumentIndexingError, \ + InvalidMetadataError, ArchivedDocumentImmutableError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.indexing_runner import IndexingRunner +from core.llm.error import ProviderTokenNotInitError +from extensions.ext_redis import redis_client +from libs.helper import TimestampField +from extensions.ext_database import db +from models.dataset import DatasetProcessRule, Dataset +from models.dataset import Document, DocumentSegment +from models.model import UploadFile +from services.dataset_service import DocumentService, DatasetService +from tasks.add_document_to_index_task import add_document_to_index_task +from tasks.remove_document_from_index_task import remove_document_from_index_task + +dataset_fields = { + 'id': fields.String, + 'name': fields.String, + 'description': fields.String, + 'permission': fields.String, + 'data_source_type': fields.String, + 'indexing_technique': fields.String, + 'created_by': fields.String, + 'created_at': TimestampField, +} + +document_fields = { + 'id': fields.String, + 'position': fields.Integer, + 'data_source_type': fields.String, + 'data_source_info': fields.Raw(attribute='data_source_info_dict'), + 'dataset_process_rule_id': fields.String, + 'name': fields.String, + 'created_from': fields.String, + 'created_by': fields.String, + 'created_at': TimestampField, + 'tokens': fields.Integer, + 'indexing_status': fields.String, + 'error': fields.String, + 'enabled': fields.Boolean, + 'disabled_at': TimestampField, + 'disabled_by': fields.String, + 'archived': fields.Boolean, + 'display_status': fields.String, + 'word_count': fields.Integer, + 'hit_count': fields.Integer, +} + + +class DocumentResource(Resource): + def get_document(self, dataset_id: str, document_id: str) -> Document: + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + document = DocumentService.get_document(dataset_id, document_id) + + if not document: + raise NotFound('Document not found.') + + if document.tenant_id != current_user.current_tenant_id: + raise Forbidden('No permission.') + + return document + + +class GetProcessRuleApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + req_data = request.args + + document_id = req_data.get('document_id') + if document_id: + # get the latest process rule + document = Document.query.get_or_404(document_id) + + dataset = DatasetService.get_dataset(document.dataset_id) + + if not dataset: + raise NotFound('Dataset not found.') + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + # get the latest process rule + dataset_process_rule = db.session.query(DatasetProcessRule). \ + filter(DatasetProcessRule.dataset_id == document.dataset_id). \ + order_by(DatasetProcessRule.created_at.desc()). \ + limit(1). \ + one_or_none() + mode = dataset_process_rule.mode + rules = dataset_process_rule.rules_dict + else: + mode = DocumentService.DEFAULT_RULES['mode'] + rules = DocumentService.DEFAULT_RULES['rules'] + + return { + 'mode': mode, + 'rules': rules + } + + +class DatasetDocumentListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id): + dataset_id = str(dataset_id) + page = request.args.get('page', default=1, type=int) + limit = request.args.get('limit', default=20, type=int) + search = request.args.get('search', default=None, type=str) + sort = request.args.get('sort', default='-created_at', type=str) + + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + query = Document.query.filter_by( + dataset_id=str(dataset_id), tenant_id=current_user.current_tenant_id) + + if search: + search = f'%{search}%' + query = query.filter(Document.name.like(search)) + + if sort.startswith('-'): + sort_logic = desc + sort = sort[1:] + else: + sort_logic = asc + + if sort == 'hit_count': + sub_query = db.select(DocumentSegment.document_id, + db.func.sum(DocumentSegment.hit_count).label("total_hit_count")) \ + .group_by(DocumentSegment.document_id) \ + .subquery() + + query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id) \ + .order_by(sort_logic(db.func.coalesce(sub_query.c.total_hit_count, 0))) + elif sort == 'created_at': + query = query.order_by(sort_logic(Document.created_at)) + else: + query = query.order_by(desc(Document.created_at)) + + paginated_documents = query.paginate( + page=page, per_page=limit, max_per_page=100, error_out=False) + documents = paginated_documents.items + + response = { + 'data': marshal(documents, document_fields), + 'has_more': len(documents) == limit, + 'limit': limit, + 'total': paginated_documents.total, + 'page': page + } + + return response + + @setup_required + @login_required + @account_initialization_required + @marshal_with(document_fields) + def post(self, dataset_id): + dataset_id = str(dataset_id) + + dataset = DatasetService.get_dataset(dataset_id) + + if not dataset: + raise NotFound('Dataset not found.') + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + parser = reqparse.RequestParser() + parser.add_argument('indexing_technique', type=str, choices=Dataset.INDEXING_TECHNIQUE_LIST, nullable=False, + location='json') + parser.add_argument('data_source', type=dict, required=True, nullable=True, location='json') + parser.add_argument('process_rule', type=dict, required=True, nullable=True, location='json') + parser.add_argument('duplicate', type=bool, nullable=False, location='json') + args = parser.parse_args() + + if not dataset.indexing_technique and not args['indexing_technique']: + raise ValueError('indexing_technique is required.') + + # validate args + DocumentService.document_create_args_validate(args) + + try: + document = DocumentService.save_document_with_dataset_id(dataset, args, current_user) + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + + return document + + +class DatasetInitApi(Resource): + dataset_and_document_fields = { + 'dataset': fields.Nested(dataset_fields), + 'document': fields.Nested(document_fields) + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(dataset_and_document_fields) + def post(self): + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument('indexing_technique', type=str, choices=Dataset.INDEXING_TECHNIQUE_LIST, required=True, + nullable=False, location='json') + parser.add_argument('data_source', type=dict, required=True, nullable=True, location='json') + parser.add_argument('process_rule', type=dict, required=True, nullable=True, location='json') + args = parser.parse_args() + + # validate args + DocumentService.document_create_args_validate(args) + + try: + dataset, document = DocumentService.save_document_without_dataset_id( + tenant_id=current_user.current_tenant_id, + document_data=args, + account=current_user + ) + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + + response = { + 'dataset': dataset, + 'document': document + } + + return response + + +class DocumentIndexingEstimateApi(DocumentResource): + + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + if document.indexing_status in ['completed', 'error']: + raise DocumentAlreadyFinishedError() + + data_process_rule = document.dataset_process_rule + data_process_rule_dict = data_process_rule.to_dict() + + response = { + "tokens": 0, + "total_price": 0, + "currency": "USD", + "total_segments": 0, + "preview": [] + } + + if document.data_source_type == 'upload_file': + data_source_info = document.data_source_info_dict + if data_source_info and 'upload_file_id' in data_source_info: + file_id = data_source_info['upload_file_id'] + + file = db.session.query(UploadFile).filter( + UploadFile.tenant_id == document.tenant_id, + UploadFile.id == file_id + ).first() + + # raise error if file not found + if not file: + raise NotFound('File not found.') + + indexing_runner = IndexingRunner() + response = indexing_runner.indexing_estimate(file, data_process_rule_dict) + + return response + + +class DocumentIndexingStatusApi(DocumentResource): + document_status_fields = { + 'id': fields.String, + 'indexing_status': fields.String, + 'processing_started_at': TimestampField, + 'parsing_completed_at': TimestampField, + 'cleaning_completed_at': TimestampField, + 'splitting_completed_at': TimestampField, + 'completed_at': TimestampField, + 'paused_at': TimestampField, + 'error': fields.String, + 'stopped_at': TimestampField, + 'completed_segments': fields.Integer, + 'total_segments': fields.Integer, + } + + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + completed_segments = DocumentSegment.query \ + .filter(DocumentSegment.completed_at.isnot(None), + DocumentSegment.document_id == str(document_id)) \ + .count() + total_segments = DocumentSegment.query \ + .filter_by(document_id=str(document_id)) \ + .count() + + document.completed_segments = completed_segments + document.total_segments = total_segments + + return marshal(document, self.document_status_fields) + + +class DocumentDetailApi(DocumentResource): + METADATA_CHOICES = {'all', 'only', 'without'} + + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + metadata = request.args.get('metadata', 'all') + if metadata not in self.METADATA_CHOICES: + raise InvalidMetadataError(f'Invalid metadata value: {metadata}') + + if metadata == 'only': + response = { + 'id': document.id, + 'doc_type': document.doc_type, + 'doc_metadata': document.doc_metadata + } + elif metadata == 'without': + process_rules = DatasetService.get_process_rules(dataset_id) + data_source_info = document.data_source_detail_dict + response = { + 'id': document.id, + 'position': document.position, + 'data_source_type': document.data_source_type, + 'data_source_info': data_source_info, + 'dataset_process_rule_id': document.dataset_process_rule_id, + 'dataset_process_rule': process_rules, + 'name': document.name, + 'created_from': document.created_from, + 'created_by': document.created_by, + 'created_at': document.created_at.timestamp(), + 'tokens': document.tokens, + 'indexing_status': document.indexing_status, + 'completed_at': int(document.completed_at.timestamp()) if document.completed_at else None, + 'updated_at': int(document.updated_at.timestamp()) if document.updated_at else None, + 'indexing_latency': document.indexing_latency, + 'error': document.error, + 'enabled': document.enabled, + 'disabled_at': int(document.disabled_at.timestamp()) if document.disabled_at else None, + 'disabled_by': document.disabled_by, + 'archived': document.archived, + 'segment_count': document.segment_count, + 'average_segment_length': document.average_segment_length, + 'hit_count': document.hit_count, + 'display_status': document.display_status + } + else: + process_rules = DatasetService.get_process_rules(dataset_id) + data_source_info = document.data_source_detail_dict_() + response = { + 'id': document.id, + 'position': document.position, + 'data_source_type': document.data_source_type, + 'data_source_info': data_source_info, + 'dataset_process_rule_id': document.dataset_process_rule_id, + 'dataset_process_rule': process_rules, + 'name': document.name, + 'created_from': document.created_from, + 'created_by': document.created_by, + 'created_at': document.created_at.timestamp(), + 'tokens': document.tokens, + 'indexing_status': document.indexing_status, + 'completed_at': int(document.completed_at.timestamp())if document.completed_at else None, + 'updated_at': int(document.updated_at.timestamp()) if document.updated_at else None, + 'indexing_latency': document.indexing_latency, + 'error': document.error, + 'enabled': document.enabled, + 'disabled_at': int(document.disabled_at.timestamp()) if document.disabled_at else None, + 'disabled_by': document.disabled_by, + 'archived': document.archived, + 'doc_type': document.doc_type, + 'doc_metadata': document.doc_metadata, + 'segment_count': document.segment_count, + 'average_segment_length': document.average_segment_length, + 'hit_count': document.hit_count, + 'display_status': document.display_status + } + + return response, 200 + + +class DocumentProcessingApi(DocumentResource): + @setup_required + @login_required + @account_initialization_required + def patch(self, dataset_id, document_id, action): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + if action == "pause": + if document.indexing_status != "indexing": + raise InvalidActionError('Document not in indexing state.') + + document.paused_by = current_user.id + document.paused_at = datetime.utcnow() + document.is_paused = True + db.session.commit() + + elif action == "resume": + if document.indexing_status not in ["paused", "error"]: + raise InvalidActionError('Document not in paused or error state.') + + document.paused_by = None + document.paused_at = None + document.is_paused = False + db.session.commit() + else: + raise InvalidActionError() + + return {'result': 'success'}, 200 + + +class DocumentDeleteApi(DocumentResource): + @setup_required + @login_required + @account_initialization_required + def delete(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + try: + DocumentService.delete_document(document) + except services.errors.document.DocumentIndexingError: + raise DocumentIndexingError('Cannot delete document during indexing.') + + return {'result': 'success'}, 204 + + +class DocumentMetadataApi(DocumentResource): + @setup_required + @login_required + @account_initialization_required + def put(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + req_data = request.get_json() + + doc_type = req_data.get('doc_type') + doc_metadata = req_data.get('doc_metadata') + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + if doc_type is None or doc_metadata is None: + raise ValueError('Both doc_type and doc_metadata must be provided.') + + if doc_type not in DocumentService.DOCUMENT_METADATA_SCHEMA: + raise ValueError('Invalid doc_type.') + + if not isinstance(doc_metadata, dict): + raise ValueError('doc_metadata must be a dictionary.') + + metadata_schema = DocumentService.DOCUMENT_METADATA_SCHEMA[doc_type] + + document.doc_metadata = {} + + for key, value_type in metadata_schema.items(): + value = doc_metadata.get(key) + if value is not None and isinstance(value, value_type): + document.doc_metadata[key] = value + + document.doc_type = doc_type + document.updated_at = datetime.utcnow() + db.session.commit() + + return {'result': 'success', 'message': 'Document metadata updated.'}, 200 + + +class DocumentStatusApi(DocumentResource): + @setup_required + @login_required + @account_initialization_required + def patch(self, dataset_id, document_id, action): + dataset_id = str(dataset_id) + document_id = str(document_id) + document = self.get_document(dataset_id, document_id) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + indexing_cache_key = 'document_{}_indexing'.format(document.id) + cache_result = redis_client.get(indexing_cache_key) + if cache_result is not None: + raise InvalidActionError("Document is being indexed, please try again later") + + if action == "enable": + if document.enabled: + raise InvalidActionError('Document already enabled.') + + document.enabled = True + document.disabled_at = None + document.disabled_by = None + document.updated_at = datetime.utcnow() + db.session.commit() + + # Set cache to prevent indexing the same document multiple times + redis_client.setex(indexing_cache_key, 600, 1) + + add_document_to_index_task.delay(document_id) + + return {'result': 'success'}, 200 + + elif action == "disable": + if not document.enabled: + raise InvalidActionError('Document already disabled.') + + document.enabled = False + document.disabled_at = datetime.utcnow() + document.disabled_by = current_user.id + document.updated_at = datetime.utcnow() + db.session.commit() + + # Set cache to prevent indexing the same document multiple times + redis_client.setex(indexing_cache_key, 600, 1) + + remove_document_from_index_task.delay(document_id) + + return {'result': 'success'}, 200 + + elif action == "archive": + if document.archived: + raise InvalidActionError('Document already archived.') + + document.archived = True + document.archived_at = datetime.utcnow() + document.archived_by = current_user.id + document.updated_at = datetime.utcnow() + db.session.commit() + + if document.enabled: + # Set cache to prevent indexing the same document multiple times + redis_client.setex(indexing_cache_key, 600, 1) + + remove_document_from_index_task.delay(document_id) + + return {'result': 'success'}, 200 + else: + raise InvalidActionError() + + +class DocumentPauseApi(DocumentResource): + def patch(self, dataset_id, document_id): + """pause document.""" + dataset_id = str(dataset_id) + document_id = str(document_id) + + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + + document = DocumentService.get_document(dataset.id, document_id) + + # 404 if document not found + if document is None: + raise NotFound("Document Not Exists.") + + # 403 if document is archived + if DocumentService.check_archived(document): + raise ArchivedDocumentImmutableError() + + try: + # pause document + DocumentService.pause_document(document) + except services.errors.document.DocumentIndexingError: + raise DocumentIndexingError('Cannot pause completed document.') + + return {'result': 'success'}, 204 + + +class DocumentRecoverApi(DocumentResource): + def patch(self, dataset_id, document_id): + """recover document.""" + dataset_id = str(dataset_id) + document_id = str(document_id) + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + document = DocumentService.get_document(dataset.id, document_id) + + # 404 if document not found + if document is None: + raise NotFound("Document Not Exists.") + + # 403 if document is archived + if DocumentService.check_archived(document): + raise ArchivedDocumentImmutableError() + try: + # pause document + DocumentService.recover_document(document) + except services.errors.document.DocumentIndexingError: + raise DocumentIndexingError('Document is not in paused status.') + + return {'result': 'success'}, 204 + + +api.add_resource(GetProcessRuleApi, '/datasets/process-rule') +api.add_resource(DatasetDocumentListApi, + '/datasets//documents') +api.add_resource(DatasetInitApi, + '/datasets/init') +api.add_resource(DocumentIndexingEstimateApi, + '/datasets//documents//indexing-estimate') +api.add_resource(DocumentIndexingStatusApi, + '/datasets//documents//indexing-status') +api.add_resource(DocumentDetailApi, + '/datasets//documents/') +api.add_resource(DocumentProcessingApi, + '/datasets//documents//processing/') +api.add_resource(DocumentDeleteApi, + '/datasets//documents/') +api.add_resource(DocumentMetadataApi, + '/datasets//documents//metadata') +api.add_resource(DocumentStatusApi, + '/datasets//documents//status/') +api.add_resource(DocumentPauseApi, '/datasets//documents//processing/pause') +api.add_resource(DocumentRecoverApi, '/datasets//documents//processing/resume') diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py new file mode 100644 index 0000000000..2c77a44c97 --- /dev/null +++ b/api/controllers/console/datasets/datasets_segments.py @@ -0,0 +1,203 @@ +# -*- coding:utf-8 -*- +from datetime import datetime + +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal +from werkzeug.exceptions import NotFound, Forbidden + +import services +from controllers.console import api +from controllers.console.datasets.error import InvalidActionError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DocumentSegment + +from libs.helper import TimestampField +from services.dataset_service import DatasetService, DocumentService +from tasks.add_segment_to_index_task import add_segment_to_index_task +from tasks.remove_segment_from_index_task import remove_segment_from_index_task + +segment_fields = { + 'id': fields.String, + 'position': fields.Integer, + 'document_id': fields.String, + 'content': fields.String, + 'word_count': fields.Integer, + 'tokens': fields.Integer, + 'keywords': fields.List(fields.String), + 'index_node_id': fields.String, + 'index_node_hash': fields.String, + 'hit_count': fields.Integer, + 'enabled': fields.Boolean, + 'disabled_at': TimestampField, + 'disabled_by': fields.String, + 'status': fields.String, + 'created_by': fields.String, + 'created_at': TimestampField, + 'indexing_at': TimestampField, + 'completed_at': TimestampField, + 'error': fields.String, + 'stopped_at': TimestampField +} + +segment_list_response = { + 'data': fields.List(fields.Nested(segment_fields)), + 'has_more': fields.Boolean, + 'limit': fields.Integer +} + + +class DatasetDocumentSegmentListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, dataset_id, document_id): + dataset_id = str(dataset_id) + document_id = str(document_id) + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + document = DocumentService.get_document(dataset_id, document_id) + + if not document: + raise NotFound('Document not found.') + + parser = reqparse.RequestParser() + parser.add_argument('last_id', type=str, default=None, location='args') + parser.add_argument('limit', type=int, default=20, location='args') + parser.add_argument('status', type=str, + action='append', default=[], location='args') + parser.add_argument('hit_count_gte', type=int, + default=None, location='args') + parser.add_argument('enabled', type=str, default='all', location='args') + args = parser.parse_args() + + last_id = args['last_id'] + limit = min(args['limit'], 100) + status_list = args['status'] + hit_count_gte = args['hit_count_gte'] + + query = DocumentSegment.query.filter( + DocumentSegment.document_id == str(document_id), + DocumentSegment.tenant_id == current_user.current_tenant_id + ) + + if last_id is not None: + last_segment = DocumentSegment.query.get(str(last_id)) + if last_segment: + query = query.filter( + DocumentSegment.position > last_segment.position) + else: + return {'data': [], 'has_more': False, 'limit': limit}, 200 + + if status_list: + query = query.filter(DocumentSegment.status.in_(status_list)) + + if hit_count_gte is not None: + query = query.filter(DocumentSegment.hit_count >= hit_count_gte) + + if args['enabled'].lower() != 'all': + if args['enabled'].lower() == 'true': + query = query.filter(DocumentSegment.enabled == True) + elif args['enabled'].lower() == 'false': + query = query.filter(DocumentSegment.enabled == False) + + total = query.count() + segments = query.order_by(DocumentSegment.position).limit(limit + 1).all() + + has_more = False + if len(segments) > limit: + has_more = True + segments = segments[:-1] + + return { + 'data': marshal(segments, segment_fields), + 'has_more': has_more, + 'limit': limit, + 'total': total + }, 200 + + +class DatasetDocumentSegmentApi(Resource): + @setup_required + @login_required + @account_initialization_required + def patch(self, dataset_id, segment_id, action): + dataset_id = str(dataset_id) + dataset = DatasetService.get_dataset(dataset_id) + if not dataset: + raise NotFound('Dataset not found.') + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + segment = DocumentSegment.query.filter( + DocumentSegment.id == str(segment_id), + DocumentSegment.tenant_id == current_user.current_tenant_id + ).first() + + if not segment: + raise NotFound('Segment not found.') + + document_indexing_cache_key = 'document_{}_indexing'.format(segment.document_id) + cache_result = redis_client.get(document_indexing_cache_key) + if cache_result is not None: + raise InvalidActionError("Document is being indexed, please try again later") + + indexing_cache_key = 'segment_{}_indexing'.format(segment.id) + cache_result = redis_client.get(indexing_cache_key) + if cache_result is not None: + raise InvalidActionError("Segment is being indexed, please try again later") + + if action == "enable": + if segment.enabled: + raise InvalidActionError("Segment is already enabled.") + + segment.enabled = True + segment.disabled_at = None + segment.disabled_by = None + db.session.commit() + + # Set cache to prevent indexing the same segment multiple times + redis_client.setex(indexing_cache_key, 600, 1) + + add_segment_to_index_task.delay(segment.id) + + return {'result': 'success'}, 200 + elif action == "disable": + if not segment.enabled: + raise InvalidActionError("Segment is already disabled.") + + segment.enabled = False + segment.disabled_at = datetime.utcnow() + segment.disabled_by = current_user.id + db.session.commit() + + # Set cache to prevent indexing the same segment multiple times + redis_client.setex(indexing_cache_key, 600, 1) + + remove_segment_from_index_task.delay(segment.id) + + return {'result': 'success'}, 200 + else: + raise InvalidActionError() + + +api.add_resource(DatasetDocumentSegmentListApi, + '/datasets//documents//segments') +api.add_resource(DatasetDocumentSegmentApi, + '/datasets//segments//') diff --git a/api/controllers/console/datasets/error.py b/api/controllers/console/datasets/error.py new file mode 100644 index 0000000000..014822d565 --- /dev/null +++ b/api/controllers/console/datasets/error.py @@ -0,0 +1,73 @@ +from libs.exception import BaseHTTPException + + +class NoFileUploadedError(BaseHTTPException): + error_code = 'no_file_uploaded' + description = "No file uploaded." + code = 400 + + +class TooManyFilesError(BaseHTTPException): + error_code = 'too_many_files' + description = "Only one file is allowed." + code = 400 + + +class FileTooLargeError(BaseHTTPException): + error_code = 'file_too_large' + description = "File size exceeded. {message}" + code = 413 + + +class UnsupportedFileTypeError(BaseHTTPException): + error_code = 'unsupported_file_type' + description = "File type not allowed." + code = 415 + + +class HighQualityDatasetOnlyError(BaseHTTPException): + error_code = 'high_quality_dataset_only' + description = "High quality dataset only." + code = 400 + + +class DatasetNotInitializedError(BaseHTTPException): + error_code = 'dataset_not_initialized' + description = "Dataset not initialized." + code = 400 + + +class ArchivedDocumentImmutableError(BaseHTTPException): + error_code = 'archived_document_immutable' + description = "Cannot process an archived document." + code = 403 + + +class DatasetNameDuplicateError(BaseHTTPException): + error_code = 'dataset_name_duplicate' + description = "Dataset name already exists." + code = 409 + + +class InvalidActionError(BaseHTTPException): + error_code = 'invalid_action' + description = "Invalid action." + code = 400 + + +class DocumentAlreadyFinishedError(BaseHTTPException): + error_code = 'document_already_finished' + description = "Document already finished." + code = 400 + + +class DocumentIndexingError(BaseHTTPException): + error_code = 'document_indexing' + description = "Document indexing." + code = 400 + + +class InvalidMetadataError(BaseHTTPException): + error_code = 'invalid_metadata' + description = "Invalid metadata." + code = 400 diff --git a/api/controllers/console/datasets/file.py b/api/controllers/console/datasets/file.py new file mode 100644 index 0000000000..75aba5e9eb --- /dev/null +++ b/api/controllers/console/datasets/file.py @@ -0,0 +1,147 @@ +import datetime +import hashlib +import tempfile +import time +import uuid +from pathlib import Path + +from cachetools import TTLCache +from flask import request, current_app +from flask_login import login_required, current_user +from flask_restful import Resource, marshal_with, fields +from werkzeug.exceptions import NotFound + +from controllers.console import api +from controllers.console.datasets.error import NoFileUploadedError, TooManyFilesError, FileTooLargeError, \ + UnsupportedFileTypeError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.index.readers.html_parser import HTMLParser +from core.index.readers.pdf_parser import PDFParser +from extensions.ext_storage import storage +from libs.helper import TimestampField +from extensions.ext_database import db +from models.model import UploadFile + +cache = TTLCache(maxsize=None, ttl=30) + +FILE_SIZE_LIMIT = 15 * 1024 * 1024 # 15MB +ALLOWED_EXTENSIONS = ['txt', 'markdown', 'md', 'pdf', 'html', 'htm'] +PREVIEW_WORDS_LIMIT = 3000 + + +class FileApi(Resource): + file_fields = { + 'id': fields.String, + 'name': fields.String, + 'size': fields.Integer, + 'extension': fields.String, + 'mime_type': fields.String, + 'created_by': fields.String, + 'created_at': TimestampField, + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(file_fields) + def post(self): + + # get file from request + file = request.files['file'] + + # check file + if 'file' not in request.files: + raise NoFileUploadedError() + + if len(request.files) > 1: + raise TooManyFilesError() + + file_content = file.read() + file_size = len(file_content) + + if file_size > FILE_SIZE_LIMIT: + message = "({file_size} > {FILE_SIZE_LIMIT})" + raise FileTooLargeError(message) + + extension = file.filename.split('.')[-1] + if extension not in ALLOWED_EXTENSIONS: + raise UnsupportedFileTypeError() + + # user uuid as file name + file_uuid = str(uuid.uuid4()) + file_key = 'upload_files/' + current_user.current_tenant_id + '/' + file_uuid + '.' + extension + + # save file to storage + storage.save(file_key, file_content) + + # save file to db + config = current_app.config + upload_file = UploadFile( + tenant_id=current_user.current_tenant_id, + storage_type=config['STORAGE_TYPE'], + key=file_key, + name=file.filename, + size=file_size, + extension=extension, + mime_type=file.mimetype, + created_by=current_user.id, + created_at=datetime.datetime.utcnow(), + used=False, + hash=hashlib.sha3_256(file_content).hexdigest() + ) + + db.session.add(upload_file) + db.session.commit() + + return upload_file, 201 + + +class FilePreviewApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self, file_id): + file_id = str(file_id) + + key = file_id + request.path + cached_response = cache.get(key) + if cached_response and time.time() - cached_response['timestamp'] < cache.ttl: + return cached_response['response'] + + upload_file = db.session.query(UploadFile) \ + .filter(UploadFile.id == file_id) \ + .first() + + if not upload_file: + raise NotFound("File not found") + + # extract text from file + extension = upload_file.extension + if extension not in ALLOWED_EXTENSIONS: + raise UnsupportedFileTypeError() + + with tempfile.TemporaryDirectory() as temp_dir: + suffix = Path(upload_file.key).suffix + filepath = f"{temp_dir}/{next(tempfile._get_candidate_names())}{suffix}" + storage.download(upload_file.key, filepath) + + if extension == 'pdf': + parser = PDFParser({'upload_file': upload_file}) + text = parser.parse_file(Path(filepath)) + elif extension in ['html', 'htm']: + # Use BeautifulSoup to extract text + parser = HTMLParser() + text = parser.parse_file(Path(filepath)) + else: + # ['txt', 'markdown', 'md'] + with open(filepath, "rb") as fp: + data = fp.read() + text = data.decode(encoding='utf-8').strip() if data else '' + + text = text[0:PREVIEW_WORDS_LIMIT] if text else '' + return {'content': text} + + +api.add_resource(FileApi, '/files/upload') +api.add_resource(FilePreviewApi, '/files//preview') diff --git a/api/controllers/console/datasets/hit_testing.py b/api/controllers/console/datasets/hit_testing.py new file mode 100644 index 0000000000..16bb571df3 --- /dev/null +++ b/api/controllers/console/datasets/hit_testing.py @@ -0,0 +1,100 @@ +import logging + +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, marshal, fields +from werkzeug.exceptions import InternalServerError, NotFound, Forbidden + +import services +from controllers.console import api +from controllers.console.datasets.error import HighQualityDatasetOnlyError, DatasetNotInitializedError +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import TimestampField +from services.dataset_service import DatasetService +from services.hit_testing_service import HitTestingService + +document_fields = { + 'id': fields.String, + 'data_source_type': fields.String, + 'name': fields.String, + 'doc_type': fields.String, +} + +segment_fields = { + 'id': fields.String, + 'position': fields.Integer, + 'document_id': fields.String, + 'content': fields.String, + 'word_count': fields.Integer, + 'tokens': fields.Integer, + 'keywords': fields.List(fields.String), + 'index_node_id': fields.String, + 'index_node_hash': fields.String, + 'hit_count': fields.Integer, + 'enabled': fields.Boolean, + 'disabled_at': TimestampField, + 'disabled_by': fields.String, + 'status': fields.String, + 'created_by': fields.String, + 'created_at': TimestampField, + 'indexing_at': TimestampField, + 'completed_at': TimestampField, + 'error': fields.String, + 'stopped_at': TimestampField, + 'document': fields.Nested(document_fields), +} + +hit_testing_record_fields = { + 'segment': fields.Nested(segment_fields), + 'score': fields.Float, + 'tsne_position': fields.Raw +} + + +class HitTestingApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, dataset_id): + dataset_id_str = str(dataset_id) + + dataset = DatasetService.get_dataset(dataset_id_str) + if dataset is None: + raise NotFound("Dataset not found.") + + try: + DatasetService.check_dataset_permission(dataset, current_user) + except services.errors.account.NoPermissionError as e: + raise Forbidden(str(e)) + + # only high quality dataset can be used for hit testing + if dataset.indexing_technique != 'high_quality': + raise HighQualityDatasetOnlyError() + + parser = reqparse.RequestParser() + parser.add_argument('query', type=str, location='json') + args = parser.parse_args() + + query = args['query'] + + if not query or len(query) > 250: + raise ValueError('Query is required and cannot exceed 250 characters') + + try: + response = HitTestingService.retrieve( + dataset=dataset, + query=query, + account=current_user, + limit=10, + ) + + return {"query": response['query'], 'records': marshal(response['records'], hit_testing_record_fields)} + except services.errors.index.IndexNotInitializedError: + raise DatasetNotInitializedError() + except Exception as e: + logging.exception("Hit testing failed.") + raise InternalServerError(str(e)) + + +api.add_resource(HitTestingApi, '/datasets//hit-testing') diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py new file mode 100644 index 0000000000..3040423d71 --- /dev/null +++ b/api/controllers/console/error.py @@ -0,0 +1,19 @@ +from libs.exception import BaseHTTPException + + +class AlreadySetupError(BaseHTTPException): + error_code = 'already_setup' + description = "Application already setup." + code = 403 + + +class NotSetupError(BaseHTTPException): + error_code = 'not_setup' + description = "Application not setup." + code = 401 + + +class AccountNotLinkTenantError(BaseHTTPException): + error_code = 'account_not_link_tenant' + description = "Account not link tenant." + code = 403 diff --git a/api/controllers/console/setup.py b/api/controllers/console/setup.py new file mode 100644 index 0000000000..4677a2075b --- /dev/null +++ b/api/controllers/console/setup.py @@ -0,0 +1,93 @@ +# -*- coding:utf-8 -*- +from functools import wraps + +import flask_login +from flask import request, current_app +from flask_restful import Resource, reqparse + +from extensions.ext_database import db +from models.model import DifySetup +from services.account_service import AccountService, TenantService, RegisterService + +from libs.helper import email, str_len +from libs.password import valid_password + +from . import api +from .error import AlreadySetupError, NotSetupError +from .wraps import only_edition_self_hosted + + +class SetupApi(Resource): + + @only_edition_self_hosted + def get(self): + setup_status = get_setup_status() + if setup_status: + return { + 'step': 'finished', + 'setup_at': setup_status.setup_at.isoformat() + } + return {'step': 'not_start'} + + @only_edition_self_hosted + def post(self): + # is set up + if get_setup_status(): + raise AlreadySetupError() + + # is tenant created + tenant_count = TenantService.get_tenant_count() + if tenant_count > 0: + raise AlreadySetupError() + + parser = reqparse.RequestParser() + parser.add_argument('email', type=email, + required=True, location='json') + parser.add_argument('name', type=str_len( + 30), required=True, location='json') + parser.add_argument('password', type=valid_password, + required=True, location='json') + args = parser.parse_args() + + # Register + account = RegisterService.register( + email=args['email'], + name=args['name'], + password=args['password'] + ) + + setup() + + # Login + flask_login.login_user(account) + AccountService.update_last_login(account, request) + + return {'result': 'success'}, 201 + + +def setup(): + dify_setup = DifySetup( + version=current_app.config['CURRENT_VERSION'] + ) + db.session.add(dify_setup) + + +def setup_required(view): + @wraps(view) + def decorated(*args, **kwargs): + # check setup + if not get_setup_status(): + raise NotSetupError() + + return view(*args, **kwargs) + + return decorated + + +def get_setup_status(): + if current_app.config['EDITION'] == 'SELF_HOSTED': + return DifySetup.query.first() + else: + return True + +api.add_resource(SetupApi, '/setup') diff --git a/api/controllers/console/version.py b/api/controllers/console/version.py new file mode 100644 index 0000000000..0e6e75c361 --- /dev/null +++ b/api/controllers/console/version.py @@ -0,0 +1,39 @@ +# -*- coding:utf-8 -*- + +import json +import logging + +import requests +from flask import current_app +from flask_restful import reqparse, Resource +from werkzeug.exceptions import InternalServerError + +from . import api + + +class VersionApi(Resource): + + def get(self): + parser = reqparse.RequestParser() + parser.add_argument('current_version', type=str, required=True, location='args') + args = parser.parse_args() + check_update_url = current_app.config['CHECK_UPDATE_URL'] + + try: + response = requests.get(check_update_url, { + 'current_version': args.get('current_version') + }) + except Exception as error: + logging.exception("Check update error.") + raise InternalServerError() + + content = json.loads(response.content) + return { + 'version': content['version'], + 'release_date': content['releaseDate'], + 'release_notes': content['releaseNotes'], + 'can_auto_update': content['canAutoUpdate'] + } + + +api.add_resource(VersionApi, '/version') diff --git a/api/controllers/console/workspace/__init__.py b/api/controllers/console/workspace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py new file mode 100644 index 0000000000..0890cd0468 --- /dev/null +++ b/api/controllers/console/workspace/account.py @@ -0,0 +1,263 @@ +# -*- coding:utf-8 -*- +from datetime import datetime + +import pytz +from flask import current_app, request +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, fields, marshal_with + +from controllers.console import api +from controllers.console.setup import setup_required +from controllers.console.workspace.error import AccountAlreadyInitedError, InvalidInvitationCodeError, \ + RepeatPasswordNotMatchError +from controllers.console.wraps import account_initialization_required +from libs.helper import TimestampField, supported_language, timezone +from extensions.ext_database import db +from models.account import InvitationCode, AccountIntegrate +from services.account_service import AccountService + + +account_fields = { + 'id': fields.String, + 'name': fields.String, + 'avatar': fields.String, + 'email': fields.String, + 'interface_language': fields.String, + 'interface_theme': fields.String, + 'timezone': fields.String, + 'last_login_at': TimestampField, + 'last_login_ip': fields.String, + 'created_at': TimestampField +} + + +class AccountInitApi(Resource): + + @setup_required + @login_required + def post(self): + account = current_user + + if account.status == 'active': + raise AccountAlreadyInitedError() + + parser = reqparse.RequestParser() + + if current_app.config['EDITION'] == 'CLOUD': + parser.add_argument('invitation_code', type=str, location='json') + + parser.add_argument( + 'interface_language', type=supported_language, required=True, location='json') + parser.add_argument('timezone', type=timezone, + required=True, location='json') + args = parser.parse_args() + + if current_app.config['EDITION'] == 'CLOUD': + if not args['invitation_code']: + raise ValueError('invitation_code is required') + + # check invitation code + invitation_code = db.session.query(InvitationCode).filter( + InvitationCode.code == args['invitation_code'], + InvitationCode.status == 'unused', + ).first() + + if not invitation_code: + raise InvalidInvitationCodeError() + + invitation_code.status = 'used' + invitation_code.used_at = datetime.utcnow() + invitation_code.used_by_tenant_id = account.current_tenant_id + invitation_code.used_by_account_id = account.id + + account.interface_language = args['interface_language'] + account.timezone = args['timezone'] + account.interface_theme = 'light' + account.status = 'active' + account.initialized_at = datetime.utcnow() + db.session.commit() + + return {'result': 'success'} + + +class AccountProfileApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def get(self): + return current_user + + +class AccountNameApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, location='json') + args = parser.parse_args() + + # Validate account name length + if len(args['name']) < 3 or len(args['name']) > 30: + raise ValueError( + "Account name must be between 3 and 30 characters.") + + updated_account = AccountService.update_account(current_user, name=args['name']) + + return updated_account + + +class AccountAvatarApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('avatar', type=str, required=True, location='json') + args = parser.parse_args() + + updated_account = AccountService.update_account(current_user, avatar=args['avatar']) + + return updated_account + + +class AccountInterfaceLanguageApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument( + 'interface_language', type=supported_language, required=True, location='json') + args = parser.parse_args() + + updated_account = AccountService.update_account(current_user, interface_language=args['interface_language']) + + return updated_account + + +class AccountInterfaceThemeApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('interface_theme', type=str, choices=[ + 'light', 'dark'], required=True, location='json') + args = parser.parse_args() + + updated_account = AccountService.update_account(current_user, interface_theme=args['interface_theme']) + + return updated_account + + +class AccountTimezoneApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('timezone', type=str, + required=True, location='json') + args = parser.parse_args() + + # Validate timezone string, e.g. America/New_York, Asia/Shanghai + if args['timezone'] not in pytz.all_timezones: + raise ValueError("Invalid timezone string.") + + updated_account = AccountService.update_account(current_user, timezone=args['timezone']) + + return updated_account + + +class AccountPasswordApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_fields) + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('password', type=str, + required=False, location='json') + parser.add_argument('new_password', type=str, + required=True, location='json') + parser.add_argument('repeat_new_password', type=str, + required=True, location='json') + args = parser.parse_args() + + if args['new_password'] != args['repeat_new_password']: + raise RepeatPasswordNotMatchError() + + AccountService.update_account_password( + current_user, args['password'], args['new_password']) + + return {"result": "success"} + + +class AccountIntegrateApi(Resource): + integrate_fields = { + 'provider': fields.String, + 'created_at': TimestampField, + 'is_bound': fields.Boolean, + 'link': fields.String + } + + integrate_list_fields = { + 'data': fields.List(fields.Nested(integrate_fields)), + } + + @setup_required + @login_required + @account_initialization_required + @marshal_with(integrate_list_fields) + def get(self): + account = current_user + + account_integrates = db.session.query(AccountIntegrate).filter( + AccountIntegrate.account_id == account.id).all() + + base_url = request.url_root.rstrip('/') + oauth_base_path = "/console/api/oauth/login" + providers = ["github", "google"] + + integrate_data = [] + for provider in providers: + existing_integrate = next((ai for ai in account_integrates if ai.provider == provider), None) + if existing_integrate: + integrate_data.append({ + 'id': existing_integrate.id, + 'provider': provider, + 'created_at': existing_integrate.created_at, + 'is_bound': True, + 'link': None + }) + else: + integrate_data.append({ + 'id': None, + 'provider': provider, + 'created_at': None, + 'is_bound': False, + 'link': f'{base_url}{oauth_base_path}/{provider}' + }) + + return {'data': integrate_data} + + +# Register API resources +api.add_resource(AccountInitApi, '/account/init') +api.add_resource(AccountProfileApi, '/account/profile') +api.add_resource(AccountNameApi, '/account/name') +api.add_resource(AccountAvatarApi, '/account/avatar') +api.add_resource(AccountInterfaceLanguageApi, '/account/interface-language') +api.add_resource(AccountInterfaceThemeApi, '/account/interface-theme') +api.add_resource(AccountTimezoneApi, '/account/timezone') +api.add_resource(AccountPasswordApi, '/account/password') +api.add_resource(AccountIntegrateApi, '/account/integrates') +# api.add_resource(AccountEmailApi, '/account/email') +# api.add_resource(AccountEmailVerifyApi, '/account/email-verify') diff --git a/api/controllers/console/workspace/error.py b/api/controllers/console/workspace/error.py new file mode 100644 index 0000000000..c5e3a3fb6a --- /dev/null +++ b/api/controllers/console/workspace/error.py @@ -0,0 +1,31 @@ +from libs.exception import BaseHTTPException + + +class RepeatPasswordNotMatchError(BaseHTTPException): + error_code = 'repeat_password_not_match' + description = "New password and repeat password does not match." + code = 400 + + +class ProviderRequestFailedError(BaseHTTPException): + error_code = 'provider_request_failed' + description = None + code = 400 + + +class InvalidInvitationCodeError(BaseHTTPException): + error_code = 'invalid_invitation_code' + description = "Invalid invitation code." + code = 400 + + +class AccountAlreadyInitedError(BaseHTTPException): + error_code = 'account_already_inited' + description = "Account already inited." + code = 400 + + +class AccountNotInitializedError(BaseHTTPException): + error_code = 'account_not_initialized' + description = "Account not initialized." + code = 400 diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py new file mode 100644 index 0000000000..e0fc2bc19f --- /dev/null +++ b/api/controllers/console/workspace/members.py @@ -0,0 +1,141 @@ +# -*- coding:utf-8 -*- + +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, marshal_with, abort, fields, marshal + +import services +from controllers.console import api +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from libs.helper import TimestampField +from extensions.ext_database import db +from models.account import Account, TenantAccountJoin +from services.account_service import TenantService, RegisterService + +account_fields = { + 'id': fields.String, + 'name': fields.String, + 'avatar': fields.String, + 'email': fields.String, + 'last_login_at': TimestampField, + 'created_at': TimestampField, + 'role': fields.String, + 'status': fields.String, +} + +account_list_fields = { + 'accounts': fields.List(fields.Nested(account_fields)) +} + + +class MemberListApi(Resource): + """List all members of current tenant.""" + + @setup_required + @login_required + @account_initialization_required + @marshal_with(account_list_fields) + def get(self): + members = TenantService.get_tenant_members(current_user.current_tenant) + return {'result': 'success', 'accounts': members}, 200 + + +class MemberInviteEmailApi(Resource): + """Invite a new member by email.""" + + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('email', type=str, required=True, location='json') + parser.add_argument('role', type=str, required=True, default='admin', location='json') + args = parser.parse_args() + + invitee_email = args['email'] + invitee_role = args['role'] + if invitee_role not in ['admin', 'normal']: + return {'code': 'invalid-role', 'message': 'Invalid role'}, 400 + + inviter = current_user + + try: + RegisterService.invite_new_member(inviter.current_tenant, invitee_email, role=invitee_role, inviter=inviter) + account = db.session.query(Account, TenantAccountJoin.role).join( + TenantAccountJoin, Account.id == TenantAccountJoin.account_id + ).filter(Account.email == args['email']).first() + account, role = account + account = marshal(account, account_fields) + account['role'] = role + except services.errors.account.CannotOperateSelfError as e: + return {'code': 'cannot-operate-self', 'message': str(e)}, 400 + except services.errors.account.NoPermissionError as e: + return {'code': 'forbidden', 'message': str(e)}, 403 + except services.errors.account.AccountAlreadyInTenantError as e: + return {'code': 'email-taken', 'message': str(e)}, 409 + except Exception as e: + return {'code': 'unexpected-error', 'message': str(e)}, 500 + + # todo:413 + + return {'result': 'success', 'account': account}, 201 + + +class MemberCancelInviteApi(Resource): + """Cancel an invitation by member id.""" + + @setup_required + @login_required + @account_initialization_required + def delete(self, member_id): + member = Account.query.get(str(member_id)) + if not member: + abort(404) + + try: + TenantService.remove_member_from_tenant(current_user.current_tenant, member, current_user) + except services.errors.account.CannotOperateSelfError as e: + return {'code': 'cannot-operate-self', 'message': str(e)}, 400 + except services.errors.account.NoPermissionError as e: + return {'code': 'forbidden', 'message': str(e)}, 403 + except services.errors.account.MemberNotInTenantError as e: + return {'code': 'member-not-found', 'message': str(e)}, 404 + except Exception as e: + raise ValueError(str(e)) + + return {'result': 'success'}, 204 + + +class MemberUpdateRoleApi(Resource): + """Update member role.""" + + @setup_required + @login_required + @account_initialization_required + def put(self, member_id): + parser = reqparse.RequestParser() + parser.add_argument('role', type=str, required=True, location='json') + args = parser.parse_args() + new_role = args['role'] + + if new_role not in ['admin', 'normal', 'owner']: + return {'code': 'invalid-role', 'message': 'Invalid role'}, 400 + + member = Account.query.get(str(member_id)) + if not member: + abort(404) + + try: + TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user) + except Exception as e: + raise ValueError(str(e)) + + # todo: 403 + + return {'result': 'success'} + + +api.add_resource(MemberListApi, '/workspaces/current/members') +api.add_resource(MemberInviteEmailApi, '/workspaces/current/members/invite-email') +api.add_resource(MemberCancelInviteApi, '/workspaces/current/members/') +api.add_resource(MemberUpdateRoleApi, '/workspaces/current/members//update-role') diff --git a/api/controllers/console/workspace/providers.py b/api/controllers/console/workspace/providers.py new file mode 100644 index 0000000000..bc6b8320af --- /dev/null +++ b/api/controllers/console/workspace/providers.py @@ -0,0 +1,246 @@ +# -*- coding:utf-8 -*- +import base64 +import json +import logging + +from flask_login import login_required, current_user +from flask_restful import Resource, reqparse, abort +from werkzeug.exceptions import Forbidden + +from controllers.console import api +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from core.llm.provider.errors import ValidateFailedError +from extensions.ext_database import db +from libs import rsa +from models.provider import Provider, ProviderType, ProviderName +from services.provider_service import ProviderService + + +class ProviderListApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def get(self): + tenant_id = current_user.current_tenant_id + + """ + If the type is AZURE_OPENAI, decode and return the four fields of azure_api_type, azure_api_version:, + azure_api_base, azure_api_key as an object, where azure_api_key displays the first 6 bits in plaintext, and the + rest is replaced by * and the last two bits are displayed in plaintext + + If the type is other, decode and return the Token field directly, the field displays the first 6 bits in + plaintext, the rest is replaced by * and the last two bits are displayed in plaintext + """ + + ProviderService.init_supported_provider(current_user.current_tenant, "cloud") + providers = Provider.query.filter_by(tenant_id=tenant_id).all() + + provider_list = [ + { + 'provider_name': p.provider_name, + 'provider_type': p.provider_type, + 'is_valid': p.is_valid, + 'last_used': p.last_used, + 'is_enabled': p.is_enabled, + **({ + 'quota_type': p.quota_type, + 'quota_limit': p.quota_limit, + 'quota_used': p.quota_used + } if p.provider_type == ProviderType.SYSTEM.value else {}), + 'token': ProviderService.get_obfuscated_api_key(current_user.current_tenant, + ProviderName(p.provider_name)) + } + for p in providers + ] + + return provider_list + + +class ProviderTokenApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + if provider not in [p.value for p in ProviderName]: + abort(404) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + logging.log(logging.ERROR, + f'User {current_user.id} is not authorized to update provider token, current_role is {current_user.current_tenant.current_role}') + raise Forbidden() + + parser = reqparse.RequestParser() + + parser.add_argument('token', type=ProviderService.get_token_type( + tenant=current_user.current_tenant, + provider_name=ProviderName(provider) + ), required=True, nullable=False, location='json') + + args = parser.parse_args() + + if not args['token']: + raise ValueError('Token is empty') + + try: + ProviderService.validate_provider_configs( + tenant=current_user.current_tenant, + provider_name=ProviderName(provider), + configs=args['token'] + ) + token_is_valid = True + except ValidateFailedError: + token_is_valid = False + + tenant = current_user.current_tenant + + base64_encrypted_token = ProviderService.get_encrypted_token( + tenant=current_user.current_tenant, + provider_name=ProviderName(provider), + configs=args['token'] + ) + + provider_model = Provider.query.filter_by(tenant_id=tenant.id, provider_name=provider, + provider_type=ProviderType.CUSTOM.value).first() + + # Only allow updating token for CUSTOM provider type + if provider_model: + provider_model.encrypted_config = base64_encrypted_token + provider_model.is_valid = token_is_valid + else: + provider_model = Provider(tenant_id=tenant.id, provider_name=provider, + provider_type=ProviderType.CUSTOM.value, + encrypted_config=base64_encrypted_token, + is_valid=token_is_valid) + db.session.add(provider_model) + + db.session.commit() + + if provider in [ProviderName.ANTHROPIC.value, ProviderName.AZURE_OPENAI.value, ProviderName.COHERE.value, + ProviderName.HUGGINGFACEHUB.value]: + return {'result': 'success', 'warning': 'MOCK: This provider is not supported yet.'}, 201 + + return {'result': 'success'}, 201 + + +class ProviderTokenValidateApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def post(self, provider): + if provider not in [p.value for p in ProviderName]: + abort(404) + + parser = reqparse.RequestParser() + parser.add_argument('token', type=ProviderService.get_token_type( + tenant=current_user.current_tenant, + provider_name=ProviderName(provider) + ), required=True, nullable=False, location='json') + args = parser.parse_args() + + # todo: remove this when the provider is supported + if provider in [ProviderName.ANTHROPIC.value, ProviderName.AZURE_OPENAI.value, ProviderName.COHERE.value, + ProviderName.HUGGINGFACEHUB.value]: + return {'result': 'success', 'warning': 'MOCK: This provider is not supported yet.'} + + result = True + error = None + + try: + ProviderService.validate_provider_configs( + tenant=current_user.current_tenant, + provider_name=ProviderName(provider), + configs=args['token'] + ) + except ValidateFailedError as e: + result = False + error = str(e) + + response = {'result': 'success' if result else 'error'} + + if not result: + response['error'] = error + + return response + + +class ProviderSystemApi(Resource): + + @setup_required + @login_required + @account_initialization_required + def put(self, provider): + if provider not in [p.value for p in ProviderName]: + abort(404) + + parser = reqparse.RequestParser() + parser.add_argument('is_enabled', type=bool, required=True, location='json') + args = parser.parse_args() + + tenant = current_user.current_tenant_id + + provider_model = Provider.query.filter_by(tenant_id=tenant.id, provider_name=provider).first() + + if provider_model and provider_model.provider_type == ProviderType.SYSTEM.value: + provider_model.is_valid = args['is_enabled'] + db.session.commit() + elif not provider_model: + ProviderService.create_system_provider(tenant, provider, args['is_enabled']) + else: + abort(403) + + return {'result': 'success'} + + @setup_required + @login_required + @account_initialization_required + def get(self, provider): + if provider not in [p.value for p in ProviderName]: + abort(404) + + # The role of the current user in the ta table must be admin or owner + if current_user.current_tenant.current_role not in ['admin', 'owner']: + raise Forbidden() + + provider_model = db.session.query(Provider).filter(Provider.tenant_id == current_user.current_tenant_id, + Provider.provider_name == provider, + Provider.provider_type == ProviderType.SYSTEM.value).first() + + system_model = None + if provider_model: + system_model = { + 'result': 'success', + 'provider': { + 'provider_name': provider_model.provider_name, + 'provider_type': provider_model.provider_type, + 'is_valid': provider_model.is_valid, + 'last_used': provider_model.last_used, + 'is_enabled': provider_model.is_enabled, + 'quota_type': provider_model.quota_type, + 'quota_limit': provider_model.quota_limit, + 'quota_used': provider_model.quota_used + } + } + else: + abort(404) + + return system_model + + +api.add_resource(ProviderTokenApi, '/providers//token', + endpoint='current_providers_token') # Deprecated +api.add_resource(ProviderTokenValidateApi, '/providers//token-validate', + endpoint='current_providers_token_validate') # Deprecated + +api.add_resource(ProviderTokenApi, '/workspaces/current/providers//token', + endpoint='workspaces_current_providers_token') # PUT for updating provider token +api.add_resource(ProviderTokenValidateApi, '/workspaces/current/providers//token-validate', + endpoint='workspaces_current_providers_token_validate') # POST for validating provider token + +api.add_resource(ProviderListApi, '/workspaces/current/providers') # GET for getting providers list +api.add_resource(ProviderSystemApi, '/workspaces/current/providers//system', + endpoint='workspaces_current_providers_system') # GET for getting provider quota, PUT for updating provider status diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py new file mode 100644 index 0000000000..2ad457c79b --- /dev/null +++ b/api/controllers/console/workspace/workspace.py @@ -0,0 +1,97 @@ +# -*- coding:utf-8 -*- +import logging + +from flask import request +from flask_login import login_required, current_user +from flask_restful import Resource, fields, marshal_with, reqparse, marshal + +from controllers.console import api +from controllers.console.setup import setup_required +from controllers.console.error import AccountNotLinkTenantError +from controllers.console.wraps import account_initialization_required +from libs.helper import TimestampField +from extensions.ext_database import db +from models.account import Tenant +from services.account_service import TenantService +from services.workspace_service import WorkspaceService + +provider_fields = { + 'provider_name': fields.String, + 'provider_type': fields.String, + 'is_valid': fields.Boolean, + 'token_is_set': fields.Boolean, +} + +tenant_fields = { + 'id': fields.String, + 'name': fields.String, + 'plan': fields.String, + 'status': fields.String, + 'created_at': TimestampField, + 'role': fields.String, + 'providers': fields.List(fields.Nested(provider_fields)), + 'in_trail': fields.Boolean, + 'trial_end_reason': fields.String, +} + +tenants_fields = { + 'id': fields.String, + 'name': fields.String, + 'plan': fields.String, + 'status': fields.String, + 'created_at': TimestampField, + 'current': fields.Boolean +} + + +class TenantListApi(Resource): + @setup_required + @login_required + @account_initialization_required + def get(self): + tenants = TenantService.get_join_tenants(current_user) + + for tenant in tenants: + if tenant.id == current_user.current_tenant_id: + tenant.current = True # Set current=True for current tenant + return {'workspaces': marshal(tenants, tenants_fields)}, 200 + + +class TenantApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(tenant_fields) + def get(self): + if request.path == '/info': + logging.warning('Deprecated URL /info was used.') + + tenant = current_user.current_tenant + + return WorkspaceService.get_tenant_info(tenant), 200 + + +class SwitchWorkspaceApi(Resource): + @setup_required + @login_required + @account_initialization_required + def post(self): + parser = reqparse.RequestParser() + parser.add_argument('tenant_id', type=str, required=True, location='json') + args = parser.parse_args() + + # check if tenant_id is valid, 403 if not + try: + TenantService.switch_tenant(current_user, args['tenant_id']) + except Exception: + raise AccountNotLinkTenantError("Account not link tenant") + + new_tenant = db.session.query(Tenant).get(args['tenant_id']) # Get new tenant + + return {'result': 'success', 'new_tenant': marshal(WorkspaceService.get_tenant_info(new_tenant), tenant_fields)} + + +api.add_resource(TenantListApi, '/workspaces') # GET for getting all tenants +api.add_resource(TenantApi, '/workspaces/current', endpoint='workspaces_current') # GET for getting current tenant info +api.add_resource(TenantApi, '/info', endpoint='info') # Deprecated +api.add_resource(SwitchWorkspaceApi, '/workspaces/switch') # POST for switching tenant diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py new file mode 100644 index 0000000000..41ce4f200b --- /dev/null +++ b/api/controllers/console/wraps.py @@ -0,0 +1,43 @@ +# -*- coding:utf-8 -*- +from functools import wraps + +from flask import current_app, abort +from flask_login import current_user + +from controllers.console.workspace.error import AccountNotInitializedError + + +def account_initialization_required(view): + @wraps(view) + def decorated(*args, **kwargs): + # check account initialization + account = current_user + + if account.status == 'uninitialized': + raise AccountNotInitializedError() + + return view(*args, **kwargs) + + return decorated + + +def only_edition_cloud(view): + @wraps(view) + def decorated(*args, **kwargs): + if current_app.config['EDITION'] != 'CLOUD': + abort(404) + + return view(*args, **kwargs) + + return decorated + + +def only_edition_self_hosted(view): + @wraps(view) + def decorated(*args, **kwargs): + if current_app.config['EDITION'] != 'SELF_HOSTED': + abort(404) + + return view(*args, **kwargs) + + return decorated diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py new file mode 100644 index 0000000000..05318a2076 --- /dev/null +++ b/api/controllers/service_api/__init__.py @@ -0,0 +1,12 @@ +# -*- coding:utf-8 -*- +from flask import Blueprint + +from libs.external_api import ExternalApi + +bp = Blueprint('service_api', __name__, url_prefix='/v1') +api = ExternalApi(bp) + + +from .app import completion, app, conversation, message + +from .dataset import document diff --git a/api/controllers/service_api/app/__init__.py b/api/controllers/service_api/app/__init__.py new file mode 100644 index 0000000000..d8018ee385 --- /dev/null +++ b/api/controllers/service_api/app/__init__.py @@ -0,0 +1,27 @@ +from extensions.ext_database import db +from models.model import EndUser + + +def create_or_update_end_user_for_user_id(app_model, user_id): + """ + Create or update session terminal based on user ID. + """ + end_user = db.session.query(EndUser) \ + .filter( + EndUser.tenant_id == app_model.tenant_id, + EndUser.session_id == user_id, + EndUser.type == 'service_api' + ).first() + + if end_user is None: + end_user = EndUser( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + type='service_api', + is_anonymous=True, + session_id=user_id + ) + db.session.add(end_user) + db.session.commit() + + return end_user diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py new file mode 100644 index 0000000000..08532441c8 --- /dev/null +++ b/api/controllers/service_api/app/app.py @@ -0,0 +1,43 @@ +# -*- coding:utf-8 -*- +from flask_restful import fields, marshal_with + +from controllers.service_api import api +from controllers.service_api.wraps import AppApiResource + + +class AppParameterApi(AppApiResource): + """Resource for app variables.""" + + variable_fields = { + 'key': fields.String, + 'name': fields.String, + 'description': fields.String, + 'type': fields.String, + 'default': fields.String, + 'max_length': fields.Integer, + 'options': fields.List(fields.String) + } + + parameters_fields = { + 'opening_statement': fields.String, + 'suggested_questions': fields.Raw, + 'suggested_questions_after_answer': fields.Raw, + 'more_like_this': fields.Raw, + 'user_input_form': fields.Raw, + } + + @marshal_with(parameters_fields) + def get(self, app_model, end_user): + """Retrieve app parameters.""" + app_model_config = app_model.app_model_config + + return { + 'opening_statement': app_model_config.opening_statement, + 'suggested_questions': app_model_config.suggested_questions_list, + 'suggested_questions_after_answer': app_model_config.suggested_questions_after_answer_dict, + 'more_like_this': app_model_config.more_like_this_dict, + 'user_input_form': app_model_config.user_input_form_list + } + + +api.add_resource(AppParameterApi, '/parameters') diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py new file mode 100644 index 0000000000..e5eb4153aa --- /dev/null +++ b/api/controllers/service_api/app/completion.py @@ -0,0 +1,182 @@ +import json +import logging +from typing import Union, Generator + +from flask import stream_with_context, Response +from flask_restful import reqparse +from werkzeug.exceptions import NotFound, InternalServerError + +import services +from controllers.service_api import api +from controllers.service_api.app import create_or_update_end_user_for_user_id +from controllers.service_api.app.error import AppUnavailableError, ProviderNotInitializeError, NotChatAppError, \ + ConversationCompletedError, CompletionRequestError, ProviderQuotaExceededError, \ + ProviderModelCurrentlyNotSupportError +from controllers.service_api.wraps import AppApiResource +from core.conversation_message_task import PubHandler +from core.llm.error import LLMBadRequestError, LLMAuthorizationError, LLMAPIUnavailableError, LLMAPIConnectionError, \ + LLMRateLimitError, ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError +from libs.helper import uuid_value +from services.completion_service import CompletionService + + +class CompletionApi(AppApiResource): + def post(self, app_model, end_user): + if app_model.mode != 'completion': + raise AppUnavailableError() + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, location='json') + parser.add_argument('response_mode', type=str, choices=['blocking', 'streaming'], location='json') + parser.add_argument('user', type=str, location='json') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + response = CompletionService.completion( + app_model=app_model, + user=end_user, + args=args, + from_source='api', + streaming=streaming + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +class CompletionStopApi(AppApiResource): + def post(self, app_model, end_user, task_id): + if app_model.mode != 'completion': + raise AppUnavailableError() + + PubHandler.stop(end_user, task_id) + + return {'result': 'success'}, 200 + + +class ChatApi(AppApiResource): + def post(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, required=True, location='json') + parser.add_argument('response_mode', type=str, choices=['blocking', 'streaming'], location='json') + parser.add_argument('conversation_id', type=uuid_value, location='json') + parser.add_argument('user', type=str, location='json') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + response = CompletionService.completion( + app_model=app_model, + user=end_user, + args=args, + from_source='api', + streaming=streaming + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +class ChatStopApi(AppApiResource): + def post(self, app_model, end_user, task_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + PubHandler.stop(end_user, task_id) + + return {'result': 'success'}, 200 + + +def compact_response(response: Union[dict | Generator]) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype='application/json') + else: + def generate() -> Generator: + try: + for chunk in response: + yield chunk + except services.errors.conversation.ConversationNotExistsError: + yield "data: " + json.dumps(api.handle_error(NotFound("Conversation Not Exists.")).get_json()) + "\n\n" + except services.errors.conversation.ConversationCompletedError: + yield "data: " + json.dumps(api.handle_error(ConversationCompletedError()).get_json()) + "\n\n" + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + yield "data: " + json.dumps(api.handle_error(AppUnavailableError()).get_json()) + "\n\n" + except ProviderTokenNotInitError: + yield "data: " + json.dumps(api.handle_error(ProviderNotInitializeError()).get_json()) + "\n\n" + except QuotaExceededError: + yield "data: " + json.dumps(api.handle_error(ProviderQuotaExceededError()).get_json()) + "\n\n" + except ModelCurrentlyNotSupportError: + yield "data: " + json.dumps(api.handle_error(ProviderModelCurrentlyNotSupportError()).get_json()) + "\n\n" + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + yield "data: " + json.dumps(api.handle_error(CompletionRequestError(str(e))).get_json()) + "\n\n" + except ValueError as e: + yield "data: " + json.dumps(api.handle_error(e).get_json()) + "\n\n" + except Exception: + logging.exception("internal server error.") + yield "data: " + json.dumps(api.handle_error(InternalServerError()).get_json()) + "\n\n" + + return Response(stream_with_context(generate()), status=200, + mimetype='text/event-stream') + + +api.add_resource(CompletionApi, '/completion-messages') +api.add_resource(CompletionStopApi, '/completion-messages//stop') +api.add_resource(ChatApi, '/chat-messages') +api.add_resource(ChatStopApi, '/chat-messages//stop') + diff --git a/api/controllers/service_api/app/conversation.py b/api/controllers/service_api/app/conversation.py new file mode 100644 index 0000000000..602ac8d785 --- /dev/null +++ b/api/controllers/service_api/app/conversation.py @@ -0,0 +1,76 @@ +# -*- coding:utf-8 -*- +from flask_restful import fields, marshal_with, reqparse +from flask_restful.inputs import int_range +from werkzeug.exceptions import NotFound + +from controllers.service_api import api +from controllers.service_api.app import create_or_update_end_user_for_user_id +from controllers.service_api.app.error import NotChatAppError +from controllers.service_api.wraps import AppApiResource +from libs.helper import TimestampField, uuid_value +import services +from services.conversation_service import ConversationService + +conversation_fields = { + 'id': fields.String, + 'name': fields.String, + 'inputs': fields.Raw, + 'status': fields.String, + 'introduction': fields.String, + 'created_at': TimestampField +} + +conversation_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(conversation_fields)) +} + + +class ConversationApi(AppApiResource): + + @marshal_with(conversation_infinite_scroll_pagination_fields) + def get(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('last_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + parser.add_argument('user', type=str, location='args') + args = parser.parse_args() + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + return ConversationService.pagination_by_last_id(app_model, end_user, args['last_id'], args['limit']) + except services.errors.conversation.LastConversationNotExistsError: + raise NotFound("Last Conversation Not Exists.") + + +class ConversationRenameApi(AppApiResource): + + @marshal_with(conversation_fields) + def post(self, app_model, end_user, c_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + conversation_id = str(c_id) + + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, location='json') + parser.add_argument('user', type=str, location='json') + args = parser.parse_args() + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + return ConversationService.rename(app_model, conversation_id, end_user, args['name']) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + + +api.add_resource(ConversationRenameApi, '/conversations//name', endpoint='conversation_name') +api.add_resource(ConversationApi, '/conversations') diff --git a/api/controllers/service_api/app/error.py b/api/controllers/service_api/app/error.py new file mode 100644 index 0000000000..c59f570efd --- /dev/null +++ b/api/controllers/service_api/app/error.py @@ -0,0 +1,51 @@ +# -*- coding:utf-8 -*- +from libs.exception import BaseHTTPException + + +class AppUnavailableError(BaseHTTPException): + error_code = 'app_unavailable' + description = "App unavailable." + code = 400 + + +class NotCompletionAppError(BaseHTTPException): + error_code = 'not_completion_app' + description = "Not Completion App" + code = 400 + + +class NotChatAppError(BaseHTTPException): + error_code = 'not_chat_app' + description = "Not Chat App" + code = 400 + + +class ConversationCompletedError(BaseHTTPException): + error_code = 'conversation_completed' + description = "Conversation Completed." + code = 400 + + +class ProviderNotInitializeError(BaseHTTPException): + error_code = 'provider_not_initialize' + description = "Provider Token not initialize." + code = 400 + + +class ProviderQuotaExceededError(BaseHTTPException): + error_code = 'provider_quota_exceeded' + description = "Provider quota exceeded." + code = 400 + + +class ProviderModelCurrentlyNotSupportError(BaseHTTPException): + error_code = 'model_currently_not_support' + description = "GPT-4 currently not support." + code = 400 + + +class CompletionRequestError(BaseHTTPException): + error_code = 'completion_request_error' + description = "Completion request failed." + code = 400 + diff --git a/api/controllers/service_api/app/message.py b/api/controllers/service_api/app/message.py new file mode 100644 index 0000000000..ef020891ff --- /dev/null +++ b/api/controllers/service_api/app/message.py @@ -0,0 +1,81 @@ +# -*- coding:utf-8 -*- +from flask_restful import fields, marshal_with, reqparse +from flask_restful.inputs import int_range +from werkzeug.exceptions import NotFound + +import services +from controllers.service_api import api +from controllers.service_api.app import create_or_update_end_user_for_user_id +from controllers.service_api.app.error import NotChatAppError +from controllers.service_api.wraps import AppApiResource +from libs.helper import TimestampField, uuid_value +from services.message_service import MessageService + + +class MessageListApi(AppApiResource): + feedback_fields = { + 'rating': fields.String + } + + message_fields = { + 'id': fields.String, + 'conversation_id': fields.String, + 'inputs': fields.Raw, + 'query': fields.String, + 'answer': fields.String, + 'feedback': fields.Nested(feedback_fields, attribute='user_feedback', allow_null=True), + 'created_at': TimestampField + } + + message_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(message_fields)) + } + + @marshal_with(message_infinite_scroll_pagination_fields) + def get(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('conversation_id', required=True, type=uuid_value, location='args') + parser.add_argument('first_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + parser.add_argument('user', type=str, location='args') + args = parser.parse_args() + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + return MessageService.pagination_by_first_id(app_model, end_user, + args['conversation_id'], args['first_id'], args['limit']) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.message.FirstMessageNotExistsError: + raise NotFound("First Message Not Exists.") + + +class MessageFeedbackApi(AppApiResource): + def post(self, app_model, end_user, message_id): + message_id = str(message_id) + + parser = reqparse.RequestParser() + parser.add_argument('rating', type=str, choices=['like', 'dislike', None], location='json') + parser.add_argument('user', type=str, location='json') + args = parser.parse_args() + + if end_user is None and args['user'] is not None: + end_user = create_or_update_end_user_for_user_id(app_model, args['user']) + + try: + MessageService.create_feedback(app_model, message_id, end_user, args['rating']) + except services.errors.message.MessageNotExistsError: + raise NotFound("Message Not Exists.") + + return {'result': 'success'} + + +api.add_resource(MessageListApi, '/messages') +api.add_resource(MessageFeedbackApi, '/messages//feedbacks') diff --git a/api/controllers/service_api/dataset/__init__.py b/api/controllers/service_api/dataset/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py new file mode 100644 index 0000000000..47a90756db --- /dev/null +++ b/api/controllers/service_api/dataset/document.py @@ -0,0 +1,129 @@ +import datetime +import uuid + +from flask import current_app +from flask_restful import reqparse +from werkzeug.exceptions import NotFound + +import services.dataset_service +from controllers.service_api import api +from controllers.service_api.app.error import ProviderNotInitializeError +from controllers.service_api.dataset.error import ArchivedDocumentImmutableError, DocumentIndexingError, \ + DatasetNotInitedError +from controllers.service_api.wraps import DatasetApiResource +from core.llm.error import ProviderTokenNotInitError +from extensions.ext_database import db +from extensions.ext_storage import storage +from models.model import UploadFile +from services.dataset_service import DocumentService + + +class DocumentListApi(DatasetApiResource): + """Resource for documents.""" + + def post(self, dataset): + """Create document.""" + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, nullable=False, location='json') + parser.add_argument('text', type=str, required=True, nullable=False, location='json') + parser.add_argument('doc_type', type=str, location='json') + parser.add_argument('doc_metadata', type=dict, location='json') + args = parser.parse_args() + + if not dataset.indexing_technique: + raise DatasetNotInitedError("Dataset indexing technique must be set.") + + doc_type = args.get('doc_type') + doc_metadata = args.get('doc_metadata') + + if doc_type and doc_type not in DocumentService.DOCUMENT_METADATA_SCHEMA: + raise ValueError('Invalid doc_type.') + + # user uuid as file name + file_uuid = str(uuid.uuid4()) + file_key = 'upload_files/' + dataset.tenant_id + '/' + file_uuid + '.txt' + + # save file to storage + storage.save(file_key, args.get('text')) + + # save file to db + config = current_app.config + upload_file = UploadFile( + tenant_id=dataset.tenant_id, + storage_type=config['STORAGE_TYPE'], + key=file_key, + name=args.get('name') + '.txt', + size=len(args.get('text')), + extension='txt', + mime_type='text/plain', + created_by=dataset.created_by, + created_at=datetime.datetime.utcnow(), + used=True, + used_by=dataset.created_by, + used_at=datetime.datetime.utcnow() + ) + + db.session.add(upload_file) + db.session.commit() + + document_data = { + 'data_source': { + 'type': 'upload_file', + 'info': upload_file.id + } + } + + try: + document = DocumentService.save_document_with_dataset_id( + dataset=dataset, + document_data=document_data, + account=dataset.created_by_account, + dataset_process_rule=dataset.latest_process_rule, + created_from='api' + ) + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + + if doc_type and doc_metadata: + metadata_schema = DocumentService.DOCUMENT_METADATA_SCHEMA[doc_type] + + document.doc_metadata = {} + + for key, value_type in metadata_schema.items(): + value = doc_metadata.get(key) + if value is not None and isinstance(value, value_type): + document.doc_metadata[key] = value + + document.doc_type = doc_type + document.updated_at = datetime.datetime.utcnow() + db.session.commit() + + return {'id': document.id} + + +class DocumentApi(DatasetApiResource): + def delete(self, dataset, document_id): + """Delete document.""" + document_id = str(document_id) + + document = DocumentService.get_document(dataset.id, document_id) + + # 404 if document not found + if document is None: + raise NotFound("Document Not Exists.") + + # 403 if document is archived + if DocumentService.check_archived(document): + raise ArchivedDocumentImmutableError() + + try: + # delete document + DocumentService.delete_document(document) + except services.errors.document.DocumentIndexingError: + raise DocumentIndexingError('Cannot delete document during indexing.') + + return {'result': 'success'}, 204 + + +api.add_resource(DocumentListApi, '/documents') +api.add_resource(DocumentApi, '/documents/') diff --git a/api/controllers/service_api/dataset/error.py b/api/controllers/service_api/dataset/error.py new file mode 100644 index 0000000000..d231e0b40a --- /dev/null +++ b/api/controllers/service_api/dataset/error.py @@ -0,0 +1,20 @@ +# -*- coding:utf-8 -*- +from libs.exception import BaseHTTPException + + +class ArchivedDocumentImmutableError(BaseHTTPException): + error_code = 'archived_document_immutable' + description = "Cannot operate when document was archived." + code = 403 + + +class DocumentIndexingError(BaseHTTPException): + error_code = 'document_indexing' + description = "Cannot operate document during indexing." + code = 403 + + +class DatasetNotInitedError(BaseHTTPException): + error_code = 'dataset_not_inited' + description = "Dataset not inited." + code = 403 diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py new file mode 100644 index 0000000000..cb64a3b158 --- /dev/null +++ b/api/controllers/service_api/wraps.py @@ -0,0 +1,95 @@ +# -*- coding:utf-8 -*- +from datetime import datetime +from functools import wraps + +from flask import request +from flask_restful import Resource +from werkzeug.exceptions import NotFound, Unauthorized + +from extensions.ext_database import db +from models.dataset import Dataset +from models.model import ApiToken, App + + +def validate_app_token(view=None): + def decorator(view): + @wraps(view) + def decorated(*args, **kwargs): + api_token = validate_and_get_api_token('app') + + app_model = db.session.query(App).get(api_token.app_id) + if not app_model: + raise NotFound() + + if app_model.status != 'normal': + raise NotFound() + + if not app_model.enable_api: + raise NotFound() + + return view(app_model, None, *args, **kwargs) + return decorated + + if view: + return decorator(view) + + # if view is None, it means that the decorator is used without parentheses + # use the decorator as a function for method_decorators + return decorator + + +def validate_dataset_token(view=None): + def decorator(view): + @wraps(view) + def decorated(*args, **kwargs): + api_token = validate_and_get_api_token('dataset') + + dataset = db.session.query(Dataset).get(api_token.dataset_id) + if not dataset: + raise NotFound() + + return view(dataset, *args, **kwargs) + return decorated + + if view: + return decorator(view) + + # if view is None, it means that the decorator is used without parentheses + # use the decorator as a function for method_decorators + return decorator + + +def validate_and_get_api_token(scope=None): + """ + Validate and get API token. + """ + auth_header = request.headers.get('Authorization') + if auth_header is None: + raise Unauthorized() + + auth_scheme, auth_token = auth_header.split(None, 1) + auth_scheme = auth_scheme.lower() + + if auth_scheme != 'bearer': + raise Unauthorized() + + api_token = db.session.query(ApiToken).filter( + ApiToken.token == auth_token, + ApiToken.type == scope, + ).first() + + if not api_token: + raise Unauthorized() + + api_token.last_used_at = datetime.utcnow() + db.session.commit() + + return api_token + + +class AppApiResource(Resource): + method_decorators = [validate_app_token] + + +class DatasetApiResource(Resource): + method_decorators = [validate_dataset_token] diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py new file mode 100644 index 0000000000..b793f11014 --- /dev/null +++ b/api/controllers/web/__init__.py @@ -0,0 +1,10 @@ +# -*- coding:utf-8 -*- +from flask import Blueprint + +from libs.external_api import ExternalApi + +bp = Blueprint('web', __name__, url_prefix='/api') +api = ExternalApi(bp) + + +from . import completion, app, conversation, message, site, saved_message diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py new file mode 100644 index 0000000000..1396531111 --- /dev/null +++ b/api/controllers/web/app.py @@ -0,0 +1,42 @@ +# -*- coding:utf-8 -*- +from flask_restful import marshal_with, fields + +from controllers.web import api +from controllers.web.wraps import WebApiResource + + +class AppParameterApi(WebApiResource): + """Resource for app variables.""" + variable_fields = { + 'key': fields.String, + 'name': fields.String, + 'description': fields.String, + 'type': fields.String, + 'default': fields.String, + 'max_length': fields.Integer, + 'options': fields.List(fields.String) + } + + parameters_fields = { + 'opening_statement': fields.String, + 'suggested_questions': fields.Raw, + 'suggested_questions_after_answer': fields.Raw, + 'more_like_this': fields.Raw, + 'user_input_form': fields.Raw, + } + + @marshal_with(parameters_fields) + def get(self, app_model, end_user): + """Retrieve app parameters.""" + app_model_config = app_model.app_model_config + + return { + 'opening_statement': app_model_config.opening_statement, + 'suggested_questions': app_model_config.suggested_questions_list, + 'suggested_questions_after_answer': app_model_config.suggested_questions_after_answer_dict, + 'more_like_this': app_model_config.more_like_this_dict, + 'user_input_form': app_model_config.user_input_form_list + } + + +api.add_resource(AppParameterApi, '/parameters') diff --git a/api/controllers/web/completion.py b/api/controllers/web/completion.py new file mode 100644 index 0000000000..532bcfaa8d --- /dev/null +++ b/api/controllers/web/completion.py @@ -0,0 +1,175 @@ +# -*- coding:utf-8 -*- +import json +import logging +from typing import Generator, Union + +from flask import Response, stream_with_context +from flask_restful import reqparse +from werkzeug.exceptions import InternalServerError, NotFound + +import services +from controllers.web import api +from controllers.web.error import AppUnavailableError, ConversationCompletedError, \ + ProviderNotInitializeError, NotChatAppError, NotCompletionAppError, CompletionRequestError, \ + ProviderQuotaExceededError, ProviderModelCurrentlyNotSupportError +from controllers.web.wraps import WebApiResource +from core.conversation_message_task import PubHandler +from core.llm.error import LLMBadRequestError, LLMAPIUnavailableError, LLMAuthorizationError, LLMAPIConnectionError, \ + LLMRateLimitError, ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError +from libs.helper import uuid_value +from services.completion_service import CompletionService + + +# define completion api for user +class CompletionApi(WebApiResource): + + def post(self, app_model, end_user): + if app_model.mode != 'completion': + raise NotCompletionAppError() + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, location='json') + parser.add_argument('response_mode', type=str, choices=['blocking', 'streaming'], location='json') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + try: + response = CompletionService.completion( + app_model=app_model, + user=end_user, + args=args, + from_source='api', + streaming=streaming + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +class CompletionStopApi(WebApiResource): + def post(self, app_model, end_user, task_id): + if app_model.mode != 'completion': + raise NotCompletionAppError() + + PubHandler.stop(end_user, task_id) + + return {'result': 'success'}, 200 + + +class ChatApi(WebApiResource): + def post(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('inputs', type=dict, required=True, location='json') + parser.add_argument('query', type=str, required=True, location='json') + parser.add_argument('response_mode', type=str, choices=['blocking', 'streaming'], location='json') + parser.add_argument('conversation_id', type=uuid_value, location='json') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + try: + response = CompletionService.completion( + app_model=app_model, + user=end_user, + args=args, + from_source='api', + streaming=streaming + ) + + return compact_response(response) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception as e: + logging.exception("internal server error.") + raise InternalServerError() + + +class ChatStopApi(WebApiResource): + def post(self, app_model, end_user, task_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + PubHandler.stop(end_user, task_id) + + return {'result': 'success'}, 200 + + +def compact_response(response: Union[dict | Generator]) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype='application/json') + else: + def generate() -> Generator: + try: + for chunk in response: + yield chunk + except services.errors.conversation.ConversationNotExistsError: + yield "data: " + json.dumps(api.handle_error(NotFound("Conversation Not Exists.")).get_json()) + "\n\n" + except services.errors.conversation.ConversationCompletedError: + yield "data: " + json.dumps(api.handle_error(ConversationCompletedError()).get_json()) + "\n\n" + except services.errors.app_model_config.AppModelConfigBrokenError: + logging.exception("App model config broken.") + yield "data: " + json.dumps(api.handle_error(AppUnavailableError()).get_json()) + "\n\n" + except ProviderTokenNotInitError: + yield "data: " + json.dumps(api.handle_error(ProviderNotInitializeError()).get_json()) + "\n\n" + except QuotaExceededError: + yield "data: " + json.dumps(api.handle_error(ProviderQuotaExceededError()).get_json()) + "\n\n" + except ModelCurrentlyNotSupportError: + yield "data: " + json.dumps(api.handle_error(ProviderModelCurrentlyNotSupportError()).get_json()) + "\n\n" + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + yield "data: " + json.dumps(api.handle_error(CompletionRequestError(str(e))).get_json()) + "\n\n" + except ValueError as e: + yield "data: " + json.dumps(api.handle_error(e).get_json()) + "\n\n" + except Exception: + logging.exception("internal server error.") + yield "data: " + json.dumps(api.handle_error(InternalServerError()).get_json()) + "\n\n" + + return Response(stream_with_context(generate()), status=200, + mimetype='text/event-stream') + + +api.add_resource(CompletionApi, '/completion-messages') +api.add_resource(CompletionStopApi, '/completion-messages//stop') +api.add_resource(ChatApi, '/chat-messages') +api.add_resource(ChatStopApi, '/chat-messages//stop') diff --git a/api/controllers/web/conversation.py b/api/controllers/web/conversation.py new file mode 100644 index 0000000000..53ba382051 --- /dev/null +++ b/api/controllers/web/conversation.py @@ -0,0 +1,121 @@ +# -*- coding:utf-8 -*- +from flask_restful import fields, reqparse, marshal_with +from flask_restful.inputs import int_range +from werkzeug.exceptions import NotFound + +from controllers.web import api +from controllers.web.error import NotChatAppError +from controllers.web.wraps import WebApiResource +from libs.helper import TimestampField, uuid_value +from services.conversation_service import ConversationService +from services.errors.conversation import LastConversationNotExistsError, ConversationNotExistsError +from services.web_conversation_service import WebConversationService + +conversation_fields = { + 'id': fields.String, + 'name': fields.String, + 'inputs': fields.Raw, + 'status': fields.String, + 'introduction': fields.String, + 'created_at': TimestampField +} + +conversation_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(conversation_fields)) +} + + +class ConversationListApi(WebApiResource): + + @marshal_with(conversation_infinite_scroll_pagination_fields) + def get(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('last_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + parser.add_argument('pinned', type=str, choices=['true', 'false', None], location='args') + args = parser.parse_args() + + pinned = None + if 'pinned' in args and args['pinned'] is not None: + pinned = True if args['pinned'] == 'true' else False + + try: + return WebConversationService.pagination_by_last_id( + app_model=app_model, + end_user=end_user, + last_id=args['last_id'], + limit=args['limit'], + pinned=pinned + ) + except LastConversationNotExistsError: + raise NotFound("Last Conversation Not Exists.") + + +class ConversationApi(WebApiResource): + def delete(self, app_model, end_user, c_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + conversation_id = str(c_id) + ConversationService.delete(app_model, conversation_id, end_user) + WebConversationService.unpin(app_model, conversation_id, end_user) + + return {"result": "success"}, 204 + + +class ConversationRenameApi(WebApiResource): + + @marshal_with(conversation_fields) + def post(self, app_model, end_user, c_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + conversation_id = str(c_id) + + parser = reqparse.RequestParser() + parser.add_argument('name', type=str, required=True, location='json') + args = parser.parse_args() + + try: + return ConversationService.rename(app_model, conversation_id, end_user, args['name']) + except ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + + +class ConversationPinApi(WebApiResource): + + def patch(self, app_model, end_user, c_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + conversation_id = str(c_id) + + try: + WebConversationService.pin(app_model, conversation_id, end_user) + except ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + + return {"result": "success"} + + +class ConversationUnPinApi(WebApiResource): + def patch(self, app_model, end_user, c_id): + if app_model.mode != 'chat': + raise NotChatAppError() + + conversation_id = str(c_id) + WebConversationService.unpin(app_model, conversation_id, end_user) + + return {"result": "success"} + + +api.add_resource(ConversationRenameApi, '/conversations//name', endpoint='web_conversation_name') +api.add_resource(ConversationListApi, '/conversations') +api.add_resource(ConversationApi, '/conversations/') +api.add_resource(ConversationPinApi, '/conversations//pin') +api.add_resource(ConversationUnPinApi, '/conversations//unpin') diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py new file mode 100644 index 0000000000..ea72422a1b --- /dev/null +++ b/api/controllers/web/error.py @@ -0,0 +1,62 @@ +# -*- coding:utf-8 -*- +from libs.exception import BaseHTTPException + + +class AppUnavailableError(BaseHTTPException): + error_code = 'app_unavailable' + description = "App unavailable." + code = 400 + + +class NotCompletionAppError(BaseHTTPException): + error_code = 'not_completion_app' + description = "Not Completion App" + code = 400 + + +class NotChatAppError(BaseHTTPException): + error_code = 'not_chat_app' + description = "Not Chat App" + code = 400 + + +class ConversationCompletedError(BaseHTTPException): + error_code = 'conversation_completed' + description = "Conversation Completed." + code = 400 + + +class ProviderNotInitializeError(BaseHTTPException): + error_code = 'provider_not_initialize' + description = "Provider Token not initialize." + code = 400 + + +class ProviderQuotaExceededError(BaseHTTPException): + error_code = 'provider_quota_exceeded' + description = "Provider quota exceeded." + code = 400 + + +class ProviderModelCurrentlyNotSupportError(BaseHTTPException): + error_code = 'model_currently_not_support' + description = "GPT-4 currently not support." + code = 400 + + +class CompletionRequestError(BaseHTTPException): + error_code = 'completion_request_error' + description = "Completion request failed." + code = 400 + + +class AppMoreLikeThisDisabledError(BaseHTTPException): + error_code = 'app_more_like_this_disabled' + description = "More like this disabled." + code = 403 + + +class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException): + error_code = 'app_suggested_questions_after_answer_disabled' + description = "Function Suggested questions after answer disabled." + code = 403 diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py new file mode 100644 index 0000000000..0d519eac06 --- /dev/null +++ b/api/controllers/web/message.py @@ -0,0 +1,189 @@ +# -*- coding:utf-8 -*- +import json +import logging +from typing import Generator, Union + +from flask import stream_with_context, Response +from flask_restful import reqparse, fields, marshal_with +from flask_restful.inputs import int_range +from werkzeug.exceptions import NotFound, InternalServerError + +import services +from controllers.web import api +from controllers.web.error import NotChatAppError, CompletionRequestError, ProviderNotInitializeError, \ + AppMoreLikeThisDisabledError, NotCompletionAppError, AppSuggestedQuestionsAfterAnswerDisabledError, \ + ProviderQuotaExceededError, ProviderModelCurrentlyNotSupportError +from controllers.web.wraps import WebApiResource +from core.llm.error import LLMRateLimitError, LLMBadRequestError, LLMAuthorizationError, LLMAPIConnectionError, \ + ProviderTokenNotInitError, LLMAPIUnavailableError, QuotaExceededError, ModelCurrentlyNotSupportError +from libs.helper import uuid_value, TimestampField +from services.completion_service import CompletionService +from services.errors.app import MoreLikeThisDisabledError +from services.errors.conversation import ConversationNotExistsError +from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError +from services.message_service import MessageService + + +class MessageListApi(WebApiResource): + feedback_fields = { + 'rating': fields.String + } + + message_fields = { + 'id': fields.String, + 'conversation_id': fields.String, + 'inputs': fields.Raw, + 'query': fields.String, + 'answer': fields.String, + 'feedback': fields.Nested(feedback_fields, attribute='user_feedback', allow_null=True), + 'created_at': TimestampField + } + + message_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(message_fields)) + } + + @marshal_with(message_infinite_scroll_pagination_fields) + def get(self, app_model, end_user): + if app_model.mode != 'chat': + raise NotChatAppError() + + parser = reqparse.RequestParser() + parser.add_argument('conversation_id', required=True, type=uuid_value, location='args') + parser.add_argument('first_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + args = parser.parse_args() + + try: + return MessageService.pagination_by_first_id(app_model, end_user, + args['conversation_id'], args['first_id'], args['limit']) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.message.FirstMessageNotExistsError: + raise NotFound("First Message Not Exists.") + + +class MessageFeedbackApi(WebApiResource): + def post(self, app_model, end_user, message_id): + message_id = str(message_id) + + parser = reqparse.RequestParser() + parser.add_argument('rating', type=str, choices=['like', 'dislike', None], location='json') + args = parser.parse_args() + + try: + MessageService.create_feedback(app_model, message_id, end_user, args['rating']) + except services.errors.message.MessageNotExistsError: + raise NotFound("Message Not Exists.") + + return {'result': 'success'} + + +class MessageMoreLikeThisApi(WebApiResource): + def get(self, app_model, end_user, message_id): + if app_model.mode != 'completion': + raise NotCompletionAppError() + + message_id = str(message_id) + + parser = reqparse.RequestParser() + parser.add_argument('response_mode', type=str, required=True, choices=['blocking', 'streaming'], location='args') + args = parser.parse_args() + + streaming = args['response_mode'] == 'streaming' + + try: + response = CompletionService.generate_more_like_this(app_model, end_user, message_id, streaming) + return compact_response(response) + except MessageNotExistsError: + raise NotFound("Message Not Exists.") + except MoreLikeThisDisabledError: + raise AppMoreLikeThisDisabledError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except ValueError as e: + raise e + except Exception: + logging.exception("internal server error.") + raise InternalServerError() + + +def compact_response(response: Union[dict | Generator]) -> Response: + if isinstance(response, dict): + return Response(response=json.dumps(response), status=200, mimetype='application/json') + else: + def generate() -> Generator: + try: + for chunk in response: + yield chunk + except MessageNotExistsError: + yield "data: " + json.dumps(api.handle_error(NotFound("Message Not Exists.")).get_json()) + "\n\n" + except MoreLikeThisDisabledError: + yield "data: " + json.dumps(api.handle_error(AppMoreLikeThisDisabledError()).get_json()) + "\n\n" + except ProviderTokenNotInitError: + yield "data: " + json.dumps(api.handle_error(ProviderNotInitializeError()).get_json()) + "\n\n" + except QuotaExceededError: + yield "data: " + json.dumps(api.handle_error(ProviderQuotaExceededError()).get_json()) + "\n\n" + except ModelCurrentlyNotSupportError: + yield "data: " + json.dumps(api.handle_error(ProviderModelCurrentlyNotSupportError()).get_json()) + "\n\n" + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + yield "data: " + json.dumps(api.handle_error(CompletionRequestError(str(e))).get_json()) + "\n\n" + except ValueError as e: + yield "data: " + json.dumps(api.handle_error(e).get_json()) + "\n\n" + except Exception: + logging.exception("internal server error.") + yield "data: " + json.dumps(api.handle_error(InternalServerError()).get_json()) + "\n\n" + + return Response(stream_with_context(generate()), status=200, + mimetype='text/event-stream') + + +class MessageSuggestedQuestionApi(WebApiResource): + def get(self, app_model, end_user, message_id): + if app_model.mode != 'chat': + raise NotCompletionAppError() + + message_id = str(message_id) + + try: + questions = MessageService.get_suggested_questions_after_answer( + app_model=app_model, + user=end_user, + message_id=message_id + ) + except MessageNotExistsError: + raise NotFound("Message not found") + except ConversationNotExistsError: + raise NotFound("Conversation not found") + except SuggestedQuestionsAfterAnswerDisabledError: + raise AppSuggestedQuestionsAfterAnswerDisabledError() + except ProviderTokenNotInitError: + raise ProviderNotInitializeError() + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, LLMAuthorizationError) as e: + raise CompletionRequestError(str(e)) + except Exception: + logging.exception("internal server error.") + raise InternalServerError() + + return {'data': questions} + + +api.add_resource(MessageListApi, '/messages') +api.add_resource(MessageFeedbackApi, '/messages//feedbacks') +api.add_resource(MessageMoreLikeThisApi, '/messages//more-like-this') +api.add_resource(MessageSuggestedQuestionApi, '/messages//suggested-questions') diff --git a/api/controllers/web/saved_message.py b/api/controllers/web/saved_message.py new file mode 100644 index 0000000000..7f6f4249c9 --- /dev/null +++ b/api/controllers/web/saved_message.py @@ -0,0 +1,74 @@ +from flask_restful import reqparse, marshal_with, fields +from flask_restful.inputs import int_range +from werkzeug.exceptions import NotFound + +from controllers.web import api +from controllers.web.error import NotCompletionAppError +from controllers.web.wraps import WebApiResource +from libs.helper import uuid_value, TimestampField +from services.errors.message import MessageNotExistsError +from services.saved_message_service import SavedMessageService + +feedback_fields = { + 'rating': fields.String +} + +message_fields = { + 'id': fields.String, + 'inputs': fields.Raw, + 'query': fields.String, + 'answer': fields.String, + 'feedback': fields.Nested(feedback_fields, attribute='user_feedback', allow_null=True), + 'created_at': TimestampField +} + + +class SavedMessageListApi(WebApiResource): + saved_message_infinite_scroll_pagination_fields = { + 'limit': fields.Integer, + 'has_more': fields.Boolean, + 'data': fields.List(fields.Nested(message_fields)) + } + + @marshal_with(saved_message_infinite_scroll_pagination_fields) + def get(self, app_model, end_user): + if app_model.mode != 'completion': + raise NotCompletionAppError() + + parser = reqparse.RequestParser() + parser.add_argument('last_id', type=uuid_value, location='args') + parser.add_argument('limit', type=int_range(1, 100), required=False, default=20, location='args') + args = parser.parse_args() + + return SavedMessageService.pagination_by_last_id(app_model, end_user, args['last_id'], args['limit']) + + def post(self, app_model, end_user): + if app_model.mode != 'completion': + raise NotCompletionAppError() + + parser = reqparse.RequestParser() + parser.add_argument('message_id', type=uuid_value, required=True, location='json') + args = parser.parse_args() + + try: + SavedMessageService.save(app_model, end_user, args['message_id']) + except MessageNotExistsError: + raise NotFound("Message Not Exists.") + + return {'result': 'success'} + + +class SavedMessageApi(WebApiResource): + def delete(self, app_model, end_user, message_id): + message_id = str(message_id) + + if app_model.mode != 'completion': + raise NotCompletionAppError() + + SavedMessageService.delete(app_model, end_user, message_id) + + return {'result': 'success'} + + +api.add_resource(SavedMessageListApi, '/saved-messages') +api.add_resource(SavedMessageApi, '/saved-messages/') diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py new file mode 100644 index 0000000000..de7a38b6df --- /dev/null +++ b/api/controllers/web/site.py @@ -0,0 +1,73 @@ +# -*- coding:utf-8 -*- +from flask_restful import fields, marshal_with +from werkzeug.exceptions import Forbidden + +from controllers.web import api +from controllers.web.wraps import WebApiResource +from extensions.ext_database import db +from models.model import Site + + +class AppSiteApi(WebApiResource): + """Resource for app sites.""" + + model_config_fields = { + 'opening_statement': fields.String, + 'suggested_questions': fields.Raw(attribute='suggested_questions_list'), + 'suggested_questions_after_answer': fields.Raw(attribute='suggested_questions_after_answer_dict'), + 'more_like_this': fields.Raw(attribute='more_like_this_dict'), + 'model': fields.Raw(attribute='model_dict'), + 'user_input_form': fields.Raw(attribute='user_input_form_list'), + 'pre_prompt': fields.String, + } + + site_fields = { + 'title': fields.String, + 'icon': fields.String, + 'icon_background': fields.String, + 'description': fields.String, + 'copyright': fields.String, + 'privacy_policy': fields.String, + 'default_language': fields.String, + 'prompt_public': fields.Boolean + } + + app_fields = { + 'app_id': fields.String, + 'end_user_id': fields.String, + 'enable_site': fields.Boolean, + 'site': fields.Nested(site_fields), + 'model_config': fields.Nested(model_config_fields, allow_null=True), + 'plan': fields.String, + } + + @marshal_with(app_fields) + def get(self, app_model, end_user): + """Retrieve app site info.""" + # get site + site = db.session.query(Site).filter(Site.app_id == app_model.id).first() + + if not site: + raise Forbidden() + + return AppSiteInfo(app_model.tenant, app_model, site, end_user.id) + + +api.add_resource(AppSiteApi, '/site') + + +class AppSiteInfo: + """Class to store site information.""" + + def __init__(self, tenant, app, site, end_user): + """Initialize AppSiteInfo instance.""" + self.app_id = app.id + self.end_user_id = end_user + self.enable_site = app.enable_site + self.site = site + self.model_config = None + self.plan = tenant.plan + + if app.enable_site and site.prompt_public: + app_model_config = app.app_model_config + self.model_config = app_model_config diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py new file mode 100644 index 0000000000..d227a9659e --- /dev/null +++ b/api/controllers/web/wraps.py @@ -0,0 +1,107 @@ +# -*- coding:utf-8 -*- +import uuid +from functools import wraps + +from flask import request, session +from flask_restful import Resource +from werkzeug.exceptions import NotFound, Unauthorized + +from extensions.ext_database import db +from models.model import App, Site, EndUser + + +def validate_token(view=None): + def decorator(view): + @wraps(view) + def decorated(*args, **kwargs): + site = validate_and_get_site() + + app_model = db.session.query(App).get(site.app_id) + if not app_model: + raise NotFound() + + if app_model.status != 'normal': + raise NotFound() + + if not app_model.enable_site: + raise NotFound() + + end_user = create_or_update_end_user_for_session(app_model) + + return view(app_model, end_user, *args, **kwargs) + return decorated + + if view: + return decorator(view) + return decorator + + +def validate_and_get_site(): + """ + Validate and get API token. + """ + auth_header = request.headers.get('Authorization') + if auth_header is None: + raise Unauthorized() + + auth_scheme, auth_token = auth_header.split(None, 1) + auth_scheme = auth_scheme.lower() + + if auth_scheme != 'bearer': + raise Unauthorized() + + site = db.session.query(Site).filter( + Site.code == auth_token, + Site.status == 'normal' + ).first() + + if not site: + raise NotFound() + + return site + + +def create_or_update_end_user_for_session(app_model): + """ + Create or update session terminal based on session ID. + """ + if 'session_id' not in session: + session['session_id'] = generate_session_id() + + session_id = session.get('session_id') + end_user = db.session.query(EndUser) \ + .filter( + EndUser.session_id == session_id, + EndUser.type == 'browser' + ).first() + + if end_user is None: + end_user = EndUser( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + type='browser', + is_anonymous=True, + session_id=session_id + ) + db.session.add(end_user) + db.session.commit() + + return end_user + + +def generate_session_id(): + """ + Generate a unique session ID. + """ + count = 1 + session_id = '' + while count != 0: + session_id = str(uuid.uuid4()) + count = db.session.query(EndUser) \ + .filter(EndUser.session_id == session_id).count() + + return session_id + + +class WebApiResource(Resource): + method_decorators = [validate_token] diff --git a/api/core/__init__.py b/api/core/__init__.py new file mode 100644 index 0000000000..f6257d8b36 --- /dev/null +++ b/api/core/__init__.py @@ -0,0 +1,52 @@ +import os +from typing import Optional + +import langchain +from flask import Flask +from jieba.analyse import default_tfidf +from langchain import set_handler +from langchain.prompts.base import DEFAULT_FORMATTER_MAPPING +from llama_index import IndexStructType, QueryMode +from llama_index.indices.registry import INDEX_STRUT_TYPE_TO_QUERY_MAP +from pydantic import BaseModel + +from core.callback_handler.std_out_callback_handler import DifyStdOutCallbackHandler +from core.index.keyword_table.jieba_keyword_table import GPTJIEBAKeywordTableIndex +from core.index.keyword_table.stopwords import STOPWORDS +from core.prompt.prompt_template import OneLineFormatter +from core.vector_store.vector_store import VectorStore +from core.vector_store.vector_store_index_query import EnhanceGPTVectorStoreIndexQuery + + +class HostedOpenAICredential(BaseModel): + api_key: str + + +class HostedLLMCredentials(BaseModel): + openai: Optional[HostedOpenAICredential] = None + + +hosted_llm_credentials = HostedLLMCredentials() + + +def init_app(app: Flask): + formatter = OneLineFormatter() + DEFAULT_FORMATTER_MAPPING['f-string'] = formatter.format + INDEX_STRUT_TYPE_TO_QUERY_MAP[IndexStructType.KEYWORD_TABLE] = GPTJIEBAKeywordTableIndex.get_query_map() + INDEX_STRUT_TYPE_TO_QUERY_MAP[IndexStructType.WEAVIATE] = { + QueryMode.DEFAULT: EnhanceGPTVectorStoreIndexQuery, + QueryMode.EMBEDDING: EnhanceGPTVectorStoreIndexQuery, + } + INDEX_STRUT_TYPE_TO_QUERY_MAP[IndexStructType.QDRANT] = { + QueryMode.DEFAULT: EnhanceGPTVectorStoreIndexQuery, + QueryMode.EMBEDDING: EnhanceGPTVectorStoreIndexQuery, + } + + default_tfidf.stop_words = STOPWORDS + + if os.environ.get("DEBUG") and os.environ.get("DEBUG").lower() == 'true': + langchain.verbose = True + set_handler(DifyStdOutCallbackHandler()) + + if app.config.get("OPENAI_API_KEY"): + hosted_llm_credentials.openai = HostedOpenAICredential(api_key=app.config.get("OPENAI_API_KEY")) diff --git a/api/core/agent/agent_builder.py b/api/core/agent/agent_builder.py new file mode 100644 index 0000000000..b1d6948467 --- /dev/null +++ b/api/core/agent/agent_builder.py @@ -0,0 +1,89 @@ +from typing import Optional + +from langchain import LLMChain +from langchain.agents import ZeroShotAgent, AgentExecutor, ConversationalAgent +from langchain.callbacks import CallbackManager +from langchain.memory.chat_memory import BaseChatMemory + +from core.callback_handler.agent_loop_gather_callback_handler import AgentLoopGatherCallbackHandler +from core.callback_handler.dataset_tool_callback_handler import DatasetToolCallbackHandler +from core.callback_handler.std_out_callback_handler import DifyStdOutCallbackHandler +from core.llm.llm_builder import LLMBuilder + + +class AgentBuilder: + @classmethod + def to_agent_chain(cls, tenant_id: str, tools, memory: Optional[BaseChatMemory], + dataset_tool_callback_handler: DatasetToolCallbackHandler, + agent_loop_gather_callback_handler: AgentLoopGatherCallbackHandler): + llm_callback_manager = CallbackManager([agent_loop_gather_callback_handler, DifyStdOutCallbackHandler()]) + llm = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name=agent_loop_gather_callback_handler.model_name, + temperature=0, + max_tokens=1024, + callback_manager=llm_callback_manager + ) + + tool_callback_manager = CallbackManager([ + agent_loop_gather_callback_handler, + dataset_tool_callback_handler, + DifyStdOutCallbackHandler() + ]) + + for tool in tools: + tool.callback_manager = tool_callback_manager + + prompt = cls.build_agent_prompt_template( + tools=tools, + memory=memory, + ) + + agent_llm_chain = LLMChain( + llm=llm, + prompt=prompt, + ) + + agent = cls.build_agent(agent_llm_chain=agent_llm_chain, memory=memory) + + agent_callback_manager = CallbackManager( + [agent_loop_gather_callback_handler, DifyStdOutCallbackHandler()] + ) + + agent_chain = AgentExecutor.from_agent_and_tools( + tools=tools, + agent=agent, + memory=memory, + callback_manager=agent_callback_manager, + max_iterations=6, + early_stopping_method="generate", + # `generate` will continue to complete the last inference after reaching the iteration limit or request time limit + ) + + return agent_chain + + @classmethod + def build_agent_prompt_template(cls, tools, memory: Optional[BaseChatMemory]): + if memory: + prompt = ConversationalAgent.create_prompt( + tools=tools, + ) + else: + prompt = ZeroShotAgent.create_prompt( + tools=tools, + ) + + return prompt + + @classmethod + def build_agent(cls, agent_llm_chain: LLMChain, memory: Optional[BaseChatMemory]): + if memory: + agent = ConversationalAgent( + llm_chain=agent_llm_chain + ) + else: + agent = ZeroShotAgent( + llm_chain=agent_llm_chain + ) + + return agent diff --git a/api/core/callback_handler/agent_loop_gather_callback_handler.py b/api/core/callback_handler/agent_loop_gather_callback_handler.py new file mode 100644 index 0000000000..f37411cacc --- /dev/null +++ b/api/core/callback_handler/agent_loop_gather_callback_handler.py @@ -0,0 +1,178 @@ +import logging +import time + +from typing import Any, Dict, List, Union, Optional + +from langchain.callbacks.base import BaseCallbackHandler +from langchain.schema import AgentAction, AgentFinish, LLMResult + +from core.callback_handler.entity.agent_loop import AgentLoop +from core.conversation_message_task import ConversationMessageTask + + +class AgentLoopGatherCallbackHandler(BaseCallbackHandler): + """Callback Handler that prints to std out.""" + + def __init__(self, model_name, conversation_message_task: ConversationMessageTask) -> None: + """Initialize callback handler.""" + self.model_name = model_name + self.conversation_message_task = conversation_message_task + self._agent_loops = [] + self._current_loop = None + self.current_chain = None + + @property + def agent_loops(self) -> List[AgentLoop]: + return self._agent_loops + + def clear_agent_loops(self) -> None: + self._agent_loops = [] + self._current_loop = None + + @property + def always_verbose(self) -> bool: + """Whether to call verbose callbacks even if verbose is False.""" + return True + + @property + def ignore_chain(self) -> bool: + """Whether to ignore chain callbacks.""" + return True + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + """Print out the prompts.""" + # serialized={'name': 'OpenAI'} + # prompts=['Answer the following questions...\nThought:'] + # kwargs={} + if not self._current_loop: + # Agent start with a LLM query + self._current_loop = AgentLoop( + position=len(self._agent_loops) + 1, + prompt=prompts[0], + status='llm_started', + started_at=time.perf_counter() + ) + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Do nothing.""" + # kwargs={} + if self._current_loop and self._current_loop.status == 'llm_started': + self._current_loop.status = 'llm_end' + self._current_loop.prompt_tokens = response.llm_output['token_usage']['prompt_tokens'] + self._current_loop.completion = response.generations[0][0].text + self._current_loop.completion_tokens = response.llm_output['token_usage']['completion_tokens'] + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + """Do nothing.""" + pass + + def on_llm_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + logging.error(error) + self._agent_loops = [] + self._current_loop = None + + def on_chain_start( + self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any + ) -> None: + """Print out that we are entering a chain.""" + pass + + def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: + """Print out that we finished a chain.""" + pass + + def on_chain_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + logging.error(error) + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + """Do nothing.""" + # kwargs={'color': 'green', 'llm_prefix': 'Thought:', 'observation_prefix': 'Observation: '} + # input_str='action-input' + # serialized={'description': 'A search engine. Useful for when you need to answer questions about current events. Input should be a search query.', 'name': 'Search'} + pass + + def on_agent_action( + self, action: AgentAction, color: Optional[str] = None, **kwargs: Any + ) -> Any: + """Run on agent action.""" + tool = action.tool + tool_input = action.tool_input + action_name_position = action.log.index("\nAction:") + 1 if action.log else -1 + thought = action.log[:action_name_position].strip() if action.log else '' + + if self._current_loop and self._current_loop.status == 'llm_end': + self._current_loop.status = 'agent_action' + self._current_loop.thought = thought + self._current_loop.tool_name = tool + self._current_loop.tool_input = tool_input + + def on_tool_end( + self, + output: str, + color: Optional[str] = None, + observation_prefix: Optional[str] = None, + llm_prefix: Optional[str] = None, + **kwargs: Any, + ) -> None: + """If not the final action, print out observation.""" + # kwargs={'name': 'Search'} + # llm_prefix='Thought:' + # observation_prefix='Observation: ' + # output='53 years' + + if self._current_loop and self._current_loop.status == 'agent_action' and output and output != 'None': + self._current_loop.status = 'tool_end' + self._current_loop.tool_output = output + self._current_loop.completed = True + self._current_loop.completed_at = time.perf_counter() + self._current_loop.latency = self._current_loop.completed_at - self._current_loop.started_at + + self.conversation_message_task.on_agent_end(self.current_chain, self.model_name, self._current_loop) + + self._agent_loops.append(self._current_loop) + self._current_loop = None + + def on_tool_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + logging.error(error) + self._agent_loops = [] + self._current_loop = None + + def on_text( + self, + text: str, + color: Optional[str] = None, + end: str = "", + **kwargs: Optional[str], + ) -> None: + """Run on additional input from chains and agents.""" + pass + + def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> Any: + """Run on agent end.""" + # Final Answer + if self._current_loop and (self._current_loop.status == 'llm_end' or self._current_loop.status == 'agent_action'): + self._current_loop.status = 'agent_finish' + self._current_loop.completed = True + self._current_loop.completed_at = time.perf_counter() + self._current_loop.latency = self._current_loop.completed_at - self._current_loop.started_at + + self.conversation_message_task.on_agent_end(self.current_chain, self.model_name, self._current_loop) + + self._agent_loops.append(self._current_loop) + self._current_loop = None + elif not self._current_loop and self._agent_loops: + self._agent_loops[-1].status = 'agent_finish' diff --git a/api/core/callback_handler/dataset_tool_callback_handler.py b/api/core/callback_handler/dataset_tool_callback_handler.py new file mode 100644 index 0000000000..e3fce66511 --- /dev/null +++ b/api/core/callback_handler/dataset_tool_callback_handler.py @@ -0,0 +1,117 @@ +import logging + +from typing import Any, Dict, List, Union, Optional + +from langchain.callbacks.base import BaseCallbackHandler +from langchain.schema import AgentAction, AgentFinish, LLMResult + +from core.callback_handler.entity.dataset_query import DatasetQueryObj +from core.conversation_message_task import ConversationMessageTask + + +class DatasetToolCallbackHandler(BaseCallbackHandler): + """Callback Handler that prints to std out.""" + + def __init__(self, conversation_message_task: ConversationMessageTask) -> None: + """Initialize callback handler.""" + self.queries = [] + self.conversation_message_task = conversation_message_task + + @property + def always_verbose(self) -> bool: + """Whether to call verbose callbacks even if verbose is False.""" + return True + + @property + def ignore_llm(self) -> bool: + """Whether to ignore LLM callbacks.""" + return True + + @property + def ignore_chain(self) -> bool: + """Whether to ignore chain callbacks.""" + return True + + @property + def ignore_agent(self) -> bool: + """Whether to ignore agent callbacks.""" + return False + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + tool_name = serialized.get('name') + dataset_id = tool_name[len("dataset-"):] + self.conversation_message_task.on_dataset_query_end(DatasetQueryObj(dataset_id=dataset_id, query=input_str)) + + def on_tool_end( + self, + output: str, + color: Optional[str] = None, + observation_prefix: Optional[str] = None, + llm_prefix: Optional[str] = None, + **kwargs: Any, + ) -> None: + # kwargs={'name': 'Search'} + # llm_prefix='Thought:' + # observation_prefix='Observation: ' + # output='53 years' + pass + + def on_tool_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + logging.error(error) + + def on_chain_start( + self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any + ) -> None: + pass + + def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: + pass + + def on_chain_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + pass + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + pass + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + pass + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + """Do nothing.""" + pass + + def on_llm_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + logging.error(error) + + def on_agent_action( + self, action: AgentAction, color: Optional[str] = None, **kwargs: Any + ) -> Any: + pass + + def on_text( + self, + text: str, + color: Optional[str] = None, + end: str = "", + **kwargs: Optional[str], + ) -> None: + """Run on additional input from chains and agents.""" + pass + + def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> Any: + """Run on agent end.""" + pass diff --git a/api/core/callback_handler/entity/agent_loop.py b/api/core/callback_handler/entity/agent_loop.py new file mode 100644 index 0000000000..13ed4caa7f --- /dev/null +++ b/api/core/callback_handler/entity/agent_loop.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + + +class AgentLoop(BaseModel): + position: int = 1 + + thought: str = None + tool_name: str = None + tool_input: str = None + tool_output: str = None + + prompt: str = None + prompt_tokens: int = None + completion: str = None + completion_tokens: int = None + + latency: float = None + + status: str = 'llm_started' + completed: bool = False + + started_at: float = None + completed_at: float = None \ No newline at end of file diff --git a/api/core/callback_handler/entity/chain_result.py b/api/core/callback_handler/entity/chain_result.py new file mode 100644 index 0000000000..596486cdb0 --- /dev/null +++ b/api/core/callback_handler/entity/chain_result.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class ChainResult(BaseModel): + type: str = None + prompt: dict = None + completion: dict = None + + status: str = 'chain_started' + completed: bool = False + + started_at: float = None + completed_at: float = None + + agent_result: dict = None + """only when type is 'AgentExecutor'""" diff --git a/api/core/callback_handler/entity/dataset_query.py b/api/core/callback_handler/entity/dataset_query.py new file mode 100644 index 0000000000..23705e55ac --- /dev/null +++ b/api/core/callback_handler/entity/dataset_query.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class DatasetQueryObj(BaseModel): + dataset_id: str = None + query: str = None diff --git a/api/core/callback_handler/entity/llm_message.py b/api/core/callback_handler/entity/llm_message.py new file mode 100644 index 0000000000..0f53295ae9 --- /dev/null +++ b/api/core/callback_handler/entity/llm_message.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class LLMMessage(BaseModel): + prompt: str = '' + prompt_tokens: int = 0 + completion: str = '' + completion_tokens: int = 0 + latency: float = 0.0 diff --git a/api/core/callback_handler/index_tool_callback_handler.py b/api/core/callback_handler/index_tool_callback_handler.py new file mode 100644 index 0000000000..f0c9379413 --- /dev/null +++ b/api/core/callback_handler/index_tool_callback_handler.py @@ -0,0 +1,38 @@ +from llama_index import Response + +from extensions.ext_database import db +from models.dataset import DocumentSegment + + +class IndexToolCallbackHandler: + + def __init__(self) -> None: + self._response = None + + @property + def response(self) -> Response: + return self._response + + def on_tool_end(self, response: Response) -> None: + """Handle tool end.""" + self._response = response + + +class DatasetIndexToolCallbackHandler(IndexToolCallbackHandler): + """Callback handler for dataset tool.""" + + def __init__(self, dataset_id: str) -> None: + super().__init__() + self.dataset_id = dataset_id + + def on_tool_end(self, response: Response) -> None: + """Handle tool end.""" + for node in response.source_nodes: + index_node_id = node.node.doc_id + + # add hit count to document segment + db.session.query(DocumentSegment).filter( + DocumentSegment.dataset_id == self.dataset_id, + DocumentSegment.index_node_id == index_node_id + ).update({DocumentSegment.hit_count: DocumentSegment.hit_count + 1}, synchronize_session=False) + diff --git a/api/core/callback_handler/llm_callback_handler.py b/api/core/callback_handler/llm_callback_handler.py new file mode 100644 index 0000000000..b6f7ef2f54 --- /dev/null +++ b/api/core/callback_handler/llm_callback_handler.py @@ -0,0 +1,147 @@ +import logging +import time +from typing import Any, Dict, List, Union, Optional + +from langchain.callbacks.base import BaseCallbackHandler +from langchain.schema import AgentAction, AgentFinish, LLMResult, HumanMessage, AIMessage, SystemMessage + +from core.callback_handler.entity.llm_message import LLMMessage +from core.conversation_message_task import ConversationMessageTask, ConversationTaskStoppedException +from core.llm.streamable_chat_open_ai import StreamableChatOpenAI +from core.llm.streamable_open_ai import StreamableOpenAI + + +class LLMCallbackHandler(BaseCallbackHandler): + + def __init__(self, llm: Union[StreamableOpenAI, StreamableChatOpenAI], + conversation_message_task: ConversationMessageTask): + self.llm = llm + self.llm_message = LLMMessage() + self.start_at = None + self.conversation_message_task = conversation_message_task + + @property + def always_verbose(self) -> bool: + """Whether to call verbose callbacks even if verbose is False.""" + return True + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + self.start_at = time.perf_counter() + + if 'Chat' in serialized['name']: + real_prompts = [] + messages = [] + for prompt in prompts: + role, content = prompt.split(': ', maxsplit=1) + if role == 'human': + role = 'user' + message = HumanMessage(content=content) + elif role == 'ai': + role = 'assistant' + message = AIMessage(content=content) + else: + message = SystemMessage(content=content) + + real_prompt = { + "role": role, + "text": content + } + real_prompts.append(real_prompt) + messages.append(message) + + self.llm_message.prompt = real_prompts + self.llm_message.prompt_tokens = self.llm.get_messages_tokens(messages) + else: + self.llm_message.prompt = [{ + "role": 'user', + "text": prompts[0] + }] + + self.llm_message.prompt_tokens = self.llm.get_num_tokens(prompts[0]) + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + end_at = time.perf_counter() + self.llm_message.latency = end_at - self.start_at + + if not self.conversation_message_task.streaming: + self.conversation_message_task.append_message_text(response.generations[0][0].text) + self.llm_message.completion = response.generations[0][0].text + self.llm_message.completion_tokens = response.llm_output['token_usage']['completion_tokens'] + else: + self.llm_message.completion_tokens = self.llm.get_num_tokens(self.llm_message.completion) + + self.conversation_message_task.save_message(self.llm_message) + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + self.conversation_message_task.append_message_text(token) + self.llm_message.completion += token + + def on_llm_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + if isinstance(error, ConversationTaskStoppedException): + if self.conversation_message_task.streaming: + end_at = time.perf_counter() + self.llm_message.latency = end_at - self.start_at + self.llm_message.completion_tokens = self.llm.get_num_tokens(self.llm_message.completion) + self.conversation_message_task.save_message(llm_message=self.llm_message, by_stopped=True) + else: + logging.error(error) + + def on_chain_start( + self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any + ) -> None: + pass + + def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: + pass + + def on_chain_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + pass + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + pass + + def on_agent_action( + self, action: AgentAction, color: Optional[str] = None, **kwargs: Any + ) -> Any: + pass + + def on_tool_end( + self, + output: str, + color: Optional[str] = None, + observation_prefix: Optional[str] = None, + llm_prefix: Optional[str] = None, + **kwargs: Any, + ) -> None: + pass + + def on_tool_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + pass + + def on_text( + self, + text: str, + color: Optional[str] = None, + end: str = "", + **kwargs: Optional[str], + ) -> None: + pass + + def on_agent_finish( + self, finish: AgentFinish, color: Optional[str] = None, **kwargs: Any + ) -> None: + pass diff --git a/api/core/callback_handler/main_chain_gather_callback_handler.py b/api/core/callback_handler/main_chain_gather_callback_handler.py new file mode 100644 index 0000000000..1bd41edd6c --- /dev/null +++ b/api/core/callback_handler/main_chain_gather_callback_handler.py @@ -0,0 +1,137 @@ +import logging +import time + +from typing import Any, Dict, List, Union, Optional + +from langchain.callbacks.base import BaseCallbackHandler +from langchain.schema import AgentAction, AgentFinish, LLMResult + +from core.callback_handler.agent_loop_gather_callback_handler import AgentLoopGatherCallbackHandler +from core.callback_handler.entity.chain_result import ChainResult +from core.constant import llm_constant +from core.conversation_message_task import ConversationMessageTask + + +class MainChainGatherCallbackHandler(BaseCallbackHandler): + """Callback Handler that prints to std out.""" + + def __init__(self, conversation_message_task: ConversationMessageTask) -> None: + """Initialize callback handler.""" + self._current_chain_result = None + self._current_chain_message = None + self.conversation_message_task = conversation_message_task + self.agent_loop_gather_callback_handler = AgentLoopGatherCallbackHandler( + llm_constant.agent_model_name, + conversation_message_task + ) + + def clear_chain_results(self) -> None: + self._current_chain_result = None + self._current_chain_message = None + self.agent_loop_gather_callback_handler.current_chain = None + + @property + def always_verbose(self) -> bool: + """Whether to call verbose callbacks even if verbose is False.""" + return True + + @property + def ignore_llm(self) -> bool: + """Whether to ignore LLM callbacks.""" + return True + + @property + def ignore_agent(self) -> bool: + """Whether to ignore agent callbacks.""" + return True + + def on_chain_start( + self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any + ) -> None: + """Print out that we are entering a chain.""" + if not self._current_chain_result: + self._current_chain_result = ChainResult( + type=serialized['name'], + prompt=inputs, + started_at=time.perf_counter() + ) + self._current_chain_message = self.conversation_message_task.init_chain(self._current_chain_result) + self.agent_loop_gather_callback_handler.current_chain = self._current_chain_message + + def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: + """Print out that we finished a chain.""" + if self._current_chain_result and self._current_chain_result.status == 'chain_started': + self._current_chain_result.status = 'chain_ended' + self._current_chain_result.completion = outputs + self._current_chain_result.completed = True + self._current_chain_result.completed_at = time.perf_counter() + + self.conversation_message_task.on_chain_end(self._current_chain_message, self._current_chain_result) + + self.clear_chain_results() + + def on_chain_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + logging.error(error) + self.clear_chain_results() + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + pass + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + pass + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + """Do nothing.""" + pass + + def on_llm_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + logging.error(error) + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + pass + + def on_agent_action( + self, action: AgentAction, color: Optional[str] = None, **kwargs: Any + ) -> Any: + pass + + def on_tool_end( + self, + output: str, + color: Optional[str] = None, + observation_prefix: Optional[str] = None, + llm_prefix: Optional[str] = None, + **kwargs: Any, + ) -> None: + pass + + def on_tool_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + logging.error(error) + + def on_text( + self, + text: str, + color: Optional[str] = None, + end: str = "", + **kwargs: Optional[str], + ) -> None: + """Run on additional input from chains and agents.""" + pass + + def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> Any: + """Run on agent end.""" + pass diff --git a/api/core/callback_handler/std_out_callback_handler.py b/api/core/callback_handler/std_out_callback_handler.py new file mode 100644 index 0000000000..352e6cb4d8 --- /dev/null +++ b/api/core/callback_handler/std_out_callback_handler.py @@ -0,0 +1,127 @@ +import sys +from typing import Any, Dict, List, Optional, Union + +from langchain.callbacks.base import BaseCallbackHandler +from langchain.input import print_text +from langchain.schema import AgentAction, AgentFinish, LLMResult + + +class DifyStdOutCallbackHandler(BaseCallbackHandler): + """Callback Handler that prints to std out.""" + + def __init__(self, color: Optional[str] = None) -> None: + """Initialize callback handler.""" + self.color = color + + def on_llm_start( + self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any + ) -> None: + """Print out the prompts.""" + print_text("\n[on_llm_start]\n", color='blue') + + if 'Chat' in serialized['name']: + for prompt in prompts: + print_text(prompt + "\n", color='blue') + else: + print_text(prompts[0] + "\n", color='blue') + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Do nothing.""" + print_text("\n[on_llm_end]\nOutput: " + str(response.generations[0][0].text) + "\nllm_output: " + str( + response.llm_output) + "\n", color='blue') + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + """Do nothing.""" + pass + + def on_llm_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + print_text("\n[on_llm_error]\nError: " + str(error) + "\n", color='blue') + + def on_chain_start( + self, serialized: Dict[str, Any], inputs: Dict[str, Any], **kwargs: Any + ) -> None: + """Print out that we are entering a chain.""" + class_name = serialized["name"] + print_text("\n[on_chain_start]\nChain: " + class_name + "\nInputs: " + str(inputs) + "\n", color='pink') + + def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None: + """Print out that we finished a chain.""" + print_text("\n[on_chain_end]\nOutputs: " + str(outputs) + "\n", color='pink') + + def on_chain_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + print_text("\n[on_chain_error]\nError: " + str(error) + "\n", color='pink') + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + **kwargs: Any, + ) -> None: + """Do nothing.""" + print_text("\n[on_tool_start] " + str(serialized), color='yellow') + + def on_agent_action( + self, action: AgentAction, color: Optional[str] = None, **kwargs: Any + ) -> Any: + """Run on agent action.""" + tool = action.tool + tool_input = action.tool_input + action_name_position = action.log.index("\nAction:") + 1 if action.log else -1 + thought = action.log[:action_name_position].strip() if action.log else '' + + log = f"Thought: {thought}\nTool: {tool}\nTool Input: {tool_input}" + print_text("\n[on_agent_action]\n" + log + "\n", color='green') + + def on_tool_end( + self, + output: str, + color: Optional[str] = None, + observation_prefix: Optional[str] = None, + llm_prefix: Optional[str] = None, + **kwargs: Any, + ) -> None: + """If not the final action, print out observation.""" + print_text("\n[on_tool_end]\n", color='yellow') + if observation_prefix: + print_text(f"\n{observation_prefix}") + print_text(output, color='yellow') + if llm_prefix: + print_text(f"\n{llm_prefix}") + print_text("\n") + + def on_tool_error( + self, error: Union[Exception, KeyboardInterrupt], **kwargs: Any + ) -> None: + """Do nothing.""" + print_text("\n[on_tool_error] Error: " + str(error) + "\n", color='yellow') + + def on_text( + self, + text: str, + color: Optional[str] = None, + end: str = "", + **kwargs: Optional[str], + ) -> None: + """Run when agent ends.""" + print_text("\n[on_text] " + text + "\n", color=color if color else self.color, end=end) + + def on_agent_finish( + self, finish: AgentFinish, color: Optional[str] = None, **kwargs: Any + ) -> None: + """Run on agent end.""" + print_text("[on_agent_finish] " + finish.return_values['output'] + "\n", color='green', end="\n") + + +class DifyStreamingStdOutCallbackHandler(DifyStdOutCallbackHandler): + """Callback handler for streaming. Only works with LLMs that support streaming.""" + + def on_llm_new_token(self, token: str, **kwargs: Any) -> None: + """Run on new LLM token. Only available when streaming is enabled.""" + sys.stdout.write(token) + sys.stdout.flush() diff --git a/api/core/chain/chain_builder.py b/api/core/chain/chain_builder.py new file mode 100644 index 0000000000..b7583ed890 --- /dev/null +++ b/api/core/chain/chain_builder.py @@ -0,0 +1,34 @@ +from typing import Optional + +from langchain.callbacks import CallbackManager + +from core.callback_handler.std_out_callback_handler import DifyStdOutCallbackHandler +from core.chain.sensitive_word_avoidance_chain import SensitiveWordAvoidanceChain +from core.chain.tool_chain import ToolChain + + +class ChainBuilder: + @classmethod + def to_tool_chain(cls, tool, **kwargs) -> ToolChain: + return ToolChain( + tool=tool, + input_key=kwargs.get('input_key', 'input'), + output_key=kwargs.get('output_key', 'tool_output'), + callback_manager=CallbackManager([DifyStdOutCallbackHandler()]) + ) + + @classmethod + def to_sensitive_word_avoidance_chain(cls, tool_config: dict, **kwargs) -> Optional[ + SensitiveWordAvoidanceChain]: + sensitive_words = tool_config.get("words", "") + if tool_config.get("enabled", False) \ + and sensitive_words: + return SensitiveWordAvoidanceChain( + sensitive_words=sensitive_words.split(","), + canned_response=tool_config.get("canned_response", ''), + output_key="sensitive_word_avoidance_output", + callback_manager=CallbackManager([DifyStdOutCallbackHandler()]), + **kwargs + ) + + return None diff --git a/api/core/chain/main_chain_builder.py b/api/core/chain/main_chain_builder.py new file mode 100644 index 0000000000..5a4ab2214d --- /dev/null +++ b/api/core/chain/main_chain_builder.py @@ -0,0 +1,116 @@ +from typing import Optional, List + +from langchain.callbacks import SharedCallbackManager +from langchain.chains import SequentialChain +from langchain.chains.base import Chain +from langchain.memory.chat_memory import BaseChatMemory + +from core.agent.agent_builder import AgentBuilder +from core.callback_handler.agent_loop_gather_callback_handler import AgentLoopGatherCallbackHandler +from core.callback_handler.dataset_tool_callback_handler import DatasetToolCallbackHandler +from core.callback_handler.main_chain_gather_callback_handler import MainChainGatherCallbackHandler +from core.chain.chain_builder import ChainBuilder +from core.constant import llm_constant +from core.conversation_message_task import ConversationMessageTask +from core.tool.dataset_tool_builder import DatasetToolBuilder + + +class MainChainBuilder: + @classmethod + def to_langchain_components(cls, tenant_id: str, agent_mode: dict, memory: Optional[BaseChatMemory], + conversation_message_task: ConversationMessageTask): + first_input_key = "input" + final_output_key = "output" + + chains = [] + + chain_callback_handler = MainChainGatherCallbackHandler(conversation_message_task) + + # agent mode + tool_chains, chains_output_key = cls.get_agent_chains( + tenant_id=tenant_id, + agent_mode=agent_mode, + memory=memory, + dataset_tool_callback_handler=DatasetToolCallbackHandler(conversation_message_task), + agent_loop_gather_callback_handler=chain_callback_handler.agent_loop_gather_callback_handler + ) + chains += tool_chains + + if chains_output_key: + final_output_key = chains_output_key + + if len(chains) == 0: + return None + + for chain in chains: + # do not add handler into singleton callback manager + if not isinstance(chain.callback_manager, SharedCallbackManager): + chain.callback_manager.add_handler(chain_callback_handler) + + # build main chain + overall_chain = SequentialChain( + chains=chains, + input_variables=[first_input_key], + output_variables=[final_output_key], + memory=memory, # only for use the memory prompt input key + ) + + return overall_chain + + @classmethod + def get_agent_chains(cls, tenant_id: str, agent_mode: dict, memory: Optional[BaseChatMemory], + dataset_tool_callback_handler: DatasetToolCallbackHandler, + agent_loop_gather_callback_handler: AgentLoopGatherCallbackHandler): + # agent mode + chains = [] + if agent_mode and agent_mode.get('enabled'): + tools = agent_mode.get('tools', []) + + pre_fixed_chains = [] + agent_tools = [] + for tool in tools: + tool_type = list(tool.keys())[0] + tool_config = list(tool.values())[0] + if tool_type == 'sensitive-word-avoidance': + chain = ChainBuilder.to_sensitive_word_avoidance_chain(tool_config) + if chain: + pre_fixed_chains.append(chain) + elif tool_type == "dataset": + dataset_tool = DatasetToolBuilder.build_dataset_tool( + tenant_id=tenant_id, + dataset_id=tool_config.get("id"), + response_mode='no_synthesizer', # "compact" + callback_handler=dataset_tool_callback_handler + ) + + if dataset_tool: + agent_tools.append(dataset_tool) + + # add pre-fixed chains + chains += pre_fixed_chains + + if len(agent_tools) == 1: + # tool to chain + tool_chain = ChainBuilder.to_tool_chain(tool=agent_tools[0], output_key='tool_output') + chains.append(tool_chain) + elif len(agent_tools) > 1: + # build agent config + agent_chain = AgentBuilder.to_agent_chain( + tenant_id=tenant_id, + tools=agent_tools, + memory=memory, + dataset_tool_callback_handler=dataset_tool_callback_handler, + agent_loop_gather_callback_handler=agent_loop_gather_callback_handler + ) + + chains.append(agent_chain) + + final_output_key = cls.get_chains_output_key(chains) + + return chains, final_output_key + + @classmethod + def get_chains_output_key(cls, chains: List[Chain]): + if len(chains) > 0: + return chains[-1].output_keys[0] + return None diff --git a/api/core/chain/sensitive_word_avoidance_chain.py b/api/core/chain/sensitive_word_avoidance_chain.py new file mode 100644 index 0000000000..a552551c0f --- /dev/null +++ b/api/core/chain/sensitive_word_avoidance_chain.py @@ -0,0 +1,42 @@ +from typing import List, Dict + +from langchain.chains.base import Chain + + +class SensitiveWordAvoidanceChain(Chain): + input_key: str = "input" #: :meta private: + output_key: str = "output" #: :meta private: + + sensitive_words: List[str] = [] + canned_response: str = None + + @property + def _chain_type(self) -> str: + return "sensitive_word_avoidance_chain" + + @property + def input_keys(self) -> List[str]: + """Expect input key. + + :meta private: + """ + return [self.input_key] + + @property + def output_keys(self) -> List[str]: + """Return output key. + + :meta private: + """ + return [self.output_key] + + def _check_sensitive_word(self, text: str) -> str: + for word in self.sensitive_words: + if word in text: + return self.canned_response + return text + + def _call(self, inputs: Dict[str, str]) -> Dict[str, str]: + text = inputs[self.input_key] + output = self._check_sensitive_word(text) + return {self.output_key: output} diff --git a/api/core/chain/tool_chain.py b/api/core/chain/tool_chain.py new file mode 100644 index 0000000000..458a35eb82 --- /dev/null +++ b/api/core/chain/tool_chain.py @@ -0,0 +1,42 @@ +from typing import List, Dict + +from langchain.chains.base import Chain +from langchain.tools import BaseTool + + +class ToolChain(Chain): + input_key: str = "input" #: :meta private: + output_key: str = "output" #: :meta private: + + tool: BaseTool + + @property + def _chain_type(self) -> str: + return "tool_chain" + + @property + def input_keys(self) -> List[str]: + """Expect input key. + + :meta private: + """ + return [self.input_key] + + @property + def output_keys(self) -> List[str]: + """Return output key. + + :meta private: + """ + return [self.output_key] + + def _call(self, inputs: Dict[str, str]) -> Dict[str, str]: + input = inputs[self.input_key] + output = self.tool.run(input, self.verbose) + return {self.output_key: output} + + async def _acall(self, inputs: Dict[str, str]) -> Dict[str, str]: + """Run the logic of this chain and return the output.""" + input = inputs[self.input_key] + output = await self.tool.arun(input, self.verbose) + return {self.output_key: output} diff --git a/api/core/completion.py b/api/core/completion.py new file mode 100644 index 0000000000..f215bd0ee5 --- /dev/null +++ b/api/core/completion.py @@ -0,0 +1,326 @@ +from typing import Optional, List, Union + +from langchain.callbacks import CallbackManager +from langchain.chat_models.base import BaseChatModel +from langchain.llms import BaseLLM +from langchain.schema import BaseMessage, BaseLanguageModel, HumanMessage +from core.constant import llm_constant +from core.callback_handler.llm_callback_handler import LLMCallbackHandler +from core.callback_handler.std_out_callback_handler import DifyStreamingStdOutCallbackHandler, \ + DifyStdOutCallbackHandler +from core.conversation_message_task import ConversationMessageTask, ConversationTaskStoppedException +from core.llm.error import LLMBadRequestError +from core.llm.llm_builder import LLMBuilder +from core.chain.main_chain_builder import MainChainBuilder +from core.llm.streamable_chat_open_ai import StreamableChatOpenAI +from core.llm.streamable_open_ai import StreamableOpenAI +from core.memory.read_only_conversation_token_db_buffer_shared_memory import \ + ReadOnlyConversationTokenDBBufferSharedMemory +from core.memory.read_only_conversation_token_db_string_buffer_shared_memory import \ + ReadOnlyConversationTokenDBStringBufferSharedMemory +from core.prompt.prompt_builder import PromptBuilder +from core.prompt.prompt_template import OutLinePromptTemplate +from core.prompt.prompts import MORE_LIKE_THIS_GENERATE_PROMPT +from models.model import App, AppModelConfig, Account, Conversation, Message + + +class Completion: + @classmethod + def generate(cls, task_id: str, app: App, app_model_config: AppModelConfig, query: str, inputs: dict, + user: Account, conversation: Optional[Conversation], streaming: bool, is_override: bool = False): + """ + errors: ProviderTokenNotInitError + """ + cls.validate_query_tokens(app.tenant_id, app_model_config, query) + + memory = None + if conversation: + # get memory of conversation (read-only) + memory = cls.get_memory_from_conversation( + tenant_id=app.tenant_id, + app_model_config=app_model_config, + conversation=conversation + ) + + inputs = conversation.inputs + + conversation_message_task = ConversationMessageTask( + task_id=task_id, + app=app, + app_model_config=app_model_config, + user=user, + conversation=conversation, + is_override=is_override, + inputs=inputs, + query=query, + streaming=streaming + ) + + # build main chain include agent + main_chain = MainChainBuilder.to_langchain_components( + tenant_id=app.tenant_id, + agent_mode=app_model_config.agent_mode_dict, + memory=ReadOnlyConversationTokenDBStringBufferSharedMemory(memory=memory) if memory else None, + conversation_message_task=conversation_message_task + ) + + chain_output = '' + if main_chain: + chain_output = main_chain.run(query) + + # run the final llm + try: + cls.run_final_llm( + tenant_id=app.tenant_id, + mode=app.mode, + app_model_config=app_model_config, + query=query, + inputs=inputs, + chain_output=chain_output, + conversation_message_task=conversation_message_task, + memory=memory, + streaming=streaming + ) + except ConversationTaskStoppedException: + return + + @classmethod + def run_final_llm(cls, tenant_id: str, mode: str, app_model_config: AppModelConfig, query: str, inputs: dict, + chain_output: str, + conversation_message_task: ConversationMessageTask, + memory: Optional[ReadOnlyConversationTokenDBBufferSharedMemory], streaming: bool): + final_llm = LLMBuilder.to_llm_from_model( + tenant_id=tenant_id, + model=app_model_config.model_dict, + streaming=streaming + ) + + # get llm prompt + prompt = cls.get_main_llm_prompt( + mode=mode, + llm=final_llm, + pre_prompt=app_model_config.pre_prompt, + query=query, + inputs=inputs, + chain_output=chain_output, + memory=memory + ) + + final_llm.callback_manager = cls.get_llm_callback_manager(final_llm, streaming, conversation_message_task) + + cls.recale_llm_max_tokens( + final_llm=final_llm, + prompt=prompt, + mode=mode + ) + + response = final_llm.generate([prompt]) + + return response + + @classmethod + def get_main_llm_prompt(cls, mode: str, llm: BaseLanguageModel, pre_prompt: str, query: str, inputs: dict, chain_output: Optional[str], + memory: Optional[ReadOnlyConversationTokenDBBufferSharedMemory]) -> \ + Union[str | List[BaseMessage]]: + pre_prompt = PromptBuilder.process_template(pre_prompt) if pre_prompt else pre_prompt + if mode == 'completion': + prompt_template = OutLinePromptTemplate.from_template( + template=("Use the following pieces of [CONTEXT] to answer the question at the end. " + "If you don't know the answer, " + "just say that you don't know, don't try to make up an answer. \n" + "```\n" + "[CONTEXT]\n" + "{context}\n" + "```\n" if chain_output else "") + + (pre_prompt + "\n" if pre_prompt else "") + + "{query}\n" + ) + + if chain_output: + inputs['context'] = chain_output + + prompt_inputs = {k: inputs[k] for k in prompt_template.input_variables if k in inputs} + prompt_content = prompt_template.format( + query=query, + **prompt_inputs + ) + + if isinstance(llm, BaseChatModel): + # use chat llm as completion model + return [HumanMessage(content=prompt_content)] + else: + return prompt_content + else: + messages: List[BaseMessage] = [] + + system_message = None + if pre_prompt: + # append pre prompt as system message + system_message = PromptBuilder.to_system_message(pre_prompt, inputs) + + if chain_output: + # append context as system message, currently only use simple stuff prompt + context_message = PromptBuilder.to_system_message( + """Use the following pieces of [CONTEXT] to answer the users question. +If you don't know the answer, just say that you don't know, don't try to make up an answer. +``` +[CONTEXT] +{context} +```""", + {'context': chain_output} + ) + + if not system_message: + system_message = context_message + else: + system_message.content = context_message.content + "\n\n" + system_message.content + + if system_message: + messages.append(system_message) + + human_inputs = { + "query": query + } + + # construct main prompt + human_message = PromptBuilder.to_human_message( + prompt_content="{query}", + inputs=human_inputs + ) + + if memory: + # append chat histories + tmp_messages = messages.copy() + [human_message] + curr_message_tokens = memory.llm.get_messages_tokens(tmp_messages) + rest_tokens = llm_constant.max_context_token_length[ + memory.llm.model_name] - memory.llm.max_tokens - curr_message_tokens + rest_tokens = max(rest_tokens, 0) + history_messages = cls.get_history_messages_from_memory(memory, rest_tokens) + messages += history_messages + + messages.append(human_message) + + return messages + + @classmethod + def get_llm_callback_manager(cls, llm: Union[StreamableOpenAI, StreamableChatOpenAI], + streaming: bool, conversation_message_task: ConversationMessageTask) -> CallbackManager: + llm_callback_handler = LLMCallbackHandler(llm, conversation_message_task) + if streaming: + callback_handlers = [llm_callback_handler, DifyStreamingStdOutCallbackHandler()] + else: + callback_handlers = [llm_callback_handler, DifyStdOutCallbackHandler()] + + return CallbackManager(callback_handlers) + + @classmethod + def get_history_messages_from_memory(cls, memory: ReadOnlyConversationTokenDBBufferSharedMemory, + max_token_limit: int) -> \ + List[BaseMessage]: + """Get memory messages.""" + memory.max_token_limit = max_token_limit + memory_key = memory.memory_variables[0] + external_context = memory.load_memory_variables({}) + return external_context[memory_key] + + @classmethod + def get_memory_from_conversation(cls, tenant_id: str, app_model_config: AppModelConfig, + conversation: Conversation, + **kwargs) -> ReadOnlyConversationTokenDBBufferSharedMemory: + # only for calc token in memory + memory_llm = LLMBuilder.to_llm_from_model( + tenant_id=tenant_id, + model=app_model_config.model_dict + ) + + # use llm config from conversation + memory = ReadOnlyConversationTokenDBBufferSharedMemory( + conversation=conversation, + llm=memory_llm, + max_token_limit=kwargs.get("max_token_limit", 2048), + memory_key=kwargs.get("memory_key", "chat_history"), + return_messages=kwargs.get("return_messages", True), + input_key=kwargs.get("input_key", "input"), + output_key=kwargs.get("output_key", "output"), + message_limit=kwargs.get("message_limit", 10), + ) + + return memory + + @classmethod + def validate_query_tokens(cls, tenant_id: str, app_model_config: AppModelConfig, query: str): + llm = LLMBuilder.to_llm_from_model( + tenant_id=tenant_id, + model=app_model_config.model_dict + ) + + model_limited_tokens = llm_constant.max_context_token_length[llm.model_name] + max_tokens = llm.max_tokens + + if model_limited_tokens - max_tokens - llm.get_num_tokens(query) < 0: + raise LLMBadRequestError("Query is too long") + + @classmethod + def recale_llm_max_tokens(cls, final_llm: Union[StreamableOpenAI, StreamableChatOpenAI], + prompt: Union[str, List[BaseMessage]], mode: str): + # recalc max_tokens if sum(prompt_token + max_tokens) over model token limit + model_limited_tokens = llm_constant.max_context_token_length[final_llm.model_name] + max_tokens = final_llm.max_tokens + + if mode == 'completion' and isinstance(final_llm, BaseLLM): + prompt_tokens = final_llm.get_num_tokens(prompt) + else: + prompt_tokens = final_llm.get_messages_tokens(prompt) + + if prompt_tokens + max_tokens > model_limited_tokens: + max_tokens = max(model_limited_tokens - prompt_tokens, 16) + final_llm.max_tokens = max_tokens + + @classmethod + def generate_more_like_this(cls, task_id: str, app: App, message: Message, pre_prompt: str, + app_model_config: AppModelConfig, user: Account, streaming: bool): + llm: StreamableOpenAI = LLMBuilder.to_llm( + tenant_id=app.tenant_id, + model_name='gpt-3.5-turbo', + streaming=streaming + ) + + # get llm prompt + original_prompt = cls.get_main_llm_prompt( + mode="completion", + llm=llm, + pre_prompt=pre_prompt, + query=message.query, + inputs=message.inputs, + chain_output=None, + memory=None + ) + + original_completion = message.answer.strip() + + prompt = MORE_LIKE_THIS_GENERATE_PROMPT + prompt = prompt.format(prompt=original_prompt, original_completion=original_completion) + + if isinstance(llm, BaseChatModel): + prompt = [HumanMessage(content=prompt)] + + conversation_message_task = ConversationMessageTask( + task_id=task_id, + app=app, + app_model_config=app_model_config, + user=user, + inputs=message.inputs, + query=message.query, + is_override=True if message.override_model_configs else False, + streaming=streaming + ) + + llm.callback_manager = cls.get_llm_callback_manager(llm, streaming, conversation_message_task) + + cls.recale_llm_max_tokens( + final_llm=llm, + prompt=prompt, + mode='completion' + ) + + llm.generate([prompt]) diff --git a/api/core/constant/llm_constant.py b/api/core/constant/llm_constant.py new file mode 100644 index 0000000000..6879ec5b06 --- /dev/null +++ b/api/core/constant/llm_constant.py @@ -0,0 +1,84 @@ +from _decimal import Decimal + +models = { + 'gpt-4': 'openai', # 8,192 tokens + 'gpt-4-32k': 'openai', # 32,768 tokens + 'gpt-3.5-turbo': 'openai', # 4,096 tokens + 'text-davinci-003': 'openai', # 4,097 tokens + 'text-davinci-002': 'openai', # 4,097 tokens + 'text-curie-001': 'openai', # 2,049 tokens + 'text-babbage-001': 'openai', # 2,049 tokens + 'text-ada-001': 'openai', # 2,049 tokens + 'text-embedding-ada-002': 'openai' # 8191 tokens, 1536 dimensions +} + +max_context_token_length = { + 'gpt-4': 8192, + 'gpt-4-32k': 32768, + 'gpt-3.5-turbo': 4096, + 'text-davinci-003': 4097, + 'text-davinci-002': 4097, + 'text-curie-001': 2049, + 'text-babbage-001': 2049, + 'text-ada-001': 2049, + 'text-embedding-ada-002': 8191 +} + +models_by_mode = { + 'chat': [ + 'gpt-4', # 8,192 tokens + 'gpt-4-32k', # 32,768 tokens + 'gpt-3.5-turbo', # 4,096 tokens + ], + 'completion': [ + 'gpt-4', # 8,192 tokens + 'gpt-4-32k', # 32,768 tokens + 'gpt-3.5-turbo', # 4,096 tokens + 'text-davinci-003', # 4,097 tokens + 'text-davinci-002' # 4,097 tokens + 'text-curie-001', # 2,049 tokens + 'text-babbage-001', # 2,049 tokens + 'text-ada-001' # 2,049 tokens + ], + 'embedding': [ + 'text-embedding-ada-002' # 8191 tokens, 1536 dimensions + ] +} + +model_currency = 'USD' + +model_prices = { + 'gpt-4': { + 'prompt': Decimal('0.03'), + 'completion': Decimal('0.06'), + }, + 'gpt-4-32k': { + 'prompt': Decimal('0.06'), + 'completion': Decimal('0.12') + }, + 'gpt-3.5-turbo': { + 'prompt': Decimal('0.002'), + 'completion': Decimal('0.002') + }, + 'text-davinci-003': { + 'prompt': Decimal('0.02'), + 'completion': Decimal('0.02') + }, + 'text-curie-001': { + 'prompt': Decimal('0.002'), + 'completion': Decimal('0.002') + }, + 'text-babbage-001': { + 'prompt': Decimal('0.0005'), + 'completion': Decimal('0.0005') + }, + 'text-ada-001': { + 'prompt': Decimal('0.0004'), + 'completion': Decimal('0.0004') + }, + 'text-embedding-ada-002': { + 'usage': Decimal('0.0004'), + } +} + +agent_model_name = 'text-davinci-003' diff --git a/api/core/conversation_message_task.py b/api/core/conversation_message_task.py new file mode 100644 index 0000000000..0df26637a3 --- /dev/null +++ b/api/core/conversation_message_task.py @@ -0,0 +1,388 @@ +import decimal +import json +from typing import Optional, Union + +from gunicorn.config import User + +from core.callback_handler.entity.agent_loop import AgentLoop +from core.callback_handler.entity.dataset_query import DatasetQueryObj +from core.callback_handler.entity.llm_message import LLMMessage +from core.callback_handler.entity.chain_result import ChainResult +from core.constant import llm_constant +from core.llm.llm_builder import LLMBuilder +from core.llm.provider.llm_provider_service import LLMProviderService +from core.prompt.prompt_builder import PromptBuilder +from core.prompt.prompt_template import OutLinePromptTemplate +from events.message_event import message_was_created +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DatasetQuery +from models.model import AppModelConfig, Conversation, Account, Message, EndUser, App, MessageAgentThought, MessageChain +from models.provider import ProviderType, Provider + + +class ConversationMessageTask: + def __init__(self, task_id: str, app: App, app_model_config: AppModelConfig, user: Account, + inputs: dict, query: str, streaming: bool, + conversation: Optional[Conversation] = None, is_override: bool = False): + self.task_id = task_id + + self.app = app + self.tenant_id = app.tenant_id + self.app_model_config = app_model_config + self.is_override = is_override + + self.user = user + self.inputs = inputs + self.query = query + self.streaming = streaming + + self.conversation = conversation + self.is_new_conversation = False + + self.message = None + + self.model_dict = self.app_model_config.model_dict + self.model_name = self.model_dict.get('name') + self.mode = app.mode + + self.init() + + self._pub_handler = PubHandler( + user=self.user, + task_id=self.task_id, + message=self.message, + conversation=self.conversation, + chain_pub=False, # disabled currently + agent_thought_pub=False # disabled currently + ) + + def init(self): + override_model_configs = None + if self.is_override: + override_model_configs = { + "model": self.app_model_config.model_dict, + "pre_prompt": self.app_model_config.pre_prompt, + "agent_mode": self.app_model_config.agent_mode_dict, + "opening_statement": self.app_model_config.opening_statement, + "suggested_questions": self.app_model_config.suggested_questions_list, + "suggested_questions_after_answer": self.app_model_config.suggested_questions_after_answer_dict, + "more_like_this": self.app_model_config.more_like_this_dict, + "user_input_form": self.app_model_config.user_input_form_list, + } + + introduction = '' + system_instruction = '' + system_instruction_tokens = 0 + if self.mode == 'chat': + introduction = self.app_model_config.opening_statement + if introduction: + prompt_template = OutLinePromptTemplate.from_template(template=PromptBuilder.process_template(introduction)) + prompt_inputs = {k: self.inputs[k] for k in prompt_template.input_variables if k in self.inputs} + introduction = prompt_template.format(**prompt_inputs) + + if self.app_model_config.pre_prompt: + pre_prompt = PromptBuilder.process_template(self.app_model_config.pre_prompt) + system_message = PromptBuilder.to_system_message(pre_prompt, self.inputs) + system_instruction = system_message.content + llm = LLMBuilder.to_llm(self.tenant_id, self.model_name) + system_instruction_tokens = llm.get_messages_tokens([system_message]) + + if not self.conversation: + self.is_new_conversation = True + self.conversation = Conversation( + app_id=self.app_model_config.app_id, + app_model_config_id=self.app_model_config.id, + model_provider=self.model_dict.get('provider'), + model_id=self.model_name, + override_model_configs=json.dumps(override_model_configs) if override_model_configs else None, + mode=self.mode, + name='', + inputs=self.inputs, + introduction=introduction, + system_instruction=system_instruction, + system_instruction_tokens=system_instruction_tokens, + status='normal', + from_source=('console' if isinstance(self.user, Account) else 'api'), + from_end_user_id=(self.user.id if isinstance(self.user, EndUser) else None), + from_account_id=(self.user.id if isinstance(self.user, Account) else None), + ) + + db.session.add(self.conversation) + db.session.flush() + + self.message = Message( + app_id=self.app_model_config.app_id, + model_provider=self.model_dict.get('provider'), + model_id=self.model_name, + override_model_configs=json.dumps(override_model_configs) if override_model_configs else None, + conversation_id=self.conversation.id, + inputs=self.inputs, + query=self.query, + message="", + message_tokens=0, + message_unit_price=0, + answer="", + answer_tokens=0, + answer_unit_price=0, + provider_response_latency=0, + total_price=0, + currency=llm_constant.model_currency, + from_source=('console' if isinstance(self.user, Account) else 'api'), + from_end_user_id=(self.user.id if isinstance(self.user, EndUser) else None), + from_account_id=(self.user.id if isinstance(self.user, Account) else None), + agent_based=self.app_model_config.agent_mode_dict.get('enabled'), + ) + + db.session.add(self.message) + db.session.flush() + + def append_message_text(self, text: str): + self._pub_handler.pub_text(text) + + def save_message(self, llm_message: LLMMessage, by_stopped: bool = False): + model_name = self.app_model_config.model_dict.get('name') + + message_tokens = llm_message.prompt_tokens + answer_tokens = llm_message.completion_tokens + message_unit_price = llm_constant.model_prices[model_name]['prompt'] + answer_unit_price = llm_constant.model_prices[model_name]['completion'] + + total_price = self.calc_total_price(message_tokens, message_unit_price, answer_tokens, answer_unit_price) + + self.message.message = llm_message.prompt + self.message.message_tokens = message_tokens + self.message.message_unit_price = message_unit_price + self.message.answer = llm_message.completion.strip() if llm_message.completion else '' + self.message.answer_tokens = answer_tokens + self.message.answer_unit_price = answer_unit_price + self.message.provider_response_latency = llm_message.latency + self.message.total_price = total_price + + self.update_provider_quota() + + db.session.commit() + + message_was_created.send( + self.message, + conversation=self.conversation, + is_first_message=self.is_new_conversation + ) + + if not by_stopped: + self._pub_handler.pub_end() + + def update_provider_quota(self): + llm_provider_service = LLMProviderService( + tenant_id=self.app.tenant_id, + provider_name=self.message.model_provider, + ) + + provider = llm_provider_service.get_provider_db_record() + if provider and provider.provider_type == ProviderType.SYSTEM.value: + db.session.query(Provider).filter( + Provider.tenant_id == self.app.tenant_id, + Provider.quota_limit > Provider.quota_used + ).update({'quota_used': Provider.quota_used + 1}) + + def init_chain(self, chain_result: ChainResult): + message_chain = MessageChain( + message_id=self.message.id, + type=chain_result.type, + input=json.dumps(chain_result.prompt), + output='' + ) + + db.session.add(message_chain) + db.session.flush() + + return message_chain + + def on_chain_end(self, message_chain: MessageChain, chain_result: ChainResult): + message_chain.output = json.dumps(chain_result.completion) + + self._pub_handler.pub_chain(message_chain) + + def on_agent_end(self, message_chain: MessageChain, agent_model_name: str, + agent_loop: AgentLoop): + agent_message_unit_price = llm_constant.model_prices[agent_model_name]['prompt'] + agent_answer_unit_price = llm_constant.model_prices[agent_model_name]['completion'] + + loop_message_tokens = agent_loop.prompt_tokens + loop_answer_tokens = agent_loop.completion_tokens + + loop_total_price = self.calc_total_price( + loop_message_tokens, + agent_message_unit_price, + loop_answer_tokens, + agent_answer_unit_price + ) + + message_agent_loop = MessageAgentThought( + message_id=self.message.id, + message_chain_id=message_chain.id, + position=agent_loop.position, + thought=agent_loop.thought, + tool=agent_loop.tool_name, + tool_input=agent_loop.tool_input, + observation=agent_loop.tool_output, + tool_process_data='', # currently not support + message=agent_loop.prompt, + message_token=loop_message_tokens, + message_unit_price=agent_message_unit_price, + answer=agent_loop.completion, + answer_token=loop_answer_tokens, + answer_unit_price=agent_answer_unit_price, + latency=agent_loop.latency, + tokens=agent_loop.prompt_tokens + agent_loop.completion_tokens, + total_price=loop_total_price, + currency=llm_constant.model_currency, + created_by_role=('account' if isinstance(self.user, Account) else 'end_user'), + created_by=self.user.id + ) + + db.session.add(message_agent_loop) + db.session.flush() + + self._pub_handler.pub_agent_thought(message_agent_loop) + + def on_dataset_query_end(self, dataset_query_obj: DatasetQueryObj): + dataset_query = DatasetQuery( + dataset_id=dataset_query_obj.dataset_id, + content=dataset_query_obj.query, + source='app', + source_app_id=self.app.id, + created_by_role=('account' if isinstance(self.user, Account) else 'end_user'), + created_by=self.user.id + ) + + db.session.add(dataset_query) + + def calc_total_price(self, message_tokens, message_unit_price, answer_tokens, answer_unit_price): + message_tokens_per_1k = (decimal.Decimal(message_tokens) / 1000).quantize(decimal.Decimal('0.001'), + rounding=decimal.ROUND_HALF_UP) + answer_tokens_per_1k = (decimal.Decimal(answer_tokens) / 1000).quantize(decimal.Decimal('0.001'), + rounding=decimal.ROUND_HALF_UP) + + total_price = message_tokens_per_1k * message_unit_price + answer_tokens_per_1k * answer_unit_price + return total_price.quantize(decimal.Decimal('0.0000001'), rounding=decimal.ROUND_HALF_UP) + + +class PubHandler: + def __init__(self, user: Union[Account | User], task_id: str, + message: Message, conversation: Conversation, + chain_pub: bool = False, agent_thought_pub: bool = False): + self._channel = PubHandler.generate_channel_name(user, task_id) + self._stopped_cache_key = PubHandler.generate_stopped_cache_key(user, task_id) + + self._task_id = task_id + self._message = message + self._conversation = conversation + self._chain_pub = chain_pub + self._agent_thought_pub = agent_thought_pub + + @classmethod + def generate_channel_name(cls, user: Union[Account | User], task_id: str): + user_str = 'account-' + user.id if isinstance(user, Account) else 'end-user-' + user.id + return "generate_result:{}-{}".format(user_str, task_id) + + @classmethod + def generate_stopped_cache_key(cls, user: Union[Account | User], task_id: str): + user_str = 'account-' + user.id if isinstance(user, Account) else 'end-user-' + user.id + return "generate_result_stopped:{}-{}".format(user_str, task_id) + + def pub_text(self, text: str): + content = { + 'event': 'message', + 'data': { + 'task_id': self._task_id, + 'message_id': self._message.id, + 'text': text, + 'mode': self._conversation.mode, + 'conversation_id': self._conversation.id + } + } + + redis_client.publish(self._channel, json.dumps(content)) + + if self._is_stopped(): + self.pub_end() + raise ConversationTaskStoppedException() + + def pub_chain(self, message_chain: MessageChain): + if self._chain_pub: + content = { + 'event': 'chain', + 'data': { + 'task_id': self._task_id, + 'message_id': self._message.id, + 'chain_id': message_chain.id, + 'type': message_chain.type, + 'input': json.loads(message_chain.input), + 'output': json.loads(message_chain.output), + 'mode': self._conversation.mode, + 'conversation_id': self._conversation.id + } + } + + redis_client.publish(self._channel, json.dumps(content)) + + if self._is_stopped(): + self.pub_end() + raise ConversationTaskStoppedException() + + def pub_agent_thought(self, message_agent_thought: MessageAgentThought): + if self._agent_thought_pub: + content = { + 'event': 'agent_thought', + 'data': { + 'task_id': self._task_id, + 'message_id': self._message.id, + 'chain_id': message_agent_thought.message_chain_id, + 'agent_thought_id': message_agent_thought.id, + 'position': message_agent_thought.position, + 'thought': message_agent_thought.thought, + 'tool': message_agent_thought.tool, + 'tool_input': message_agent_thought.tool_input, + 'observation': message_agent_thought.observation, + 'answer': message_agent_thought.answer, + 'mode': self._conversation.mode, + 'conversation_id': self._conversation.id + } + } + + redis_client.publish(self._channel, json.dumps(content)) + + if self._is_stopped(): + self.pub_end() + raise ConversationTaskStoppedException() + + + def pub_end(self): + content = { + 'event': 'end', + } + + redis_client.publish(self._channel, json.dumps(content)) + + @classmethod + def pub_error(cls, user: Union[Account | User], task_id: str, e): + content = { + 'error': type(e).__name__, + 'description': e.description if getattr(e, 'description', None) is not None else str(e) + } + + channel = cls.generate_channel_name(user, task_id) + redis_client.publish(channel, json.dumps(content)) + + def _is_stopped(self): + return redis_client.get(self._stopped_cache_key) is not None + + @classmethod + def stop(cls, user: Union[Account | User], task_id: str): + stopped_cache_key = cls.generate_stopped_cache_key(user, task_id) + redis_client.setex(stopped_cache_key, 600, 1) + + +class ConversationTaskStoppedException(Exception): + pass diff --git a/api/core/docstore/dataset_docstore.py b/api/core/docstore/dataset_docstore.py new file mode 100644 index 0000000000..b3b968532c --- /dev/null +++ b/api/core/docstore/dataset_docstore.py @@ -0,0 +1,190 @@ +from typing import Any, Dict, Optional, Sequence + +import tiktoken +from llama_index.data_structs import Node +from llama_index.docstore.types import BaseDocumentStore +from llama_index.docstore.utils import json_to_doc +from llama_index.schema import BaseDocument +from sqlalchemy import func + +from core.llm.token_calculator import TokenCalculator +from extensions.ext_database import db +from models.dataset import Dataset, DocumentSegment + + +class DatesetDocumentStore(BaseDocumentStore): + def __init__( + self, + dataset: Dataset, + user_id: str, + embedding_model_name: str, + document_id: Optional[str] = None, + ): + self._dataset = dataset + self._user_id = user_id + self._embedding_model_name = embedding_model_name + self._document_id = document_id + + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> "DatesetDocumentStore": + return cls(**config_dict) + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dict.""" + return { + "dataset_id": self._dataset.id, + } + + @property + def dateset_id(self) -> Any: + return self._dataset.id + + @property + def user_id(self) -> Any: + return self._user_id + + @property + def embedding_model_name(self) -> Any: + return self._embedding_model_name + + @property + def docs(self) -> Dict[str, BaseDocument]: + document_segments = db.session.query(DocumentSegment).filter( + DocumentSegment.dataset_id == self._dataset.id + ).all() + + output = {} + for document_segment in document_segments: + doc_id = document_segment.index_node_id + result = self.segment_to_dict(document_segment) + output[doc_id] = json_to_doc(result) + + return output + + def add_documents( + self, docs: Sequence[BaseDocument], allow_update: bool = True + ) -> None: + max_position = db.session.query(func.max(DocumentSegment.position)).filter( + DocumentSegment.document == self._document_id + ).scalar() + + if max_position is None: + max_position = 0 + + for doc in docs: + if doc.is_doc_id_none: + raise ValueError("doc_id not set") + + if not isinstance(doc, Node): + raise ValueError("doc must be a Node") + + segment_document = self.get_document(doc_id=doc.get_doc_id(), raise_error=False) + + # NOTE: doc could already exist in the store, but we overwrite it + if not allow_update and segment_document: + raise ValueError( + f"doc_id {doc.get_doc_id()} already exists. " + "Set allow_update to True to overwrite." + ) + + # calc embedding use tokens + tokens = TokenCalculator.get_num_tokens(self._embedding_model_name, doc.get_text()) + + if not segment_document: + max_position += 1 + + segment_document = DocumentSegment( + tenant_id=self._dataset.tenant_id, + dataset_id=self._dataset.id, + document_id=self._document_id, + index_node_id=doc.get_doc_id(), + index_node_hash=doc.get_doc_hash(), + position=max_position, + content=doc.get_text(), + word_count=len(doc.get_text()), + tokens=tokens, + created_by=self._user_id, + ) + db.session.add(segment_document) + else: + segment_document.content = doc.get_text() + segment_document.index_node_hash = doc.get_doc_hash() + segment_document.word_count = len(doc.get_text()) + segment_document.tokens = tokens + + db.session.commit() + + def document_exists(self, doc_id: str) -> bool: + """Check if document exists.""" + result = self.get_document_segment(doc_id) + return result is not None + + def get_document( + self, doc_id: str, raise_error: bool = True + ) -> Optional[BaseDocument]: + document_segment = self.get_document_segment(doc_id) + + if document_segment is None: + if raise_error: + raise ValueError(f"doc_id {doc_id} not found.") + else: + return None + + result = self.segment_to_dict(document_segment) + return json_to_doc(result) + + def delete_document(self, doc_id: str, raise_error: bool = True) -> None: + document_segment = self.get_document_segment(doc_id) + + if document_segment is None: + if raise_error: + raise ValueError(f"doc_id {doc_id} not found.") + else: + return None + + db.session.delete(document_segment) + db.session.commit() + + def set_document_hash(self, doc_id: str, doc_hash: str) -> None: + """Set the hash for a given doc_id.""" + document_segment = self.get_document_segment(doc_id) + + if document_segment is None: + return None + + document_segment.index_node_hash = doc_hash + db.session.commit() + + def get_document_hash(self, doc_id: str) -> Optional[str]: + """Get the stored hash for a document, if it exists.""" + document_segment = self.get_document_segment(doc_id) + + if document_segment is None: + return None + + return document_segment.index_node_hash + + def update_docstore(self, other: "BaseDocumentStore") -> None: + """Update docstore. + + Args: + other (BaseDocumentStore): docstore to update from + + """ + self.add_documents(list(other.docs.values())) + + def get_document_segment(self, doc_id: str) -> DocumentSegment: + document_segment = db.session.query(DocumentSegment).filter( + DocumentSegment.dataset_id == self._dataset.id, + DocumentSegment.index_node_id == doc_id + ).first() + + return document_segment + + def segment_to_dict(self, segment: DocumentSegment) -> Dict[str, Any]: + return { + "doc_id": segment.index_node_id, + "doc_hash": segment.index_node_hash, + "text": segment.content, + "__type__": Node.get_type() + } diff --git a/api/core/docstore/empty_docstore.py b/api/core/docstore/empty_docstore.py new file mode 100644 index 0000000000..e19f1824cb --- /dev/null +++ b/api/core/docstore/empty_docstore.py @@ -0,0 +1,51 @@ +from typing import Any, Dict, Optional, Sequence +from llama_index.docstore.types import BaseDocumentStore +from llama_index.schema import BaseDocument + + +class EmptyDocumentStore(BaseDocumentStore): + @classmethod + def from_dict(cls, config_dict: Dict[str, Any]) -> "EmptyDocumentStore": + return cls() + + def to_dict(self) -> Dict[str, Any]: + """Serialize to dict.""" + return {} + + @property + def docs(self) -> Dict[str, BaseDocument]: + return {} + + def add_documents( + self, docs: Sequence[BaseDocument], allow_update: bool = True + ) -> None: + pass + + def document_exists(self, doc_id: str) -> bool: + """Check if document exists.""" + return False + + def get_document( + self, doc_id: str, raise_error: bool = True + ) -> Optional[BaseDocument]: + return None + + def delete_document(self, doc_id: str, raise_error: bool = True) -> None: + pass + + def set_document_hash(self, doc_id: str, doc_hash: str) -> None: + """Set the hash for a given doc_id.""" + pass + + def get_document_hash(self, doc_id: str) -> Optional[str]: + """Get the stored hash for a document, if it exists.""" + return None + + def update_docstore(self, other: "BaseDocumentStore") -> None: + """Update docstore. + + Args: + other (BaseDocumentStore): docstore to update from + + """ + self.add_documents(list(other.docs.values())) diff --git a/api/core/embedding/openai_embedding.py b/api/core/embedding/openai_embedding.py new file mode 100644 index 0000000000..0938397423 --- /dev/null +++ b/api/core/embedding/openai_embedding.py @@ -0,0 +1,176 @@ +from typing import Optional, Any, List + +import openai +from llama_index.embeddings.base import BaseEmbedding +from llama_index.embeddings.openai import OpenAIEmbeddingMode, OpenAIEmbeddingModelType, _QUERY_MODE_MODEL_DICT, \ + _TEXT_MODE_MODEL_DICT +from tenacity import wait_random_exponential, retry, stop_after_attempt + +from core.llm.error_handle_wraps import handle_llm_exceptions, handle_llm_exceptions_async + + +@retry(reraise=True, wait=wait_random_exponential(min=1, max=20), stop=stop_after_attempt(6)) +def get_embedding( + text: str, + engine: Optional[str] = None, + openai_api_key: Optional[str] = None, +) -> List[float]: + """Get embedding. + + NOTE: Copied from OpenAI's embedding utils: + https://github.com/openai/openai-python/blob/main/openai/embeddings_utils.py + + Copied here to avoid importing unnecessary dependencies + like matplotlib, plotly, scipy, sklearn. + + """ + text = text.replace("\n", " ") + return openai.Embedding.create(input=[text], engine=engine, api_key=openai_api_key)["data"][0]["embedding"] + + +@retry(reraise=True, wait=wait_random_exponential(min=1, max=20), stop=stop_after_attempt(6)) +async def aget_embedding(text: str, engine: Optional[str] = None, openai_api_key: Optional[str] = None) -> List[float]: + """Asynchronously get embedding. + + NOTE: Copied from OpenAI's embedding utils: + https://github.com/openai/openai-python/blob/main/openai/embeddings_utils.py + + Copied here to avoid importing unnecessary dependencies + like matplotlib, plotly, scipy, sklearn. + + """ + # replace newlines, which can negatively affect performance. + text = text.replace("\n", " ") + + return (await openai.Embedding.acreate(input=[text], engine=engine, api_key=openai_api_key))["data"][0][ + "embedding" + ] + + +@retry(reraise=True, wait=wait_random_exponential(min=1, max=20), stop=stop_after_attempt(6)) +def get_embeddings( + list_of_text: List[str], + engine: Optional[str] = None, + openai_api_key: Optional[str] = None +) -> List[List[float]]: + """Get embeddings. + + NOTE: Copied from OpenAI's embedding utils: + https://github.com/openai/openai-python/blob/main/openai/embeddings_utils.py + + Copied here to avoid importing unnecessary dependencies + like matplotlib, plotly, scipy, sklearn. + + """ + assert len(list_of_text) <= 2048, "The batch size should not be larger than 2048." + + # replace newlines, which can negatively affect performance. + list_of_text = [text.replace("\n", " ") for text in list_of_text] + + data = openai.Embedding.create(input=list_of_text, engine=engine, api_key=openai_api_key).data + data = sorted(data, key=lambda x: x["index"]) # maintain the same order as input. + return [d["embedding"] for d in data] + + +@retry(reraise=True, wait=wait_random_exponential(min=1, max=20), stop=stop_after_attempt(6)) +async def aget_embeddings( + list_of_text: List[str], engine: Optional[str] = None, openai_api_key: Optional[str] = None +) -> List[List[float]]: + """Asynchronously get embeddings. + + NOTE: Copied from OpenAI's embedding utils: + https://github.com/openai/openai-python/blob/main/openai/embeddings_utils.py + + Copied here to avoid importing unnecessary dependencies + like matplotlib, plotly, scipy, sklearn. + + """ + assert len(list_of_text) <= 2048, "The batch size should not be larger than 2048." + + # replace newlines, which can negatively affect performance. + list_of_text = [text.replace("\n", " ") for text in list_of_text] + + data = (await openai.Embedding.acreate(input=list_of_text, engine=engine, api_key=openai_api_key)).data + data = sorted(data, key=lambda x: x["index"]) # maintain the same order as input. + return [d["embedding"] for d in data] + + +class OpenAIEmbedding(BaseEmbedding): + + def __init__( + self, + mode: str = OpenAIEmbeddingMode.TEXT_SEARCH_MODE, + model: str = OpenAIEmbeddingModelType.TEXT_EMBED_ADA_002, + deployment_name: Optional[str] = None, + openai_api_key: Optional[str] = None, + **kwargs: Any, + ) -> None: + """Init params.""" + super().__init__(**kwargs) + self.mode = OpenAIEmbeddingMode(mode) + self.model = OpenAIEmbeddingModelType(model) + self.deployment_name = deployment_name + self.openai_api_key = openai_api_key + + @handle_llm_exceptions + def _get_query_embedding(self, query: str) -> List[float]: + """Get query embedding.""" + if self.deployment_name is not None: + engine = self.deployment_name + else: + key = (self.mode, self.model) + if key not in _QUERY_MODE_MODEL_DICT: + raise ValueError(f"Invalid mode, model combination: {key}") + engine = _QUERY_MODE_MODEL_DICT[key] + return get_embedding(query, engine=engine, openai_api_key=self.openai_api_key) + + def _get_text_embedding(self, text: str) -> List[float]: + """Get text embedding.""" + if self.deployment_name is not None: + engine = self.deployment_name + else: + key = (self.mode, self.model) + if key not in _TEXT_MODE_MODEL_DICT: + raise ValueError(f"Invalid mode, model combination: {key}") + engine = _TEXT_MODE_MODEL_DICT[key] + return get_embedding(text, engine=engine, openai_api_key=self.openai_api_key) + + async def _aget_text_embedding(self, text: str) -> List[float]: + """Asynchronously get text embedding.""" + if self.deployment_name is not None: + engine = self.deployment_name + else: + key = (self.mode, self.model) + if key not in _TEXT_MODE_MODEL_DICT: + raise ValueError(f"Invalid mode, model combination: {key}") + engine = _TEXT_MODE_MODEL_DICT[key] + return await aget_embedding(text, engine=engine, openai_api_key=self.openai_api_key) + + def _get_text_embeddings(self, texts: List[str]) -> List[List[float]]: + """Get text embeddings. + + By default, this is a wrapper around _get_text_embedding. + Can be overriden for batch queries. + + """ + if self.deployment_name is not None: + engine = self.deployment_name + else: + key = (self.mode, self.model) + if key not in _TEXT_MODE_MODEL_DICT: + raise ValueError(f"Invalid mode, model combination: {key}") + engine = _TEXT_MODE_MODEL_DICT[key] + embeddings = get_embeddings(texts, engine=engine, openai_api_key=self.openai_api_key) + return embeddings + + async def _aget_text_embeddings(self, texts: List[str]) -> List[List[float]]: + """Asynchronously get text embeddings.""" + if self.deployment_name is not None: + engine = self.deployment_name + else: + key = (self.mode, self.model) + if key not in _TEXT_MODE_MODEL_DICT: + raise ValueError(f"Invalid mode, model combination: {key}") + engine = _TEXT_MODE_MODEL_DICT[key] + embeddings = await aget_embeddings(texts, engine=engine, openai_api_key=self.openai_api_key) + return embeddings diff --git a/api/core/generator/llm_generator.py b/api/core/generator/llm_generator.py new file mode 100644 index 0000000000..67e5753007 --- /dev/null +++ b/api/core/generator/llm_generator.py @@ -0,0 +1,120 @@ +import logging + +from langchain.chat_models.base import BaseChatModel +from langchain.schema import HumanMessage + +from core.constant import llm_constant +from core.llm.llm_builder import LLMBuilder +from core.llm.streamable_open_ai import StreamableOpenAI +from core.llm.token_calculator import TokenCalculator + +from core.prompt.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser +from core.prompt.prompt_template import OutLinePromptTemplate +from core.prompt.prompts import CONVERSATION_TITLE_PROMPT, CONVERSATION_SUMMARY_PROMPT, INTRODUCTION_GENERATE_PROMPT + + +# gpt-3.5-turbo works not well +generate_base_model = 'text-davinci-003' + + +class LLMGenerator: + @classmethod + def generate_conversation_name(cls, tenant_id: str, query, answer): + prompt = CONVERSATION_TITLE_PROMPT + prompt = prompt.format(query=query, answer=answer) + llm: StreamableOpenAI = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name=generate_base_model, + max_tokens=50 + ) + + if isinstance(llm, BaseChatModel): + prompt = [HumanMessage(content=prompt)] + + response = llm.generate([prompt]) + answer = response.generations[0][0].text + return answer.strip() + + @classmethod + def generate_conversation_summary(cls, tenant_id: str, messages): + max_tokens = 200 + + prompt = CONVERSATION_SUMMARY_PROMPT + prompt_with_empty_context = prompt.format(context='') + prompt_tokens = TokenCalculator.get_num_tokens(generate_base_model, prompt_with_empty_context) + rest_tokens = llm_constant.max_context_token_length[generate_base_model] - prompt_tokens - max_tokens + + context = '' + for message in messages: + if not message.answer: + continue + + message_qa_text = "Human:" + message.query + "\nAI:" + message.answer + "\n" + if rest_tokens - TokenCalculator.get_num_tokens(generate_base_model, context + message_qa_text) > 0: + context += message_qa_text + + prompt = prompt.format(context=context) + + llm: StreamableOpenAI = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name=generate_base_model, + max_tokens=max_tokens + ) + + if isinstance(llm, BaseChatModel): + prompt = [HumanMessage(content=prompt)] + + response = llm.generate([prompt]) + answer = response.generations[0][0].text + return answer.strip() + + @classmethod + def generate_introduction(cls, tenant_id: str, pre_prompt: str): + prompt = INTRODUCTION_GENERATE_PROMPT + prompt = prompt.format(prompt=pre_prompt) + + llm: StreamableOpenAI = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name=generate_base_model, + ) + + if isinstance(llm, BaseChatModel): + prompt = [HumanMessage(content=prompt)] + + response = llm.generate([prompt]) + answer = response.generations[0][0].text + return answer.strip() + + @classmethod + def generate_suggested_questions_after_answer(cls, tenant_id: str, histories: str): + output_parser = SuggestedQuestionsAfterAnswerOutputParser() + format_instructions = output_parser.get_format_instructions() + + prompt = OutLinePromptTemplate( + template="{histories}\n{format_instructions}\nquestions:\n", + input_variables=["histories"], + partial_variables={"format_instructions": format_instructions} + ) + + _input = prompt.format_prompt(histories=histories) + + llm: StreamableOpenAI = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name=generate_base_model, + temperature=0, + max_tokens=256 + ) + + if isinstance(llm, BaseChatModel): + query = [HumanMessage(content=_input.to_string())] + else: + query = _input.to_string() + + try: + output = llm(query) + questions = output_parser.parse(output) + except Exception: + logging.exception("Error generating suggested questions after answer") + questions = [] + + return questions diff --git a/api/core/index/index_builder.py b/api/core/index/index_builder.py new file mode 100644 index 0000000000..baf16b0f3a --- /dev/null +++ b/api/core/index/index_builder.py @@ -0,0 +1,45 @@ +from langchain.callbacks import CallbackManager +from llama_index import ServiceContext, PromptHelper, LLMPredictor +from core.callback_handler.std_out_callback_handler import DifyStdOutCallbackHandler +from core.embedding.openai_embedding import OpenAIEmbedding +from core.llm.llm_builder import LLMBuilder + + +class IndexBuilder: + @classmethod + def get_default_service_context(cls, tenant_id: str) -> ServiceContext: + # set number of output tokens + num_output = 512 + + # only for verbose + callback_manager = CallbackManager([DifyStdOutCallbackHandler()]) + + llm = LLMBuilder.to_llm( + tenant_id=tenant_id, + model_name='text-davinci-003', + temperature=0, + max_tokens=num_output, + callback_manager=callback_manager, + ) + + llm_predictor = LLMPredictor(llm=llm) + + # These parameters here will affect the logic of segmenting the final synthesized response. + # The number of refinement iterations in the synthesis process depends + # on whether the length of the segmented output exceeds the max_input_size. + prompt_helper = PromptHelper( + max_input_size=3500, + num_output=num_output, + max_chunk_overlap=20 + ) + + model_credentials = LLMBuilder.get_model_credentials( + tenant_id=tenant_id, + model_name='text-embedding-ada-002' + ) + + return ServiceContext.from_defaults( + llm_predictor=llm_predictor, + prompt_helper=prompt_helper, + embed_model=OpenAIEmbedding(**model_credentials), + ) diff --git a/api/core/index/keyword_table/jieba_keyword_table.py b/api/core/index/keyword_table/jieba_keyword_table.py new file mode 100644 index 0000000000..89dcca5802 --- /dev/null +++ b/api/core/index/keyword_table/jieba_keyword_table.py @@ -0,0 +1,159 @@ +import re +from typing import ( + Any, + Dict, + List, + Set, + Optional +) + +import jieba.analyse + +from core.index.keyword_table.stopwords import STOPWORDS +from llama_index.indices.query.base import IS +from llama_index import QueryMode +from llama_index.indices.base import QueryMap +from llama_index.indices.keyword_table.base import BaseGPTKeywordTableIndex +from llama_index.indices.keyword_table.query import BaseGPTKeywordTableQuery +from llama_index.docstore import BaseDocumentStore +from llama_index.indices.postprocessor.node import ( + BaseNodePostprocessor, +) +from llama_index.indices.response.response_builder import ResponseMode +from llama_index.indices.service_context import ServiceContext +from llama_index.optimization.optimizer import BaseTokenUsageOptimizer +from llama_index.prompts.prompts import ( + QuestionAnswerPrompt, + RefinePrompt, + SimpleInputPrompt, +) + +from core.index.query.synthesizer import EnhanceResponseSynthesizer + + +def jieba_extract_keywords( + text_chunk: str, + max_keywords: Optional[int] = None, + expand_with_subtokens: bool = True, +) -> Set[str]: + """Extract keywords with JIEBA tfidf.""" + keywords = jieba.analyse.extract_tags( + sentence=text_chunk, + topK=max_keywords, + ) + + if expand_with_subtokens: + return set(expand_tokens_with_subtokens(keywords)) + else: + return set(keywords) + + +def expand_tokens_with_subtokens(tokens: Set[str]) -> Set[str]: + """Get subtokens from a list of tokens., filtering for stopwords.""" + results = set() + for token in tokens: + results.add(token) + sub_tokens = re.findall(r"\w+", token) + if len(sub_tokens) > 1: + results.update({w for w in sub_tokens if w not in list(STOPWORDS)}) + + return results + + +class GPTJIEBAKeywordTableIndex(BaseGPTKeywordTableIndex): + """GPT JIEBA Keyword Table Index. + + This index uses a JIEBA keyword extractor to extract keywords from the text. + + """ + + def _extract_keywords(self, text: str) -> Set[str]: + """Extract keywords from text.""" + return jieba_extract_keywords(text, max_keywords=self.max_keywords_per_chunk) + + @classmethod + def get_query_map(self) -> QueryMap: + """Get query map.""" + super_map = super().get_query_map() + super_map[QueryMode.DEFAULT] = GPTKeywordTableJIEBAQuery + return super_map + + def _delete(self, doc_id: str, **delete_kwargs: Any) -> None: + """Delete a document.""" + # get set of ids that correspond to node + node_idxs_to_delete = {doc_id} + + # delete node_idxs from keyword to node idxs mapping + keywords_to_delete = set() + for keyword, node_idxs in self._index_struct.table.items(): + if node_idxs_to_delete.intersection(node_idxs): + self._index_struct.table[keyword] = node_idxs.difference( + node_idxs_to_delete + ) + if not self._index_struct.table[keyword]: + keywords_to_delete.add(keyword) + + for keyword in keywords_to_delete: + del self._index_struct.table[keyword] + + +class GPTKeywordTableJIEBAQuery(BaseGPTKeywordTableQuery): + """GPT Keyword Table Index JIEBA Query. + + Extracts keywords using JIEBA keyword extractor. + Set when `mode="jieba"` in `query` method of `GPTKeywordTableIndex`. + + .. code-block:: python + + response = index.query("", mode="jieba") + + See BaseGPTKeywordTableQuery for arguments. + + """ + + @classmethod + def from_args( + cls, + index_struct: IS, + service_context: ServiceContext, + docstore: Optional[BaseDocumentStore] = None, + node_postprocessors: Optional[List[BaseNodePostprocessor]] = None, + verbose: bool = False, + # response synthesizer args + response_mode: ResponseMode = ResponseMode.DEFAULT, + text_qa_template: Optional[QuestionAnswerPrompt] = None, + refine_template: Optional[RefinePrompt] = None, + simple_template: Optional[SimpleInputPrompt] = None, + response_kwargs: Optional[Dict] = None, + use_async: bool = False, + streaming: bool = False, + optimizer: Optional[BaseTokenUsageOptimizer] = None, + # class-specific args + **kwargs: Any, + ) -> "BaseGPTIndexQuery": + response_synthesizer = EnhanceResponseSynthesizer.from_args( + service_context=service_context, + text_qa_template=text_qa_template, + refine_template=refine_template, + simple_template=simple_template, + response_mode=response_mode, + response_kwargs=response_kwargs, + use_async=use_async, + streaming=streaming, + optimizer=optimizer, + ) + return cls( + index_struct=index_struct, + service_context=service_context, + response_synthesizer=response_synthesizer, + docstore=docstore, + node_postprocessors=node_postprocessors, + verbose=verbose, + **kwargs, + ) + + def _get_keywords(self, query_str: str) -> List[str]: + """Extract keywords.""" + return list( + jieba_extract_keywords(query_str, max_keywords=self.max_keywords_per_query) + ) diff --git a/api/core/index/keyword_table/stopwords.py b/api/core/index/keyword_table/stopwords.py new file mode 100644 index 0000000000..c616a15cf0 --- /dev/null +++ b/api/core/index/keyword_table/stopwords.py @@ -0,0 +1,90 @@ +STOPWORDS = { + "during", "when", "but", "then", "further", "isn", "mustn't", "until", "own", "i", "couldn", "y", "only", "you've", + "ours", "who", "where", "ourselves", "has", "to", "was", "didn't", "themselves", "if", "against", "through", "her", + "an", "your", "can", "those", "didn", "about", "aren't", "shan't", "be", "not", "these", "again", "so", "t", + "theirs", "weren", "won't", "won", "itself", "just", "same", "while", "why", "doesn", "aren", "him", "haven", + "for", "you'll", "that", "we", "am", "d", "by", "having", "wasn't", "than", "weren't", "out", "from", "now", + "their", "too", "hadn", "o", "needn", "most", "it", "under", "needn't", "any", "some", "few", "ll", "hers", "which", + "m", "you're", "off", "other", "had", "she", "you'd", "do", "you", "does", "s", "will", "each", "wouldn't", "hasn't", + "such", "more", "whom", "she's", "my", "yours", "yourself", "of", "on", "very", "hadn't", "with", "yourselves", + "been", "ma", "them", "mightn't", "shan", "mustn", "they", "what", "both", "that'll", "how", "is", "he", "because", + "down", "haven't", "are", "no", "it's", "our", "being", "the", "or", "above", "myself", "once", "don't", "doesn't", + "as", "nor", "here", "herself", "hasn", "mightn", "have", "its", "all", "were", "ain", "this", "at", "after", + "over", "shouldn't", "into", "before", "don", "wouldn", "re", "couldn't", "wasn", "in", "should", "there", + "himself", "isn't", "should've", "doing", "ve", "shouldn", "a", "did", "and", "his", "between", "me", "up", "below", + "人民", "末##末", "啊", "阿", "哎", "哎呀", "哎哟", "唉", "俺", "俺们", "按", "按照", "吧", "吧哒", "把", "罢了", "被", "本", + "本着", "比", "比方", "比如", "鄙人", "彼", "彼此", "边", "别", "别的", "别说", "并", "并且", "不比", "不成", "不单", "不但", + "不独", "不管", "不光", "不过", "不仅", "不拘", "不论", "不怕", "不然", "不如", "不特", "不惟", "不问", "不只", "朝", "朝着", + "趁", "趁着", "乘", "冲", "除", "除此之外", "除非", "除了", "此", "此间", "此外", "从", "从而", "打", "待", "但", "但是", "当", + "当着", "到", "得", "的", "的话", "等", "等等", "地", "第", "叮咚", "对", "对于", "多", "多少", "而", "而况", "而且", "而是", + "而外", "而言", "而已", "尔后", "反过来", "反过来说", "反之", "非但", "非徒", "否则", "嘎", "嘎登", "该", "赶", "个", "各", + "各个", "各位", "各种", "各自", "给", "根据", "跟", "故", "故此", "固然", "关于", "管", "归", "果然", "果真", "过", "哈", + "哈哈", "呵", "和", "何", "何处", "何况", "何时", "嘿", "哼", "哼唷", "呼哧", "乎", "哗", "还是", "还有", "换句话说", "换言之", + "或", "或是", "或者", "极了", "及", "及其", "及至", "即", "即便", "即或", "即令", "即若", "即使", "几", "几时", "己", "既", + "既然", "既是", "继而", "加之", "假如", "假若", "假使", "鉴于", "将", "较", "较之", "叫", "接着", "结果", "借", "紧接着", + "进而", "尽", "尽管", "经", "经过", "就", "就是", "就是说", "据", "具体地说", "具体说来", "开始", "开外", "靠", "咳", "可", + "可见", "可是", "可以", "况且", "啦", "来", "来着", "离", "例如", "哩", "连", "连同", "两者", "了", "临", "另", "另外", + "另一方面", "论", "嘛", "吗", "慢说", "漫说", "冒", "么", "每", "每当", "们", "莫若", "某", "某个", "某些", "拿", "哪", + "哪边", "哪儿", "哪个", "哪里", "哪年", "哪怕", "哪天", "哪些", "哪样", "那", "那边", "那儿", "那个", "那会儿", "那里", "那么", + "那么些", "那么样", "那时", "那些", "那样", "乃", "乃至", "呢", "能", "你", "你们", "您", "宁", "宁可", "宁肯", "宁愿", "哦", + "呕", "啪达", "旁人", "呸", "凭", "凭借", "其", "其次", "其二", "其他", "其它", "其一", "其余", "其中", "起", "起见", "岂但", + "恰恰相反", "前后", "前者", "且", "然而", "然后", "然则", "让", "人家", "任", "任何", "任凭", "如", "如此", "如果", "如何", + "如其", "如若", "如上所述", "若", "若非", "若是", "啥", "上下", "尚且", "设若", "设使", "甚而", "甚么", "甚至", "省得", "时候", + "什么", "什么样", "使得", "是", "是的", "首先", "谁", "谁知", "顺", "顺着", "似的", "虽", "虽然", "虽说", "虽则", "随", "随着", + "所", "所以", "他", "他们", "他人", "它", "它们", "她", "她们", "倘", "倘或", "倘然", "倘若", "倘使", "腾", "替", "通过", "同", + "同时", "哇", "万一", "往", "望", "为", "为何", "为了", "为什么", "为着", "喂", "嗡嗡", "我", "我们", "呜", "呜呼", "乌乎", + "无论", "无宁", "毋宁", "嘻", "吓", "相对而言", "像", "向", "向着", "嘘", "呀", "焉", "沿", "沿着", "要", "要不", "要不然", + "要不是", "要么", "要是", "也", "也罢", "也好", "一", "一般", "一旦", "一方面", "一来", "一切", "一样", "一则", "依", "依照", + "矣", "以", "以便", "以及", "以免", "以至", "以至于", "以致", "抑或", "因", "因此", "因而", "因为", "哟", "用", "由", + "由此可见", "由于", "有", "有的", "有关", "有些", "又", "于", "于是", "于是乎", "与", "与此同时", "与否", "与其", "越是", + "云云", "哉", "再说", "再者", "在", "在下", "咱", "咱们", "则", "怎", "怎么", "怎么办", "怎么样", "怎样", "咋", "照", "照着", + "者", "这", "这边", "这儿", "这个", "这会儿", "这就是说", "这里", "这么", "这么点儿", "这么些", "这么样", "这时", "这些", "这样", + "正如", "吱", "之", "之类", "之所以", "之一", "只是", "只限", "只要", "只有", "至", "至于", "诸位", "着", "着呢", "自", "自从", + "自个儿", "自各儿", "自己", "自家", "自身", "综上所述", "总的来看", "总的来说", "总的说来", "总而言之", "总之", "纵", "纵令", + "纵然", "纵使", "遵照", "作为", "兮", "呃", "呗", "咚", "咦", "喏", "啐", "喔唷", "嗬", "嗯", "嗳", "~", "!", ".", ":", + "\"", "'", "(", ")", "*", "A", "白", "社会主义", "--", "..", ">>", " [", " ]", "", "<", ">", "/", "\\", "|", "-", "_", + "+", "=", "&", "^", "%", "#", "@", "`", ";", "$", "(", ")", "——", "—", "¥", "·", "...", "‘", "’", "〉", "〈", "…", + " ", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "二", + "三", "四", "五", "六", "七", "八", "九", "零", ">", "<", "@", "#", "$", "%", "︿", "&", "*", "+", "~", "|", "[", + "]", "{", "}", "啊哈", "啊呀", "啊哟", "挨次", "挨个", "挨家挨户", "挨门挨户", "挨门逐户", "挨着", "按理", "按期", "按时", + "按说", "暗地里", "暗中", "暗自", "昂然", "八成", "白白", "半", "梆", "保管", "保险", "饱", "背地里", "背靠背", "倍感", "倍加", + "本人", "本身", "甭", "比起", "比如说", "比照", "毕竟", "必", "必定", "必将", "必须", "便", "别人", "并非", "并肩", "并没", + "并没有", "并排", "并无", "勃然", "不", "不必", "不常", "不大", "不但...而且", "不得", "不得不", "不得了", "不得已", "不迭", + "不定", "不对", "不妨", "不管怎样", "不会", "不仅...而且", "不仅仅", "不仅仅是", "不经意", "不可开交", "不可抗拒", "不力", "不了", + "不料", "不满", "不免", "不能不", "不起", "不巧", "不然的话", "不日", "不少", "不胜", "不时", "不是", "不同", "不能", "不要", + "不外", "不外乎", "不下", "不限", "不消", "不已", "不亦乐乎", "不由得", "不再", "不择手段", "不怎么", "不曾", "不知不觉", "不止", + "不止一次", "不至于", "才", "才能", "策略地", "差不多", "差一点", "常", "常常", "常言道", "常言说", "常言说得好", "长此下去", + "长话短说", "长期以来", "长线", "敞开儿", "彻夜", "陈年", "趁便", "趁机", "趁热", "趁势", "趁早", "成年", "成年累月", "成心", + "乘机", "乘胜", "乘势", "乘隙", "乘虚", "诚然", "迟早", "充分", "充其极", "充其量", "抽冷子", "臭", "初", "出", "出来", "出去", + "除此", "除此而外", "除此以外", "除开", "除去", "除却", "除外", "处处", "川流不息", "传", "传说", "传闻", "串行", "纯", "纯粹", + "此后", "此中", "次第", "匆匆", "从不", "从此", "从此以后", "从古到今", "从古至今", "从今以后", "从宽", "从来", "从轻", "从速", + "从头", "从未", "从无到有", "从小", "从新", "从严", "从优", "从早到晚", "从中", "从重", "凑巧", "粗", "存心", "达旦", "打从", + "打开天窗说亮话", "大", "大不了", "大大", "大抵", "大都", "大多", "大凡", "大概", "大家", "大举", "大略", "大面儿上", "大事", + "大体", "大体上", "大约", "大张旗鼓", "大致", "呆呆地", "带", "殆", "待到", "单", "单纯", "单单", "但愿", "弹指之间", "当场", + "当儿", "当即", "当口儿", "当然", "当庭", "当头", "当下", "当真", "当中", "倒不如", "倒不如说", "倒是", "到处", "到底", "到了儿", + "到目前为止", "到头", "到头来", "得起", "得天独厚", "的确", "等到", "叮当", "顶多", "定", "动不动", "动辄", "陡然", "都", "独", + "独自", "断然", "顿时", "多次", "多多", "多多少少", "多多益善", "多亏", "多年来", "多年前", "而后", "而论", "而又", "尔等", + "二话不说", "二话没说", "反倒", "反倒是", "反而", "反手", "反之亦然", "反之则", "方", "方才", "方能", "放量", "非常", "非得", + "分期", "分期分批", "分头", "奋勇", "愤然", "风雨无阻", "逢", "弗", "甫", "嘎嘎", "该当", "概", "赶快", "赶早不赶晚", "敢", + "敢情", "敢于", "刚", "刚才", "刚好", "刚巧", "高低", "格外", "隔日", "隔夜", "个人", "各式", "更", "更加", "更进一步", "更为", + "公然", "共", "共总", "够瞧的", "姑且", "古来", "故而", "故意", "固", "怪", "怪不得", "惯常", "光", "光是", "归根到底", + "归根结底", "过于", "毫不", "毫无", "毫无保留地", "毫无例外", "好在", "何必", "何尝", "何妨", "何苦", "何乐而不为", "何须", + "何止", "很", "很多", "很少", "轰然", "后来", "呼啦", "忽地", "忽然", "互", "互相", "哗啦", "话说", "还", "恍然", "会", "豁然", + "活", "伙同", "或多或少", "或许", "基本", "基本上", "基于", "极", "极大", "极度", "极端", "极力", "极其", "极为", "急匆匆", + "即将", "即刻", "即是说", "几度", "几番", "几乎", "几经", "既...又", "继之", "加上", "加以", "间或", "简而言之", "简言之", + "简直", "见", "将才", "将近", "将要", "交口", "较比", "较为", "接连不断", "接下来", "皆可", "截然", "截至", "藉以", "借此", + "借以", "届时", "仅", "仅仅", "谨", "进来", "进去", "近", "近几年来", "近来", "近年来", "尽管如此", "尽可能", "尽快", "尽量", + "尽然", "尽如人意", "尽心竭力", "尽心尽力", "尽早", "精光", "经常", "竟", "竟然", "究竟", "就此", "就地", "就算", "居然", "局外", + "举凡", "据称", "据此", "据实", "据说", "据我所知", "据悉", "具体来说", "决不", "决非", "绝", "绝不", "绝顶", "绝对", "绝非", + "均", "喀", "看", "看来", "看起来", "看上去", "看样子", "可好", "可能", "恐怕", "快", "快要", "来不及", "来得及", "来讲", + "来看", "拦腰", "牢牢", "老", "老大", "老老实实", "老是", "累次", "累年", "理当", "理该", "理应", "历", "立", "立地", "立刻", + "立马", "立时", "联袂", "连连", "连日", "连日来", "连声", "连袂", "临到", "另方面", "另行", "另一个", "路经", "屡", "屡次", + "屡次三番", "屡屡", "缕缕", "率尔", "率然", "略", "略加", "略微", "略为", "论说", "马上", "蛮", "满", "没", "没有", "每逢", + "每每", "每时每刻", "猛然", "猛然间", "莫", "莫不", "莫非", "莫如", "默默地", "默然", "呐", "那末", "奈", "难道", "难得", "难怪", + "难说", "内", "年复一年", "凝神", "偶而", "偶尔", "怕", "砰", "碰巧", "譬如", "偏偏", "乒", "平素", "颇", "迫于", "扑通", + "其后", "其实", "奇", "齐", "起初", "起来", "起首", "起头", "起先", "岂", "岂非", "岂止", "迄", "恰逢", "恰好", "恰恰", "恰巧", + "恰如", "恰似", "千", "千万", "千万千万", "切", "切不可", "切莫", "切切", "切勿", "窃", "亲口", "亲身", "亲手", "亲眼", "亲自", + "顷", "顷刻", "顷刻间", "顷刻之间", "请勿", "穷年累月", "取道", "去", "权时", "全都", "全力", "全年", "全然", "全身心", "然", + "人人", "仍", "仍旧", "仍然", "日复一日", "日见", "日渐", "日益", "日臻", "如常", "如此等等", "如次", "如今", "如期", "如前所述", + "如上", "如下", "汝", "三番两次", "三番五次", "三天两头", "瑟瑟", "沙沙", "上", "上来", "上去", "一个", "月", "日", "\n" +} diff --git a/api/core/index/keyword_table_index.py b/api/core/index/keyword_table_index.py new file mode 100644 index 0000000000..f0b3905557 --- /dev/null +++ b/api/core/index/keyword_table_index.py @@ -0,0 +1,135 @@ +import json +from typing import List, Optional + +from llama_index import ServiceContext, LLMPredictor, OpenAIEmbedding +from llama_index.data_structs import KeywordTable, Node +from llama_index.indices.keyword_table.base import BaseGPTKeywordTableIndex +from llama_index.indices.registry import load_index_struct_from_dict + +from core.docstore.dataset_docstore import DatesetDocumentStore +from core.docstore.empty_docstore import EmptyDocumentStore +from core.index.index_builder import IndexBuilder +from core.index.keyword_table.jieba_keyword_table import GPTJIEBAKeywordTableIndex +from core.llm.llm_builder import LLMBuilder +from extensions.ext_database import db +from models.dataset import Dataset, DatasetKeywordTable, DocumentSegment + + +class KeywordTableIndex: + + def __init__(self, dataset: Dataset): + self._dataset = dataset + + def add_nodes(self, nodes: List[Node]): + llm = LLMBuilder.to_llm( + tenant_id=self._dataset.tenant_id, + model_name='fake' + ) + + service_context = ServiceContext.from_defaults( + llm_predictor=LLMPredictor(llm=llm), + embed_model=OpenAIEmbedding() + ) + + dataset_keyword_table = self.get_keyword_table() + if not dataset_keyword_table or not dataset_keyword_table.keyword_table_dict: + index_struct = KeywordTable() + else: + index_struct_dict = dataset_keyword_table.keyword_table_dict + index_struct: KeywordTable = load_index_struct_from_dict(index_struct_dict) + + # create index + index = GPTJIEBAKeywordTableIndex( + index_struct=index_struct, + docstore=EmptyDocumentStore(), + service_context=service_context + ) + + for node in nodes: + keywords = index._extract_keywords(node.get_text()) + self.update_segment_keywords(node.doc_id, list(keywords)) + index._index_struct.add_node(list(keywords), node) + + index_struct_dict = index.index_struct.to_dict() + + if not dataset_keyword_table: + dataset_keyword_table = DatasetKeywordTable( + dataset_id=self._dataset.id, + keyword_table=json.dumps(index_struct_dict) + ) + db.session.add(dataset_keyword_table) + else: + dataset_keyword_table.keyword_table = json.dumps(index_struct_dict) + + db.session.commit() + + def del_nodes(self, node_ids: List[str]): + llm = LLMBuilder.to_llm( + tenant_id=self._dataset.tenant_id, + model_name='fake' + ) + + service_context = ServiceContext.from_defaults( + llm_predictor=LLMPredictor(llm=llm), + embed_model=OpenAIEmbedding() + ) + + dataset_keyword_table = self.get_keyword_table() + if not dataset_keyword_table or not dataset_keyword_table.keyword_table_dict: + return + else: + index_struct_dict = dataset_keyword_table.keyword_table_dict + index_struct: KeywordTable = load_index_struct_from_dict(index_struct_dict) + + # create index + index = GPTJIEBAKeywordTableIndex( + index_struct=index_struct, + docstore=EmptyDocumentStore(), + service_context=service_context + ) + + for node_id in node_ids: + index.delete(node_id) + + index_struct_dict = index.index_struct.to_dict() + + if not dataset_keyword_table: + dataset_keyword_table = DatasetKeywordTable( + dataset_id=self._dataset.id, + keyword_table=json.dumps(index_struct_dict) + ) + db.session.add(dataset_keyword_table) + else: + dataset_keyword_table.keyword_table = json.dumps(index_struct_dict) + + db.session.commit() + + @property + def query_index(self) -> Optional[BaseGPTKeywordTableIndex]: + docstore = DatesetDocumentStore( + dataset=self._dataset, + user_id=self._dataset.created_by, + embedding_model_name="text-embedding-ada-002" + ) + + service_context = IndexBuilder.get_default_service_context(tenant_id=self._dataset.tenant_id) + + dataset_keyword_table = self.get_keyword_table() + if not dataset_keyword_table or not dataset_keyword_table.keyword_table_dict: + return None + + index_struct: KeywordTable = load_index_struct_from_dict(dataset_keyword_table.keyword_table_dict) + + return GPTJIEBAKeywordTableIndex(index_struct=index_struct, docstore=docstore, service_context=service_context) + + def get_keyword_table(self): + dataset_keyword_table = self._dataset.dataset_keyword_table + if dataset_keyword_table: + return dataset_keyword_table + return None + + def update_segment_keywords(self, node_id: str, keywords: List[str]): + document_segment = db.session.query(DocumentSegment).filter(DocumentSegment.index_node_id == node_id).first() + if document_segment: + document_segment.keywords = keywords + db.session.commit() diff --git a/api/core/index/query/synthesizer.py b/api/core/index/query/synthesizer.py new file mode 100644 index 0000000000..7ab8b4a8ca --- /dev/null +++ b/api/core/index/query/synthesizer.py @@ -0,0 +1,79 @@ +from typing import ( + Any, + Dict, + Optional, Sequence, +) + +from llama_index.indices.response.response_synthesis import ResponseSynthesizer +from llama_index.indices.response.response_builder import ResponseMode, BaseResponseBuilder, get_response_builder +from llama_index.indices.service_context import ServiceContext +from llama_index.optimization.optimizer import BaseTokenUsageOptimizer +from llama_index.prompts.prompts import ( + QuestionAnswerPrompt, + RefinePrompt, + SimpleInputPrompt, +) +from llama_index.types import RESPONSE_TEXT_TYPE + + +class EnhanceResponseSynthesizer(ResponseSynthesizer): + @classmethod + def from_args( + cls, + service_context: ServiceContext, + streaming: bool = False, + use_async: bool = False, + text_qa_template: Optional[QuestionAnswerPrompt] = None, + refine_template: Optional[RefinePrompt] = None, + simple_template: Optional[SimpleInputPrompt] = None, + response_mode: ResponseMode = ResponseMode.DEFAULT, + response_kwargs: Optional[Dict] = None, + optimizer: Optional[BaseTokenUsageOptimizer] = None, + ) -> "ResponseSynthesizer": + response_builder: Optional[BaseResponseBuilder] = None + if response_mode != ResponseMode.NO_TEXT: + if response_mode == 'no_synthesizer': + response_builder = NoSynthesizer( + service_context=service_context, + simple_template=simple_template, + streaming=streaming, + ) + else: + response_builder = get_response_builder( + service_context, + text_qa_template, + refine_template, + simple_template, + response_mode, + use_async=use_async, + streaming=streaming, + ) + return cls(response_builder, response_mode, response_kwargs, optimizer) + + +class NoSynthesizer(BaseResponseBuilder): + def __init__( + self, + service_context: ServiceContext, + simple_template: Optional[SimpleInputPrompt] = None, + streaming: bool = False, + ) -> None: + super().__init__(service_context, streaming) + + async def aget_response( + self, + query_str: str, + text_chunks: Sequence[str], + prev_response: Optional[str] = None, + **response_kwargs: Any, + ) -> RESPONSE_TEXT_TYPE: + return "\n".join(text_chunks) + + def get_response( + self, + query_str: str, + text_chunks: Sequence[str], + prev_response: Optional[str] = None, + **response_kwargs: Any, + ) -> RESPONSE_TEXT_TYPE: + return "\n".join(text_chunks) \ No newline at end of file diff --git a/api/core/index/readers/html_parser.py b/api/core/index/readers/html_parser.py new file mode 100644 index 0000000000..2afadb284c --- /dev/null +++ b/api/core/index/readers/html_parser.py @@ -0,0 +1,22 @@ +from pathlib import Path +from typing import Dict + +from bs4 import BeautifulSoup +from llama_index.readers.file.base_parser import BaseParser + + +class HTMLParser(BaseParser): + """HTML parser.""" + + def _init_parser(self) -> Dict: + """Init parser.""" + return {} + + def parse_file(self, file: Path, errors: str = "ignore") -> str: + """Parse file.""" + with open(file, "rb") as fp: + soup = BeautifulSoup(fp, 'html.parser') + text = soup.get_text() + text = text.strip() if text else '' + + return text diff --git a/api/core/index/readers/pdf_parser.py b/api/core/index/readers/pdf_parser.py new file mode 100644 index 0000000000..81c4840c60 --- /dev/null +++ b/api/core/index/readers/pdf_parser.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Dict + +from flask import current_app +from llama_index.readers.file.base_parser import BaseParser +from pypdf import PdfReader + +from extensions.ext_storage import storage +from models.model import UploadFile + + +class PDFParser(BaseParser): + """PDF parser.""" + + def _init_parser(self) -> Dict: + """Init parser.""" + return {} + + def parse_file(self, file: Path, errors: str = "ignore") -> str: + """Parse file.""" + if not current_app.config.get('PDF_PREVIEW', True): + return '' + + plaintext_file_key = '' + plaintext_file_exists = False + if self._parser_config and 'upload_file' in self._parser_config and self._parser_config['upload_file']: + upload_file: UploadFile = self._parser_config['upload_file'] + if upload_file.hash: + plaintext_file_key = 'upload_files/' + upload_file.tenant_id + '/' + upload_file.hash + '.plaintext' + try: + text = storage.load(plaintext_file_key).decode('utf-8') + plaintext_file_exists = True + return text + except FileNotFoundError: + pass + + text_list = [] + with open(file, "rb") as fp: + # Create a PDF object + pdf = PdfReader(fp) + + # Get the number of pages in the PDF document + num_pages = len(pdf.pages) + + # Iterate over every page + for page in range(num_pages): + # Extract the text from the page + page_text = pdf.pages[page].extract_text() + text_list.append(page_text) + text = "\n".join(text_list) + + # save plaintext file for caching + if not plaintext_file_exists and plaintext_file_key: + storage.save(plaintext_file_key, text.encode('utf-8')) + + return text diff --git a/api/core/index/vector_index.py b/api/core/index/vector_index.py new file mode 100644 index 0000000000..f9d8542a8c --- /dev/null +++ b/api/core/index/vector_index.py @@ -0,0 +1,136 @@ +import json +import logging +from typing import List, Optional + +from llama_index.data_structs import Node +from requests import ReadTimeout +from sqlalchemy.exc import IntegrityError +from tenacity import retry, stop_after_attempt, retry_if_exception_type + +from core.index.index_builder import IndexBuilder +from core.vector_store.base import BaseGPTVectorStoreIndex +from extensions.ext_vector_store import vector_store +from extensions.ext_database import db +from models.dataset import Dataset, Embedding + + +class VectorIndex: + + def __init__(self, dataset: Dataset): + self._dataset = dataset + + def add_nodes(self, nodes: List[Node], duplicate_check: bool = False): + if not self._dataset.index_struct_dict: + index_id = "Vector_index_" + self._dataset.id.replace("-", "_") + self._dataset.index_struct = json.dumps(vector_store.to_index_struct(index_id)) + db.session.commit() + + service_context = IndexBuilder.get_default_service_context(tenant_id=self._dataset.tenant_id) + + index = vector_store.get_index( + service_context=service_context, + index_struct=self._dataset.index_struct_dict + ) + + if duplicate_check: + nodes = self._filter_duplicate_nodes(index, nodes) + + embedding_queue_nodes = [] + embedded_nodes = [] + for node in nodes: + node_hash = node.doc_hash + + # if node hash in cached embedding tables, use cached embedding + embedding = db.session.query(Embedding).filter_by(hash=node_hash).first() + if embedding: + node.embedding = embedding.get_embedding() + embedded_nodes.append(node) + else: + embedding_queue_nodes.append(node) + + if embedding_queue_nodes: + embedding_results = index._get_node_embedding_results( + embedding_queue_nodes, + set(), + ) + + # pre embed nodes for cached embedding + for embedding_result in embedding_results: + node = embedding_result.node + node.embedding = embedding_result.embedding + + try: + embedding = Embedding(hash=node.doc_hash) + embedding.set_embedding(node.embedding) + db.session.add(embedding) + db.session.commit() + except IntegrityError: + db.session.rollback() + continue + except: + logging.exception('Failed to add embedding to db') + continue + + embedded_nodes.append(node) + + self.index_insert_nodes(index, embedded_nodes) + + @retry(reraise=True, retry=retry_if_exception_type(ReadTimeout), stop=stop_after_attempt(3)) + def index_insert_nodes(self, index: BaseGPTVectorStoreIndex, nodes: List[Node]): + index.insert_nodes(nodes) + + def del_nodes(self, node_ids: List[str]): + if not self._dataset.index_struct_dict: + return + + service_context = IndexBuilder.get_default_service_context(tenant_id=self._dataset.tenant_id) + + index = vector_store.get_index( + service_context=service_context, + index_struct=self._dataset.index_struct_dict + ) + + for node_id in node_ids: + self.index_delete_node(index, node_id) + + @retry(reraise=True, retry=retry_if_exception_type(ReadTimeout), stop=stop_after_attempt(3)) + def index_delete_node(self, index: BaseGPTVectorStoreIndex, node_id: str): + index.delete_node(node_id) + + def del_doc(self, doc_id: str): + if not self._dataset.index_struct_dict: + return + + service_context = IndexBuilder.get_default_service_context(tenant_id=self._dataset.tenant_id) + + index = vector_store.get_index( + service_context=service_context, + index_struct=self._dataset.index_struct_dict + ) + + self.index_delete_doc(index, doc_id) + + @retry(reraise=True, retry=retry_if_exception_type(ReadTimeout), stop=stop_after_attempt(3)) + def index_delete_doc(self, index: BaseGPTVectorStoreIndex, doc_id: str): + index.delete(doc_id) + + @property + def query_index(self) -> Optional[BaseGPTVectorStoreIndex]: + if not self._dataset.index_struct_dict: + return None + + service_context = IndexBuilder.get_default_service_context(tenant_id=self._dataset.tenant_id) + + return vector_store.get_index( + service_context=service_context, + index_struct=self._dataset.index_struct_dict + ) + + def _filter_duplicate_nodes(self, index: BaseGPTVectorStoreIndex, nodes: List[Node]) -> List[Node]: + for node in nodes: + node_id = node.doc_id + exists_duplicate_node = index.exists_by_node_id(node_id) + if exists_duplicate_node: + nodes.remove(node) + + return nodes diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py new file mode 100644 index 0000000000..fd9e430116 --- /dev/null +++ b/api/core/indexing_runner.py @@ -0,0 +1,467 @@ +import datetime +import json +import re +import tempfile +import time +from pathlib import Path +from typing import Optional, List +from langchain.text_splitter import RecursiveCharacterTextSplitter + +from llama_index import SimpleDirectoryReader +from llama_index.data_structs import Node +from llama_index.data_structs.node_v2 import DocumentRelationship +from llama_index.node_parser import SimpleNodeParser, NodeParser +from llama_index.readers.file.base import DEFAULT_FILE_EXTRACTOR +from llama_index.readers.file.markdown_parser import MarkdownParser + +from core.docstore.dataset_docstore import DatesetDocumentStore +from core.index.keyword_table_index import KeywordTableIndex +from core.index.readers.html_parser import HTMLParser +from core.index.readers.pdf_parser import PDFParser +from core.index.vector_index import VectorIndex +from core.llm.token_calculator import TokenCalculator +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from extensions.ext_storage import storage +from models.dataset import Document, Dataset, DocumentSegment, DatasetProcessRule +from models.model import UploadFile + + +class IndexingRunner: + + def __init__(self, embedding_model_name: str = "text-embedding-ada-002"): + self.storage = storage + self.embedding_model_name = embedding_model_name + + def run(self, document: Document): + """Run the indexing process.""" + # get dataset + dataset = Dataset.query.filter_by( + id=document.dataset_id + ).first() + + if not dataset: + raise ValueError("no dataset found") + + # load file + text_docs = self._load_data(document) + + # get the process rule + processing_rule = db.session.query(DatasetProcessRule). \ + filter(DatasetProcessRule.id == document.dataset_process_rule_id). \ + first() + + # get node parser for splitting + node_parser = self._get_node_parser(processing_rule) + + # split to nodes + nodes = self._step_split( + text_docs=text_docs, + node_parser=node_parser, + dataset=dataset, + document=document, + processing_rule=processing_rule + ) + + # build index + self._build_index( + dataset=dataset, + document=document, + nodes=nodes + ) + + def run_in_splitting_status(self, document: Document): + """Run the indexing process when the index_status is splitting.""" + # get dataset + dataset = Dataset.query.filter_by( + id=document.dataset_id + ).first() + + if not dataset: + raise ValueError("no dataset found") + + # get exist document_segment list and delete + document_segments = DocumentSegment.query.filter_by( + dataset_id=dataset.id, + document_id=document.id + ).all() + db.session.delete(document_segments) + db.session.commit() + # load file + text_docs = self._load_data(document) + + # get the process rule + processing_rule = db.session.query(DatasetProcessRule). \ + filter(DatasetProcessRule.id == document.dataset_process_rule_id). \ + first() + + # get node parser for splitting + node_parser = self._get_node_parser(processing_rule) + + # split to nodes + nodes = self._step_split( + text_docs=text_docs, + node_parser=node_parser, + dataset=dataset, + document=document, + processing_rule=processing_rule + ) + + # build index + self._build_index( + dataset=dataset, + document=document, + nodes=nodes + ) + + def run_in_indexing_status(self, document: Document): + """Run the indexing process when the index_status is indexing.""" + # get dataset + dataset = Dataset.query.filter_by( + id=document.dataset_id + ).first() + + if not dataset: + raise ValueError("no dataset found") + + # get exist document_segment list and delete + document_segments = DocumentSegment.query.filter_by( + dataset_id=dataset.id, + document_id=document.id + ).all() + nodes = [] + if document_segments: + for document_segment in document_segments: + # transform segment to node + if document_segment.status != "completed": + relationships = { + DocumentRelationship.SOURCE: document_segment.document_id, + } + + previous_segment = document_segment.previous_segment + if previous_segment: + relationships[DocumentRelationship.PREVIOUS] = previous_segment.index_node_id + + next_segment = document_segment.next_segment + if next_segment: + relationships[DocumentRelationship.NEXT] = next_segment.index_node_id + node = Node( + doc_id=document_segment.index_node_id, + doc_hash=document_segment.index_node_hash, + text=document_segment.content, + extra_info=None, + node_info=None, + relationships=relationships + ) + nodes.append(node) + + # build index + self._build_index( + dataset=dataset, + document=document, + nodes=nodes + ) + + def indexing_estimate(self, file_detail: UploadFile, tmp_processing_rule: dict) -> dict: + """ + Estimate the indexing for the document. + """ + # load data from file + text_docs = self._load_data_from_file(file_detail) + + processing_rule = DatasetProcessRule( + mode=tmp_processing_rule["mode"], + rules=json.dumps(tmp_processing_rule["rules"]) + ) + + # get node parser for splitting + node_parser = self._get_node_parser(processing_rule) + + # split to nodes + nodes = self._split_to_nodes( + text_docs=text_docs, + node_parser=node_parser, + processing_rule=processing_rule + ) + + tokens = 0 + preview_texts = [] + for node in nodes: + if len(preview_texts) < 5: + preview_texts.append(node.get_text()) + + tokens += TokenCalculator.get_num_tokens(self.embedding_model_name, node.get_text()) + + return { + "total_segments": len(nodes), + "tokens": tokens, + "total_price": '{:f}'.format(TokenCalculator.get_token_price(self.embedding_model_name, tokens)), + "currency": TokenCalculator.get_currency(self.embedding_model_name), + "preview": preview_texts + } + + def _load_data(self, document: Document) -> List[Document]: + # load file + if document.data_source_type != "upload_file": + return [] + + data_source_info = document.data_source_info_dict + if not data_source_info or 'upload_file_id' not in data_source_info: + raise ValueError("no upload file found") + + file_detail = db.session.query(UploadFile). \ + filter(UploadFile.id == data_source_info['upload_file_id']). \ + one_or_none() + + text_docs = self._load_data_from_file(file_detail) + + # update document status to splitting + self._update_document_index_status( + document_id=document.id, + after_indexing_status="splitting", + extra_update_params={ + Document.file_id: file_detail.id, + Document.word_count: sum([len(text_doc.text) for text_doc in text_docs]), + Document.parsing_completed_at: datetime.datetime.utcnow() + } + ) + + # replace doc id to document model id + for text_doc in text_docs: + # remove invalid symbol + text_doc.text = self.filter_string(text_doc.get_text()) + text_doc.doc_id = document.id + + return text_docs + + def filter_string(self, text): + pattern = re.compile('[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\xFF]') + return pattern.sub('', text) + + def _load_data_from_file(self, upload_file: UploadFile) -> List[Document]: + with tempfile.TemporaryDirectory() as temp_dir: + suffix = Path(upload_file.key).suffix + filepath = f"{temp_dir}/{next(tempfile._get_candidate_names())}{suffix}" + self.storage.download(upload_file.key, filepath) + + file_extractor = DEFAULT_FILE_EXTRACTOR.copy() + file_extractor[".markdown"] = MarkdownParser() + file_extractor[".html"] = HTMLParser() + file_extractor[".htm"] = HTMLParser() + file_extractor[".pdf"] = PDFParser({'upload_file': upload_file}) + + loader = SimpleDirectoryReader(input_files=[filepath], file_extractor=file_extractor) + text_docs = loader.load_data() + + return text_docs + + def _get_node_parser(self, processing_rule: DatasetProcessRule) -> NodeParser: + """ + Get the NodeParser object according to the processing rule. + """ + if processing_rule.mode == "custom": + # The user-defined segmentation rule + rules = json.loads(processing_rule.rules) + segmentation = rules["segmentation"] + if segmentation["max_tokens"] < 50 or segmentation["max_tokens"] > 1000: + raise ValueError("Custom segment length should be between 50 and 1000.") + + separator = segmentation["separator"] + if not separator: + separators = ["\n\n", "。", ".", " ", ""] + else: + separator = separator.replace('\\n', '\n') + separators = [separator, ""] + + character_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( + chunk_size=segmentation["max_tokens"], + chunk_overlap=0, + separators=separators + ) + else: + # Automatic segmentation + character_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( + chunk_size=DatasetProcessRule.AUTOMATIC_RULES['segmentation']['max_tokens'], + chunk_overlap=0, + separators=["\n\n", "。", ".", " ", ""] + ) + + return SimpleNodeParser(text_splitter=character_splitter, include_extra_info=True) + + def _step_split(self, text_docs: List[Document], node_parser: NodeParser, + dataset: Dataset, document: Document, processing_rule: DatasetProcessRule) -> List[Node]: + """ + Split the text documents into nodes and save them to the document segment. + """ + nodes = self._split_to_nodes( + text_docs=text_docs, + node_parser=node_parser, + processing_rule=processing_rule + ) + + # save node to document segment + doc_store = DatesetDocumentStore( + dataset=dataset, + user_id=document.created_by, + embedding_model_name=self.embedding_model_name, + document_id=document.id + ) + + doc_store.add_documents(nodes) + + # update document status to indexing + cur_time = datetime.datetime.utcnow() + self._update_document_index_status( + document_id=document.id, + after_indexing_status="indexing", + extra_update_params={ + Document.cleaning_completed_at: cur_time, + Document.splitting_completed_at: cur_time, + } + ) + + # update segment status to indexing + self._update_segments_by_document( + document_id=document.id, + update_params={ + DocumentSegment.status: "indexing", + DocumentSegment.indexing_at: datetime.datetime.utcnow() + } + ) + + return nodes + + def _split_to_nodes(self, text_docs: List[Document], node_parser: NodeParser, + processing_rule: DatasetProcessRule) -> List[Node]: + """ + Split the text documents into nodes. + """ + all_nodes = [] + for text_doc in text_docs: + # document clean + document_text = self._document_clean(text_doc.get_text(), processing_rule) + text_doc.text = document_text + + # parse document to nodes + nodes = node_parser.get_nodes_from_documents([text_doc]) + + all_nodes.extend(nodes) + + return all_nodes + + def _document_clean(self, text: str, processing_rule: DatasetProcessRule) -> str: + """ + Clean the document text according to the processing rules. + """ + if processing_rule.mode == "automatic": + rules = DatasetProcessRule.AUTOMATIC_RULES + else: + rules = json.loads(processing_rule.rules) if processing_rule.rules else {} + + if 'pre_processing_rules' in rules: + pre_processing_rules = rules["pre_processing_rules"] + for pre_processing_rule in pre_processing_rules: + if pre_processing_rule["id"] == "remove_extra_spaces" and pre_processing_rule["enabled"] is True: + # Remove extra spaces + pattern = r'\n{3,}' + text = re.sub(pattern, '\n\n', text) + pattern = r'[\t\f\r\x20\u00a0\u1680\u180e\u2000-\u200a\u202f\u205f\u3000]{2,}' + text = re.sub(pattern, ' ', text) + elif pre_processing_rule["id"] == "remove_urls_emails" and pre_processing_rule["enabled"] is True: + # Remove email + pattern = r'([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)' + text = re.sub(pattern, '', text) + + # Remove URL + pattern = r'https?://[^\s]+' + text = re.sub(pattern, '', text) + + return text + + def _build_index(self, dataset: Dataset, document: Document, nodes: List[Node]) -> None: + """ + Build the index for the document. + """ + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + # chunk nodes by chunk size + indexing_start_at = time.perf_counter() + tokens = 0 + chunk_size = 100 + for i in range(0, len(nodes), chunk_size): + # check document is paused + self._check_document_paused_status(document.id) + chunk_nodes = nodes[i:i + chunk_size] + + tokens += sum( + TokenCalculator.get_num_tokens(self.embedding_model_name, node.get_text()) for node in chunk_nodes + ) + + # save vector index + if dataset.indexing_technique == "high_quality": + vector_index.add_nodes(chunk_nodes) + + # save keyword index + keyword_table_index.add_nodes(chunk_nodes) + + node_ids = [node.doc_id for node in chunk_nodes] + db.session.query(DocumentSegment).filter( + DocumentSegment.document_id == document.id, + DocumentSegment.index_node_id.in_(node_ids), + DocumentSegment.status == "indexing" + ).update({ + DocumentSegment.status: "completed", + DocumentSegment.completed_at: datetime.datetime.utcnow() + }) + + db.session.commit() + + indexing_end_at = time.perf_counter() + + # update document status to completed + self._update_document_index_status( + document_id=document.id, + after_indexing_status="completed", + extra_update_params={ + Document.tokens: tokens, + Document.completed_at: datetime.datetime.utcnow(), + Document.indexing_latency: indexing_end_at - indexing_start_at, + } + ) + + def _check_document_paused_status(self, document_id: str): + indexing_cache_key = 'document_{}_is_paused'.format(document_id) + result = redis_client.get(indexing_cache_key) + if result: + raise DocumentIsPausedException() + + def _update_document_index_status(self, document_id: str, after_indexing_status: str, + extra_update_params: Optional[dict] = None) -> None: + """ + Update the document indexing status. + """ + count = Document.query.filter_by(id=document_id, is_paused=True).count() + if count > 0: + raise DocumentIsPausedException() + + update_params = { + Document.indexing_status: after_indexing_status + } + + if extra_update_params: + update_params.update(extra_update_params) + + Document.query.filter_by(id=document_id).update(update_params) + db.session.commit() + + def _update_segments_by_document(self, document_id: str, update_params: dict) -> None: + """ + Update the document segment by document id. + """ + DocumentSegment.query.filter_by(document_id=document_id).update(update_params) + db.session.commit() + + +class DocumentIsPausedException(Exception): + pass diff --git a/api/core/llm/error.py b/api/core/llm/error.py new file mode 100644 index 0000000000..883d282e8a --- /dev/null +++ b/api/core/llm/error.py @@ -0,0 +1,55 @@ +from typing import Optional + + +class LLMError(Exception): + """Base class for all LLM exceptions.""" + description: Optional[str] = None + + def __init__(self, description: Optional[str] = None) -> None: + self.description = description + + +class LLMBadRequestError(LLMError): + """Raised when the LLM returns bad request.""" + description = "Bad Request" + + +class LLMAPIConnectionError(LLMError): + """Raised when the LLM returns API connection error.""" + description = "API Connection Error" + + +class LLMAPIUnavailableError(LLMError): + """Raised when the LLM returns API unavailable error.""" + description = "API Unavailable Error" + + +class LLMRateLimitError(LLMError): + """Raised when the LLM returns rate limit error.""" + description = "Rate Limit Error" + + +class LLMAuthorizationError(LLMError): + """Raised when the LLM returns authorization error.""" + description = "Authorization Error" + + +class ProviderTokenNotInitError(Exception): + """ + Custom exception raised when the provider token is not initialized. + """ + description = "Provider Token Not Init" + + +class QuotaExceededError(Exception): + """ + Custom exception raised when the quota for a provider has been exceeded. + """ + description = "Quota Exceeded" + + +class ModelCurrentlyNotSupportError(Exception): + """ + Custom exception raised when the model not support + """ + description = "Model Currently Not Support" diff --git a/api/core/llm/error_handle_wraps.py b/api/core/llm/error_handle_wraps.py new file mode 100644 index 0000000000..ae9a0278bb --- /dev/null +++ b/api/core/llm/error_handle_wraps.py @@ -0,0 +1,51 @@ +import logging +from functools import wraps + +import openai + +from core.llm.error import LLMAPIConnectionError, LLMAPIUnavailableError, LLMRateLimitError, LLMAuthorizationError, \ + LLMBadRequestError + + +def handle_llm_exceptions(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except openai.error.InvalidRequestError as e: + logging.exception("Invalid request to OpenAI API.") + raise LLMBadRequestError(str(e)) + except openai.error.APIConnectionError as e: + logging.exception("Failed to connect to OpenAI API.") + raise LLMAPIConnectionError(str(e)) + except (openai.error.APIError, openai.error.ServiceUnavailableError, openai.error.Timeout) as e: + logging.exception("OpenAI service unavailable.") + raise LLMAPIUnavailableError(str(e)) + except openai.error.RateLimitError as e: + raise LLMRateLimitError(str(e)) + except openai.error.AuthenticationError as e: + raise LLMAuthorizationError(str(e)) + + return wrapper + + +def handle_llm_exceptions_async(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except openai.error.InvalidRequestError as e: + logging.exception("Invalid request to OpenAI API.") + raise LLMBadRequestError(str(e)) + except openai.error.APIConnectionError as e: + logging.exception("Failed to connect to OpenAI API.") + raise LLMAPIConnectionError(str(e)) + except (openai.error.APIError, openai.error.ServiceUnavailableError, openai.error.Timeout) as e: + logging.exception("OpenAI service unavailable.") + raise LLMAPIUnavailableError(str(e)) + except openai.error.RateLimitError as e: + raise LLMRateLimitError(str(e)) + except openai.error.AuthenticationError as e: + raise LLMAuthorizationError(str(e)) + + return wrapper diff --git a/api/core/llm/llm_builder.py b/api/core/llm/llm_builder.py new file mode 100644 index 0000000000..4355593c5d --- /dev/null +++ b/api/core/llm/llm_builder.py @@ -0,0 +1,103 @@ +from typing import Union, Optional + +from langchain.callbacks import CallbackManager +from langchain.llms.fake import FakeListLLM + +from core.constant import llm_constant +from core.llm.provider.llm_provider_service import LLMProviderService +from core.llm.streamable_chat_open_ai import StreamableChatOpenAI +from core.llm.streamable_open_ai import StreamableOpenAI + + +class LLMBuilder: + """ + This class handles the following logic: + 1. For providers with the name 'OpenAI', the OPENAI_API_KEY value is stored directly in encrypted_config. + 2. For providers with the name 'Azure OpenAI', encrypted_config stores the serialized values of four fields, as shown below: + OPENAI_API_TYPE=azure + OPENAI_API_VERSION=2022-12-01 + OPENAI_API_BASE=https://your-resource-name.openai.azure.com + OPENAI_API_KEY= + 3. For providers with the name 'Anthropic', the ANTHROPIC_API_KEY value is stored directly in encrypted_config. + 4. For providers with the name 'Cohere', the COHERE_API_KEY value is stored directly in encrypted_config. + 5. For providers with the name 'HUGGINGFACEHUB', the HUGGINGFACEHUB_API_KEY value is stored directly in encrypted_config. + 6. Providers with the provider_type 'CUSTOM' can be created through the admin interface, while 'System' providers cannot be created through the admin interface. + 7. If both CUSTOM and System providers exist in the records, the CUSTOM provider is preferred by default, but this preference can be changed via an input parameter. + 8. For providers with the provider_type 'System', the quota_used must not exceed quota_limit. If the quota is exceeded, the provider cannot be used. Currently, only the TRIAL quota_type is supported, which is permanently non-resetting. + """ + + @classmethod + def to_llm(cls, tenant_id: str, model_name: str, **kwargs) -> Union[StreamableOpenAI, StreamableChatOpenAI, FakeListLLM]: + if model_name == 'fake': + return FakeListLLM(responses=[]) + + mode = cls.get_mode_by_model(model_name) + if mode == 'chat': + # llm_cls = StreamableAzureChatOpenAI + llm_cls = StreamableChatOpenAI + elif mode == 'completion': + llm_cls = StreamableOpenAI + else: + raise ValueError(f"model name {model_name} is not supported.") + + model_credentials = cls.get_model_credentials(tenant_id, model_name) + + return llm_cls( + model_name=model_name, + temperature=kwargs.get('temperature', 0), + max_tokens=kwargs.get('max_tokens', 256), + top_p=kwargs.get('top_p', 1), + frequency_penalty=kwargs.get('frequency_penalty', 0), + presence_penalty=kwargs.get('presence_penalty', 0), + callback_manager=kwargs.get('callback_manager', None), + streaming=kwargs.get('streaming', False), + # request_timeout=None + **model_credentials + ) + + @classmethod + def to_llm_from_model(cls, tenant_id: str, model: dict, streaming: bool = False, + callback_manager: Optional[CallbackManager] = None) -> Union[StreamableOpenAI, StreamableChatOpenAI]: + model_name = model.get("name") + completion_params = model.get("completion_params", {}) + + return cls.to_llm( + tenant_id=tenant_id, + model_name=model_name, + temperature=completion_params.get('temperature', 0), + max_tokens=completion_params.get('max_tokens', 256), + top_p=completion_params.get('top_p', 0), + frequency_penalty=completion_params.get('frequency_penalty', 0.1), + presence_penalty=completion_params.get('presence_penalty', 0.1), + streaming=streaming, + callback_manager=callback_manager + ) + + @classmethod + def get_mode_by_model(cls, model_name: str) -> str: + if not model_name: + raise ValueError(f"empty model name is not supported.") + + if model_name in llm_constant.models_by_mode['chat']: + return "chat" + elif model_name in llm_constant.models_by_mode['completion']: + return "completion" + else: + raise ValueError(f"model name {model_name} is not supported.") + + @classmethod + def get_model_credentials(cls, tenant_id: str, model_name: str) -> dict: + """ + Returns the API credentials for the given tenant_id and model_name, based on the model's provider. + Raises an exception if the model_name is not found or if the provider is not found. + """ + if not model_name: + raise Exception('model name not found') + + if model_name not in llm_constant.models: + raise Exception('model {} not found'.format(model_name)) + + model_provider = llm_constant.models[model_name] + + provider_service = LLMProviderService(tenant_id=tenant_id, provider_name=model_provider) + return provider_service.get_credentials(model_name) diff --git a/api/core/llm/moderation.py b/api/core/llm/moderation.py new file mode 100644 index 0000000000..d18d6fc5c2 --- /dev/null +++ b/api/core/llm/moderation.py @@ -0,0 +1,15 @@ +import openai +from models.provider import ProviderName + + +class Moderation: + + def __init__(self, provider: str, api_key: str): + self.provider = provider + self.api_key = api_key + + if self.provider == ProviderName.OPENAI.value: + self.client = openai.Moderation + + def moderate(self, text): + return self.client.create(input=text, api_key=self.api_key) diff --git a/api/core/llm/provider/anthropic_provider.py b/api/core/llm/provider/anthropic_provider.py new file mode 100644 index 0000000000..4c7756305e --- /dev/null +++ b/api/core/llm/provider/anthropic_provider.py @@ -0,0 +1,23 @@ +from typing import Optional + +from core.llm.provider.base import BaseProvider +from models.provider import ProviderName + + +class AnthropicProvider(BaseProvider): + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + credentials = self.get_credentials(model_id) + # todo + return [] + + def get_credentials(self, model_id: Optional[str] = None) -> dict: + """ + Returns the API credentials for Azure OpenAI as a dictionary, for the given tenant_id. + The dictionary contains keys: azure_api_type, azure_api_version, azure_api_base, and azure_api_key. + """ + return { + 'anthropic_api_key': self.get_provider_api_key(model_id=model_id) + } + + def get_provider_name(self): + return ProviderName.ANTHROPIC \ No newline at end of file diff --git a/api/core/llm/provider/azure_provider.py b/api/core/llm/provider/azure_provider.py new file mode 100644 index 0000000000..e0ba0d0734 --- /dev/null +++ b/api/core/llm/provider/azure_provider.py @@ -0,0 +1,105 @@ +import json +from typing import Optional, Union + +import requests + +from core.llm.provider.base import BaseProvider +from models.provider import ProviderName + + +class AzureProvider(BaseProvider): + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + credentials = self.get_credentials(model_id) + url = "{}/openai/deployments?api-version={}".format( + credentials.get('openai_api_base'), + credentials.get('openai_api_version') + ) + + headers = { + "api-key": credentials.get('openai_api_key'), + "content-type": "application/json; charset=utf-8" + } + + response = requests.get(url, headers=headers) + + if response.status_code == 200: + result = response.json() + return [{ + 'id': deployment['id'], + 'name': '{} ({})'.format(deployment['id'], deployment['model']) + } for deployment in result['data'] if deployment['status'] == 'succeeded'] + else: + # TODO: optimize in future + raise Exception('Failed to get deployments from Azure OpenAI. Status code: {}'.format(response.status_code)) + + def get_credentials(self, model_id: Optional[str] = None) -> dict: + """ + Returns the API credentials for Azure OpenAI as a dictionary. + """ + encrypted_config = self.get_provider_api_key(model_id=model_id) + config = json.loads(encrypted_config) + config['openai_api_type'] = 'azure' + config['deployment_name'] = model_id + return config + + def get_provider_name(self): + return ProviderName.AZURE_OPENAI + + def get_provider_configs(self, obfuscated: bool = False) -> Union[str | dict]: + """ + Returns the provider configs. + """ + try: + config = self.get_provider_api_key() + config = json.loads(config) + except: + config = { + 'openai_api_type': 'azure', + 'openai_api_version': '2023-03-15-preview', + 'openai_api_base': 'https://foo.microsoft.com/bar', + 'openai_api_key': '' + } + + if obfuscated: + if not config.get('openai_api_key'): + config = { + 'openai_api_type': 'azure', + 'openai_api_version': '2023-03-15-preview', + 'openai_api_base': 'https://foo.microsoft.com/bar', + 'openai_api_key': '' + } + + config['openai_api_key'] = self.obfuscated_token(config.get('openai_api_key')) + return config + + return config + + def get_token_type(self): + # TODO: change to dict when implemented + return lambda value: value + + def config_validate(self, config: Union[dict | str]): + """ + Validates the given config. + """ + # TODO: implement + pass + + def get_encrypted_token(self, config: Union[dict | str]): + """ + Returns the encrypted token. + """ + return json.dumps({ + 'openai_api_type': 'azure', + 'openai_api_version': '2023-03-15-preview', + 'openai_api_base': config['openai_api_base'], + 'openai_api_key': self.encrypt_token(config['openai_api_key']) + }) + + def get_decrypted_token(self, token: str): + """ + Returns the decrypted token. + """ + config = json.loads(token) + config['openai_api_key'] = self.decrypt_token(config['openai_api_key']) + return config diff --git a/api/core/llm/provider/base.py b/api/core/llm/provider/base.py new file mode 100644 index 0000000000..89343ff62a --- /dev/null +++ b/api/core/llm/provider/base.py @@ -0,0 +1,124 @@ +import base64 +from abc import ABC, abstractmethod +from typing import Optional, Union + +from core import hosted_llm_credentials +from core.llm.error import QuotaExceededError, ModelCurrentlyNotSupportError, ProviderTokenNotInitError +from extensions.ext_database import db +from libs import rsa +from models.account import Tenant +from models.provider import Provider, ProviderType, ProviderName + + +class BaseProvider(ABC): + def __init__(self, tenant_id: str): + self.tenant_id = tenant_id + + def get_provider_api_key(self, model_id: Optional[str] = None, prefer_custom: bool = True) -> str: + """ + Returns the decrypted API key for the given tenant_id and provider_name. + If the provider is of type SYSTEM and the quota is exceeded, raises a QuotaExceededError. + If the provider is not found or not valid, raises a ProviderTokenNotInitError. + """ + provider = self.get_provider(prefer_custom) + if not provider: + raise ProviderTokenNotInitError() + + if provider.provider_type == ProviderType.SYSTEM.value: + quota_used = provider.quota_used if provider.quota_used is not None else 0 + quota_limit = provider.quota_limit if provider.quota_limit is not None else 0 + + if model_id and model_id == 'gpt-4': + raise ModelCurrentlyNotSupportError() + + if quota_used >= quota_limit: + raise QuotaExceededError() + + return self.get_hosted_credentials() + else: + return self.get_decrypted_token(provider.encrypted_config) + + def get_provider(self, prefer_custom: bool) -> Optional[Provider]: + """ + Returns the Provider instance for the given tenant_id and provider_name. + If both CUSTOM and System providers exist, the preferred provider will be returned based on the prefer_custom flag. + """ + providers = db.session.query(Provider).filter( + Provider.tenant_id == self.tenant_id, + Provider.provider_name == self.get_provider_name().value + ).order_by(Provider.provider_type.desc() if prefer_custom else Provider.provider_type).all() + + custom_provider = None + system_provider = None + + for provider in providers: + if provider.provider_type == ProviderType.CUSTOM.value: + custom_provider = provider + elif provider.provider_type == ProviderType.SYSTEM.value: + system_provider = provider + + if custom_provider and custom_provider.is_valid and custom_provider.encrypted_config: + return custom_provider + elif system_provider and system_provider.is_valid: + return system_provider + else: + return None + + def get_hosted_credentials(self) -> str: + if self.get_provider_name() != ProviderName.OPENAI: + raise ProviderTokenNotInitError() + + if not hosted_llm_credentials.openai or not hosted_llm_credentials.openai.api_key: + raise ProviderTokenNotInitError() + + return hosted_llm_credentials.openai.api_key + + def get_provider_configs(self, obfuscated: bool = False) -> Union[str | dict]: + """ + Returns the provider configs. + """ + try: + config = self.get_provider_api_key() + except: + config = 'THIS-IS-A-MOCK-TOKEN' + + if obfuscated: + return self.obfuscated_token(config) + + return config + + def obfuscated_token(self, token: str): + return token[:6] + '*' * (len(token) - 8) + token[-2:] + + def get_token_type(self): + return str + + def get_encrypted_token(self, config: Union[dict | str]): + return self.encrypt_token(config) + + def get_decrypted_token(self, token: str): + return self.decrypt_token(token) + + def encrypt_token(self, token): + tenant = db.session.query(Tenant).filter(Tenant.id == self.tenant_id).first() + encrypted_token = rsa.encrypt(token, tenant.encrypt_public_key) + return base64.b64encode(encrypted_token).decode() + + def decrypt_token(self, token): + return rsa.decrypt(base64.b64decode(token), self.tenant_id) + + @abstractmethod + def get_provider_name(self): + raise NotImplementedError + + @abstractmethod + def get_credentials(self, model_id: Optional[str] = None) -> dict: + raise NotImplementedError + + @abstractmethod + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + raise NotImplementedError + + @abstractmethod + def config_validate(self, config: str): + raise NotImplementedError diff --git a/api/core/llm/provider/errors.py b/api/core/llm/provider/errors.py new file mode 100644 index 0000000000..407b7f7906 --- /dev/null +++ b/api/core/llm/provider/errors.py @@ -0,0 +1,2 @@ +class ValidateFailedError(Exception): + description = "Provider Validate failed" diff --git a/api/core/llm/provider/huggingface_provider.py b/api/core/llm/provider/huggingface_provider.py new file mode 100644 index 0000000000..b3dd3ed573 --- /dev/null +++ b/api/core/llm/provider/huggingface_provider.py @@ -0,0 +1,22 @@ +from typing import Optional + +from core.llm.provider.base import BaseProvider +from models.provider import ProviderName + + +class HuggingfaceProvider(BaseProvider): + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + credentials = self.get_credentials(model_id) + # todo + return [] + + def get_credentials(self, model_id: Optional[str] = None) -> dict: + """ + Returns the API credentials for Huggingface as a dictionary, for the given tenant_id. + """ + return { + 'huggingface_api_key': self.get_provider_api_key(model_id=model_id) + } + + def get_provider_name(self): + return ProviderName.HUGGINGFACEHUB \ No newline at end of file diff --git a/api/core/llm/provider/llm_provider_service.py b/api/core/llm/provider/llm_provider_service.py new file mode 100644 index 0000000000..ca4f8bec6d --- /dev/null +++ b/api/core/llm/provider/llm_provider_service.py @@ -0,0 +1,53 @@ +from typing import Optional, Union + +from core.llm.provider.anthropic_provider import AnthropicProvider +from core.llm.provider.azure_provider import AzureProvider +from core.llm.provider.base import BaseProvider +from core.llm.provider.huggingface_provider import HuggingfaceProvider +from core.llm.provider.openai_provider import OpenAIProvider +from models.provider import Provider + + +class LLMProviderService: + + def __init__(self, tenant_id: str, provider_name: str): + self.provider = self.init_provider(tenant_id, provider_name) + + def init_provider(self, tenant_id: str, provider_name: str) -> BaseProvider: + if provider_name == 'openai': + return OpenAIProvider(tenant_id) + elif provider_name == 'azure_openai': + return AzureProvider(tenant_id) + elif provider_name == 'anthropic': + return AnthropicProvider(tenant_id) + elif provider_name == 'huggingface': + return HuggingfaceProvider(tenant_id) + else: + raise Exception('provider {} not found'.format(provider_name)) + + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + return self.provider.get_models(model_id) + + def get_credentials(self, model_id: Optional[str] = None) -> dict: + return self.provider.get_credentials(model_id) + + def get_provider_configs(self, obfuscated: bool = False) -> Union[str | dict]: + return self.provider.get_provider_configs(obfuscated) + + def get_provider_db_record(self, prefer_custom: bool = False) -> Optional[Provider]: + return self.provider.get_provider(prefer_custom) + + def config_validate(self, config: Union[dict | str]): + """ + Validates the given config. + + :param config: + :raises: ValidateFailedError + """ + return self.provider.config_validate(config) + + def get_token_type(self): + return self.provider.get_token_type() + + def get_encrypted_token(self, config: Union[dict | str]): + return self.provider.get_encrypted_token(config) diff --git a/api/core/llm/provider/openai_provider.py b/api/core/llm/provider/openai_provider.py new file mode 100644 index 0000000000..8257ad3aab --- /dev/null +++ b/api/core/llm/provider/openai_provider.py @@ -0,0 +1,44 @@ +import logging +from typing import Optional, Union + +import openai +from openai.error import AuthenticationError, OpenAIError + +from core.llm.moderation import Moderation +from core.llm.provider.base import BaseProvider +from core.llm.provider.errors import ValidateFailedError +from models.provider import ProviderName + + +class OpenAIProvider(BaseProvider): + def get_models(self, model_id: Optional[str] = None) -> list[dict]: + credentials = self.get_credentials(model_id) + response = openai.Model.list(**credentials) + + return [{ + 'id': model['id'], + 'name': model['id'], + } for model in response['data']] + + def get_credentials(self, model_id: Optional[str] = None) -> dict: + """ + Returns the credentials for the given tenant_id and provider_name. + """ + return { + 'openai_api_key': self.get_provider_api_key(model_id=model_id) + } + + def get_provider_name(self): + return ProviderName.OPENAI + + def config_validate(self, config: Union[dict | str]): + """ + Validates the given config. + """ + try: + Moderation(self.get_provider_name().value, config).moderate('test') + except (AuthenticationError, OpenAIError) as ex: + raise ValidateFailedError(str(ex)) + except Exception as ex: + logging.exception('OpenAI config validation failed') + raise ex diff --git a/api/core/llm/streamable_azure_chat_open_ai.py b/api/core/llm/streamable_azure_chat_open_ai.py new file mode 100644 index 0000000000..539ce92774 --- /dev/null +++ b/api/core/llm/streamable_azure_chat_open_ai.py @@ -0,0 +1,89 @@ +import requests +from langchain.schema import BaseMessage, ChatResult, LLMResult +from langchain.chat_models import AzureChatOpenAI +from typing import Optional, List + +from core.llm.error_handle_wraps import handle_llm_exceptions, handle_llm_exceptions_async + + +class StreamableAzureChatOpenAI(AzureChatOpenAI): + def get_messages_tokens(self, messages: List[BaseMessage]) -> int: + """Get the number of tokens in a list of messages. + + Args: + messages: The messages to count the tokens of. + + Returns: + The number of tokens in the messages. + """ + tokens_per_message = 5 + tokens_per_request = 3 + + message_tokens = tokens_per_request + message_strs = '' + for message in messages: + message_strs += message.content + message_tokens += tokens_per_message + + # calc once + message_tokens += self.get_num_tokens(message_strs) + + return message_tokens + + def _generate( + self, messages: List[BaseMessage], stop: Optional[List[str]] = None + ) -> ChatResult: + self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], + verbose=self.verbose + ) + + chat_result = super()._generate(messages, stop) + + result = LLMResult( + generations=[chat_result.generations], + llm_output=chat_result.llm_output + ) + self.callback_manager.on_llm_end(result, verbose=self.verbose) + + return chat_result + + async def _agenerate( + self, messages: List[BaseMessage], stop: Optional[List[str]] = None + ) -> ChatResult: + if self.callback_manager.is_async: + await self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], + verbose=self.verbose + ) + else: + self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], + verbose=self.verbose + ) + + chat_result = super()._generate(messages, stop) + + result = LLMResult( + generations=[chat_result.generations], + llm_output=chat_result.llm_output + ) + + if self.callback_manager.is_async: + await self.callback_manager.on_llm_end(result, verbose=self.verbose) + else: + self.callback_manager.on_llm_end(result, verbose=self.verbose) + + return chat_result + + @handle_llm_exceptions + def generate( + self, messages: List[List[BaseMessage]], stop: Optional[List[str]] = None + ) -> LLMResult: + return super().generate(messages, stop) + + @handle_llm_exceptions_async + async def agenerate( + self, messages: List[List[BaseMessage]], stop: Optional[List[str]] = None + ) -> LLMResult: + return await super().agenerate(messages, stop) diff --git a/api/core/llm/streamable_chat_open_ai.py b/api/core/llm/streamable_chat_open_ai.py new file mode 100644 index 0000000000..59391e4ce0 --- /dev/null +++ b/api/core/llm/streamable_chat_open_ai.py @@ -0,0 +1,86 @@ +from langchain.schema import BaseMessage, ChatResult, LLMResult +from langchain.chat_models import ChatOpenAI +from typing import Optional, List + +from core.llm.error_handle_wraps import handle_llm_exceptions, handle_llm_exceptions_async + + +class StreamableChatOpenAI(ChatOpenAI): + + def get_messages_tokens(self, messages: List[BaseMessage]) -> int: + """Get the number of tokens in a list of messages. + + Args: + messages: The messages to count the tokens of. + + Returns: + The number of tokens in the messages. + """ + tokens_per_message = 5 + tokens_per_request = 3 + + message_tokens = tokens_per_request + message_strs = '' + for message in messages: + message_strs += message.content + message_tokens += tokens_per_message + + # calc once + message_tokens += self.get_num_tokens(message_strs) + + return message_tokens + + def _generate( + self, messages: List[BaseMessage], stop: Optional[List[str]] = None + ) -> ChatResult: + self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], verbose=self.verbose + ) + + chat_result = super()._generate(messages, stop) + + result = LLMResult( + generations=[chat_result.generations], + llm_output=chat_result.llm_output + ) + self.callback_manager.on_llm_end(result, verbose=self.verbose) + + return chat_result + + async def _agenerate( + self, messages: List[BaseMessage], stop: Optional[List[str]] = None + ) -> ChatResult: + if self.callback_manager.is_async: + await self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], verbose=self.verbose + ) + else: + self.callback_manager.on_llm_start( + {"name": self.__class__.__name__}, [(message.type + ": " + message.content) for message in messages], verbose=self.verbose + ) + + chat_result = super()._generate(messages, stop) + + result = LLMResult( + generations=[chat_result.generations], + llm_output=chat_result.llm_output + ) + + if self.callback_manager.is_async: + await self.callback_manager.on_llm_end(result, verbose=self.verbose) + else: + self.callback_manager.on_llm_end(result, verbose=self.verbose) + + return chat_result + + @handle_llm_exceptions + def generate( + self, messages: List[List[BaseMessage]], stop: Optional[List[str]] = None + ) -> LLMResult: + return super().generate(messages, stop) + + @handle_llm_exceptions_async + async def agenerate( + self, messages: List[List[BaseMessage]], stop: Optional[List[str]] = None + ) -> LLMResult: + return await super().agenerate(messages, stop) diff --git a/api/core/llm/streamable_open_ai.py b/api/core/llm/streamable_open_ai.py new file mode 100644 index 0000000000..94754af30e --- /dev/null +++ b/api/core/llm/streamable_open_ai.py @@ -0,0 +1,20 @@ +from langchain.schema import LLMResult +from typing import Optional, List +from langchain import OpenAI + +from core.llm.error_handle_wraps import handle_llm_exceptions, handle_llm_exceptions_async + + +class StreamableOpenAI(OpenAI): + + @handle_llm_exceptions + def generate( + self, prompts: List[str], stop: Optional[List[str]] = None + ) -> LLMResult: + return super().generate(prompts, stop) + + @handle_llm_exceptions_async + async def agenerate( + self, prompts: List[str], stop: Optional[List[str]] = None + ) -> LLMResult: + return await super().agenerate(prompts, stop) diff --git a/api/core/llm/token_calculator.py b/api/core/llm/token_calculator.py new file mode 100644 index 0000000000..e45f2b4d62 --- /dev/null +++ b/api/core/llm/token_calculator.py @@ -0,0 +1,41 @@ +import decimal +from typing import Optional + +import tiktoken + +from core.constant import llm_constant + + +class TokenCalculator: + @classmethod + def get_num_tokens(cls, model_name: str, text: str): + if len(text) == 0: + return 0 + + enc = tiktoken.encoding_for_model(model_name) + + tokenized_text = enc.encode(text) + + # calculate the number of tokens in the encoded text + return len(tokenized_text) + + @classmethod + def get_token_price(cls, model_name: str, tokens: int, text_type: Optional[str] = None) -> decimal.Decimal: + if model_name in llm_constant.models_by_mode['embedding']: + unit_price = llm_constant.model_prices[model_name]['usage'] + elif text_type == 'prompt': + unit_price = llm_constant.model_prices[model_name]['prompt'] + elif text_type == 'completion': + unit_price = llm_constant.model_prices[model_name]['completion'] + else: + raise Exception('Invalid text type') + + tokens_per_1k = (decimal.Decimal(tokens) / 1000).quantize(decimal.Decimal('0.001'), + rounding=decimal.ROUND_HALF_UP) + + total_price = tokens_per_1k * unit_price + return total_price.quantize(decimal.Decimal('0.0000001'), rounding=decimal.ROUND_HALF_UP) + + @classmethod + def get_currency(cls, model_name: str): + return llm_constant.model_currency diff --git a/api/core/memory/read_only_conversation_token_db_buffer_shared_memory.py b/api/core/memory/read_only_conversation_token_db_buffer_shared_memory.py new file mode 100644 index 0000000000..16f982c592 --- /dev/null +++ b/api/core/memory/read_only_conversation_token_db_buffer_shared_memory.py @@ -0,0 +1,77 @@ +from typing import Any, List, Dict, Union + +from langchain.memory.chat_memory import BaseChatMemory +from langchain.schema import get_buffer_string, BaseMessage, HumanMessage, AIMessage + +from core.llm.streamable_chat_open_ai import StreamableChatOpenAI +from core.llm.streamable_open_ai import StreamableOpenAI +from extensions.ext_database import db +from models.model import Conversation, Message + + +class ReadOnlyConversationTokenDBBufferSharedMemory(BaseChatMemory): + conversation: Conversation + human_prefix: str = "Human" + ai_prefix: str = "AI" + llm: Union[StreamableChatOpenAI | StreamableOpenAI] + memory_key: str = "chat_history" + max_token_limit: int = 2000 + message_limit: int = 10 + + @property + def buffer(self) -> List[BaseMessage]: + """String buffer of memory.""" + # fetch limited messages desc, and return reversed + messages = db.session.query(Message).filter( + Message.conversation_id == self.conversation.id, + Message.answer_tokens > 0 + ).order_by(Message.created_at.desc()).limit(self.message_limit).all() + + messages = list(reversed(messages)) + + chat_messages: List[BaseMessage] = [] + for message in messages: + chat_messages.append(HumanMessage(content=message.query)) + chat_messages.append(AIMessage(content=message.answer)) + + if not chat_messages: + return chat_messages + + # prune the chat message if it exceeds the max token limit + curr_buffer_length = self.llm.get_messages_tokens(chat_messages) + if curr_buffer_length > self.max_token_limit: + pruned_memory = [] + while curr_buffer_length > self.max_token_limit and chat_messages: + pruned_memory.append(chat_messages.pop(0)) + curr_buffer_length = self.llm.get_messages_tokens(chat_messages) + + return chat_messages + + @property + def memory_variables(self) -> List[str]: + """Will always return list of memory variables. + + :meta private: + """ + return [self.memory_key] + + def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]: + """Return history buffer.""" + buffer: Any = self.buffer + if self.return_messages: + final_buffer: Any = buffer + else: + final_buffer = get_buffer_string( + buffer, + human_prefix=self.human_prefix, + ai_prefix=self.ai_prefix, + ) + return {self.memory_key: final_buffer} + + def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, str]) -> None: + """Nothing should be saved or changed""" + pass + + def clear(self) -> None: + """Nothing to clear, got a memory like a vault.""" + pass diff --git a/api/core/memory/read_only_conversation_token_db_string_buffer_shared_memory.py b/api/core/memory/read_only_conversation_token_db_string_buffer_shared_memory.py new file mode 100644 index 0000000000..e5933931a2 --- /dev/null +++ b/api/core/memory/read_only_conversation_token_db_string_buffer_shared_memory.py @@ -0,0 +1,36 @@ +from typing import Any, List, Dict + +from langchain.memory.chat_memory import BaseChatMemory +from langchain.schema import get_buffer_string, BaseMessage, BaseLanguageModel + +from core.memory.read_only_conversation_token_db_buffer_shared_memory import \ + ReadOnlyConversationTokenDBBufferSharedMemory + + +class ReadOnlyConversationTokenDBStringBufferSharedMemory(BaseChatMemory): + memory: ReadOnlyConversationTokenDBBufferSharedMemory + + @property + def memory_variables(self) -> List[str]: + """Return memory variables.""" + return self.memory.memory_variables + + def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, str]: + """Load memory variables from memory.""" + buffer: Any = self.memory.buffer + + final_buffer = get_buffer_string( + buffer, + human_prefix=self.memory.human_prefix, + ai_prefix=self.memory.ai_prefix, + ) + + return {self.memory.memory_key: final_buffer} + + def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, str]) -> None: + """Nothing should be saved or changed""" + pass + + def clear(self) -> None: + """Nothing to clear, got a memory like a vault.""" + pass \ No newline at end of file diff --git a/api/core/prompt/output_parser/suggested_questions_after_answer.py b/api/core/prompt/output_parser/suggested_questions_after_answer.py new file mode 100644 index 0000000000..7898d08262 --- /dev/null +++ b/api/core/prompt/output_parser/suggested_questions_after_answer.py @@ -0,0 +1,16 @@ +import json +from typing import Any + +from langchain.schema import BaseOutputParser +from core.prompt.prompts import SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + + +class SuggestedQuestionsAfterAnswerOutputParser(BaseOutputParser): + + def get_format_instructions(self) -> str: + return SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + + def parse(self, text: str) -> Any: + json_string = text.strip() + json_obj = json.loads(json_string) + return json_obj diff --git a/api/core/prompt/prompt_builder.py b/api/core/prompt/prompt_builder.py new file mode 100644 index 0000000000..cbe41576f1 --- /dev/null +++ b/api/core/prompt/prompt_builder.py @@ -0,0 +1,37 @@ +import re + +from langchain.prompts import SystemMessagePromptTemplate, HumanMessagePromptTemplate, AIMessagePromptTemplate +from langchain.schema import BaseMessage + +from core.prompt.prompt_template import OutLinePromptTemplate + + +class PromptBuilder: + @classmethod + def to_system_message(cls, prompt_content: str, inputs: dict) -> BaseMessage: + prompt_template = OutLinePromptTemplate.from_template(prompt_content) + system_prompt_template = SystemMessagePromptTemplate(prompt=prompt_template) + prompt_inputs = {k: inputs[k] for k in system_prompt_template.input_variables if k in inputs} + system_message = system_prompt_template.format(**prompt_inputs) + return system_message + + @classmethod + def to_ai_message(cls, prompt_content: str, inputs: dict) -> BaseMessage: + prompt_template = OutLinePromptTemplate.from_template(prompt_content) + ai_prompt_template = AIMessagePromptTemplate(prompt=prompt_template) + prompt_inputs = {k: inputs[k] for k in ai_prompt_template.input_variables if k in inputs} + ai_message = ai_prompt_template.format(**prompt_inputs) + return ai_message + + @classmethod + def to_human_message(cls, prompt_content: str, inputs: dict) -> BaseMessage: + prompt_template = OutLinePromptTemplate.from_template(prompt_content) + human_prompt_template = HumanMessagePromptTemplate(prompt=prompt_template) + human_message = human_prompt_template.format(**inputs) + return human_message + + @classmethod + def process_template(cls, template: str): + processed_template = re.sub(r'\{(.+?)\}', r'\1', template) + processed_template = re.sub(r'\{\{(.+?)\}\}', r'{\1}', processed_template) + return processed_template diff --git a/api/core/prompt/prompt_template.py b/api/core/prompt/prompt_template.py new file mode 100644 index 0000000000..6799c5a733 --- /dev/null +++ b/api/core/prompt/prompt_template.py @@ -0,0 +1,37 @@ +import re +from typing import Any + +from langchain import PromptTemplate +from langchain.formatting import StrictFormatter + + +class OutLinePromptTemplate(PromptTemplate): + @classmethod + def from_template(cls, template: str, **kwargs: Any) -> PromptTemplate: + """Load a prompt template from a template.""" + input_variables = { + v for _, v, _, _ in OneLineFormatter().parse(template) if v is not None + } + return cls( + input_variables=list(sorted(input_variables)), template=template, **kwargs + ) + + +class OneLineFormatter(StrictFormatter): + def parse(self, format_string): + last_end = 0 + results = [] + for match in re.finditer(r"{([a-zA-Z_]\w*)}", format_string): + field_name = match.group(1) + start, end = match.span() + + literal_text = format_string[last_end:start] + last_end = end + + results.append((literal_text, field_name, '', None)) + + remaining_literal_text = format_string[last_end:] + if remaining_literal_text: + results.append((remaining_literal_text, None, None, None)) + + return results diff --git a/api/core/prompt/prompts.py b/api/core/prompt/prompts.py new file mode 100644 index 0000000000..1d9c00990c --- /dev/null +++ b/api/core/prompt/prompts.py @@ -0,0 +1,63 @@ +from llama_index import QueryKeywordExtractPrompt + +CONVERSATION_TITLE_PROMPT = ( + "Human:{query}\n-----\n" + "Help me summarize the intent of what the human said and provide a title, the title should not exceed 20 words.\n" + "If the human said is conducted in Chinese, you should return a Chinese title.\n" + "If the human said is conducted in English, you should return an English title.\n" + "title:" +) + +CONVERSATION_SUMMARY_PROMPT = ( + "Please generate a short summary of the following conversation.\n" + "If the conversation communicating in Chinese, you should return a Chinese summary.\n" + "If the conversation communicating in English, you should return an English summary.\n" + "[Conversation Start]\n" + "{context}\n" + "[Conversation End]\n\n" + "summary:" +) + +INTRODUCTION_GENERATE_PROMPT = ( + "I am designing a product for users to interact with an AI through dialogue. " + "The Prompt given to the AI before the conversation is:\n\n" + "```\n{prompt}\n```\n\n" + "Please generate a brief introduction of no more than 50 words that greets the user, based on this Prompt. " + "Do not reveal the developer's motivation or deep logic behind the Prompt, " + "but focus on building a relationship with the user:\n" +) + +MORE_LIKE_THIS_GENERATE_PROMPT = ( + "-----\n" + "{original_completion}\n" + "-----\n\n" + "Please use the above content as a sample for generating the result, " + "and include key information points related to the original sample in the result. " + "Try to rephrase this information in different ways and predict according to the rules below.\n\n" + "-----\n" + "{prompt}\n" +) + +SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = ( + "Please help me predict the three most likely questions that human would ask, " + "and keeping each question under 20 characters.\n" + "The output must be in JSON format following the specified schema:\n" + "[\"question1\",\"question2\",\"question3\"]\n" +) + +QUERY_KEYWORD_EXTRACT_TEMPLATE_TMPL = ( + "A question is provided below. Given the question, extract up to {max_keywords} " + "keywords from the text. Focus on extracting the keywords that we can use " + "to best lookup answers to the question. Avoid stopwords." + "I am not sure which language the following question is in. " + "If the user asked the question in Chinese, please return the keywords in Chinese. " + "If the user asked the question in English, please return the keywords in English.\n" + "---------------------\n" + "{question}\n" + "---------------------\n" + "Provide keywords in the following comma-separated format: 'KEYWORDS: '\n" +) + +QUERY_KEYWORD_EXTRACT_TEMPLATE = QueryKeywordExtractPrompt( + QUERY_KEYWORD_EXTRACT_TEMPLATE_TMPL +) diff --git a/api/core/tool/dataset_tool_builder.py b/api/core/tool/dataset_tool_builder.py new file mode 100644 index 0000000000..b31b15511a --- /dev/null +++ b/api/core/tool/dataset_tool_builder.py @@ -0,0 +1,83 @@ +from typing import Optional + +from langchain.callbacks import CallbackManager +from llama_index.langchain_helpers.agents import IndexToolConfig + +from core.callback_handler.dataset_tool_callback_handler import DatasetToolCallbackHandler +from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler +from core.callback_handler.std_out_callback_handler import DifyStdOutCallbackHandler +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from core.prompt.prompts import QUERY_KEYWORD_EXTRACT_TEMPLATE +from core.tool.llama_index_tool import EnhanceLlamaIndexTool +from extensions.ext_database import db +from models.dataset import Dataset + + +class DatasetToolBuilder: + @classmethod + def build_dataset_tool(cls, tenant_id: str, dataset_id: str, + response_mode: str = "no_synthesizer", + callback_handler: Optional[DatasetToolCallbackHandler] = None): + # get dataset from dataset id + dataset = db.session.query(Dataset).filter( + Dataset.tenant_id == tenant_id, + Dataset.id == dataset_id + ).first() + + if not dataset: + return None + + if dataset.indexing_technique == "economy": + # use keyword table query + index = KeywordTableIndex(dataset=dataset).query_index + + if not index: + return None + + query_kwargs = { + "mode": "default", + "response_mode": response_mode, + "query_keyword_extract_template": QUERY_KEYWORD_EXTRACT_TEMPLATE, + "max_keywords_per_query": 5, + # If num_chunks_per_query is too large, + # it will slow down the synthesis process due to multiple iterations of refinement. + "num_chunks_per_query": 2 + } + else: + index = VectorIndex(dataset=dataset).query_index + + if not index: + return None + + query_kwargs = { + "mode": "default", + "response_mode": response_mode, + # If top_k is too large, + # it will slow down the synthesis process due to multiple iterations of refinement. + "similarity_top_k": 2 + } + + # fulfill description when it is empty + description = dataset.description + if not description: + description = 'useful for when you want to answer queries about the ' + dataset.name + + index_tool_config = IndexToolConfig( + index=index, + name=f"dataset-{dataset_id}", + description=description, + index_query_kwargs=query_kwargs, + tool_kwargs={ + "callback_manager": CallbackManager([callback_handler, DifyStdOutCallbackHandler()]) + }, + # tool_kwargs={"return_direct": True}, + # return_direct: Whether to return LLM results directly or process the output data with an Output Parser + ) + + index_callback_handler = DatasetIndexToolCallbackHandler(dataset_id=dataset_id) + + return EnhanceLlamaIndexTool.from_tool_config( + tool_config=index_tool_config, + callback_handler=index_callback_handler + ) diff --git a/api/core/tool/llama_index_tool.py b/api/core/tool/llama_index_tool.py new file mode 100644 index 0000000000..ffb216771b --- /dev/null +++ b/api/core/tool/llama_index_tool.py @@ -0,0 +1,43 @@ +from typing import Dict + +from langchain.tools import BaseTool +from llama_index.indices.base import BaseGPTIndex +from llama_index.langchain_helpers.agents import IndexToolConfig +from pydantic import Field + +from core.callback_handler.index_tool_callback_handler import IndexToolCallbackHandler + + +class EnhanceLlamaIndexTool(BaseTool): + """Tool for querying a LlamaIndex.""" + + # NOTE: name/description still needs to be set + index: BaseGPTIndex + query_kwargs: Dict = Field(default_factory=dict) + return_sources: bool = False + callback_handler: IndexToolCallbackHandler + + @classmethod + def from_tool_config(cls, tool_config: IndexToolConfig, + callback_handler: IndexToolCallbackHandler) -> "EnhanceLlamaIndexTool": + """Create a tool from a tool config.""" + return_sources = tool_config.tool_kwargs.pop("return_sources", False) + return cls( + index=tool_config.index, + callback_handler=callback_handler, + name=tool_config.name, + description=tool_config.description, + return_sources=return_sources, + query_kwargs=tool_config.index_query_kwargs, + **tool_config.tool_kwargs, + ) + + def _run(self, tool_input: str) -> str: + response = self.index.query(tool_input, **self.query_kwargs) + self.callback_handler.on_tool_end(response) + return str(response) + + async def _arun(self, tool_input: str) -> str: + response = await self.index.aquery(tool_input, **self.query_kwargs) + self.callback_handler.on_tool_end(response) + return str(response) diff --git a/api/core/vector_store/base.py b/api/core/vector_store/base.py new file mode 100644 index 0000000000..526f83831d --- /dev/null +++ b/api/core/vector_store/base.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from llama_index import ServiceContext, GPTVectorStoreIndex +from llama_index.data_structs import Node +from llama_index.vector_stores.types import VectorStore + + +class BaseVectorStoreClient(ABC): + @abstractmethod + def get_index(self, service_context: ServiceContext, config: dict) -> GPTVectorStoreIndex: + raise NotImplementedError + + @abstractmethod + def to_index_config(self, index_id: str) -> dict: + raise NotImplementedError + + +class BaseGPTVectorStoreIndex(GPTVectorStoreIndex): + def delete_node(self, node_id: str): + self._vector_store.delete_node(node_id) + + def exists_by_node_id(self, node_id: str) -> bool: + return self._vector_store.exists_by_node_id(node_id) + + +class EnhanceVectorStore(ABC): + @abstractmethod + def delete_node(self, node_id: str): + pass + + @abstractmethod + def exists_by_node_id(self, node_id: str) -> bool: + pass diff --git a/api/core/vector_store/qdrant_vector_store_client.py b/api/core/vector_store/qdrant_vector_store_client.py new file mode 100644 index 0000000000..1188c121e3 --- /dev/null +++ b/api/core/vector_store/qdrant_vector_store_client.py @@ -0,0 +1,147 @@ +import os +from typing import cast, List + +from llama_index.data_structs import Node +from llama_index.data_structs.node_v2 import DocumentRelationship +from llama_index.vector_stores.types import VectorStoreQuery, VectorStoreQueryResult +from qdrant_client.http.models import Payload, Filter + +import qdrant_client +from llama_index import ServiceContext, GPTVectorStoreIndex, GPTQdrantIndex +from llama_index.data_structs.data_structs_v2 import QdrantIndexDict +from llama_index.vector_stores import QdrantVectorStore +from qdrant_client.local.qdrant_local import QdrantLocal + +from core.vector_store.base import BaseVectorStoreClient, BaseGPTVectorStoreIndex, EnhanceVectorStore + + +class QdrantVectorStoreClient(BaseVectorStoreClient): + + def __init__(self, url: str, api_key: str, root_path: str): + self._client = self.init_from_config(url, api_key, root_path) + + @classmethod + def init_from_config(cls, url: str, api_key: str, root_path: str): + if url and url.startswith('path:'): + path = url.replace('path:', '') + if not os.path.isabs(path): + path = os.path.join(root_path, path) + + return qdrant_client.QdrantClient( + path=path + ) + else: + return qdrant_client.QdrantClient( + url=url, + api_key=api_key, + ) + + def get_index(self, service_context: ServiceContext, config: dict) -> GPTVectorStoreIndex: + index_struct = QdrantIndexDict() + + if self._client is None: + raise Exception("Vector client is not initialized.") + + # {"collection_name": "Gpt_index_xxx"} + collection_name = config.get('collection_name') + if not collection_name: + raise Exception("collection_name cannot be None.") + + return GPTQdrantEnhanceIndex( + service_context=service_context, + index_struct=index_struct, + vector_store=QdrantEnhanceVectorStore( + client=self._client, + collection_name=collection_name + ) + ) + + def to_index_config(self, index_id: str) -> dict: + return {"collection_name": index_id} + + +class GPTQdrantEnhanceIndex(GPTQdrantIndex, BaseGPTVectorStoreIndex): + pass + + +class QdrantEnhanceVectorStore(QdrantVectorStore, EnhanceVectorStore): + def delete_node(self, node_id: str): + """ + Delete node from the index. + + :param node_id: node id + """ + from qdrant_client.http import models as rest + + self._reload_if_needed() + + self._client.delete( + collection_name=self._collection_name, + points_selector=rest.Filter( + must=[ + rest.FieldCondition( + key="id", match=rest.MatchValue(value=node_id) + ) + ] + ), + ) + + def exists_by_node_id(self, node_id: str) -> bool: + """ + Get node from the index by node id. + + :param node_id: node id + """ + self._reload_if_needed() + + response = self._client.retrieve( + collection_name=self._collection_name, + ids=[node_id] + ) + + return len(response) > 0 + + def query( + self, + query: VectorStoreQuery, + ) -> VectorStoreQueryResult: + """Query index for top k most similar nodes. + + Args: + query (VectorStoreQuery): query + """ + query_embedding = cast(List[float], query.query_embedding) + + self._reload_if_needed() + + response = self._client.search( + collection_name=self._collection_name, + query_vector=query_embedding, + limit=cast(int, query.similarity_top_k), + query_filter=cast(Filter, self._build_query_filter(query)), + with_vectors=True + ) + + nodes = [] + similarities = [] + ids = [] + for point in response: + payload = cast(Payload, point.payload) + node = Node( + doc_id=str(point.id), + text=payload.get("text"), + embedding=point.vector, + extra_info=payload.get("extra_info"), + relationships={ + DocumentRelationship.SOURCE: payload.get("doc_id", "None"), + }, + ) + nodes.append(node) + similarities.append(point.score) + ids.append(str(point.id)) + + return VectorStoreQueryResult(nodes=nodes, similarities=similarities, ids=ids) + + def _reload_if_needed(self): + if isinstance(self._client._client, QdrantLocal): + self._client._client._load() diff --git a/api/core/vector_store/vector_store.py b/api/core/vector_store/vector_store.py new file mode 100644 index 0000000000..56b5fd0f97 --- /dev/null +++ b/api/core/vector_store/vector_store.py @@ -0,0 +1,61 @@ +from flask import Flask +from llama_index import ServiceContext, GPTVectorStoreIndex +from requests import ReadTimeout +from tenacity import retry, retry_if_exception_type, stop_after_attempt + +from core.vector_store.qdrant_vector_store_client import QdrantVectorStoreClient +from core.vector_store.weaviate_vector_store_client import WeaviateVectorStoreClient + +SUPPORTED_VECTOR_STORES = ['weaviate', 'qdrant'] + + +class VectorStore: + + def __init__(self): + self._vector_store = None + self._client = None + + def init_app(self, app: Flask): + if not app.config['VECTOR_STORE']: + return + + self._vector_store = app.config['VECTOR_STORE'] + if self._vector_store not in SUPPORTED_VECTOR_STORES: + raise ValueError(f"Vector store {self._vector_store} is not supported.") + + if self._vector_store == 'weaviate': + self._client = WeaviateVectorStoreClient( + endpoint=app.config['WEAVIATE_ENDPOINT'], + api_key=app.config['WEAVIATE_API_KEY'], + grpc_enabled=app.config['WEAVIATE_GRPC_ENABLED'] + ) + elif self._vector_store == 'qdrant': + self._client = QdrantVectorStoreClient( + url=app.config['QDRANT_URL'], + api_key=app.config['QDRANT_API_KEY'], + root_path=app.root_path + ) + + app.extensions['vector_store'] = self + + @retry(reraise=True, retry=retry_if_exception_type(ReadTimeout), stop=stop_after_attempt(3)) + def get_index(self, service_context: ServiceContext, index_struct: dict) -> GPTVectorStoreIndex: + vector_store_config: dict = index_struct.get('vector_store') + index = self.get_client().get_index( + service_context=service_context, + config=vector_store_config + ) + + return index + + def to_index_struct(self, index_id: str) -> dict: + return { + "type": self._vector_store, + "vector_store": self.get_client().to_index_config(index_id) + } + + def get_client(self): + if not self._client: + raise Exception("Vector store client is not initialized.") + + return self._client diff --git a/api/core/vector_store/vector_store_index_query.py b/api/core/vector_store/vector_store_index_query.py new file mode 100644 index 0000000000..f29de83f9e --- /dev/null +++ b/api/core/vector_store/vector_store_index_query.py @@ -0,0 +1,66 @@ +from llama_index.indices.query.base import IS +from typing import ( + Any, + Dict, + List, + Optional +) + +from llama_index.docstore import BaseDocumentStore +from llama_index.indices.postprocessor.node import ( + BaseNodePostprocessor, +) +from llama_index.indices.vector_store import GPTVectorStoreIndexQuery +from llama_index.indices.response.response_builder import ResponseMode +from llama_index.indices.service_context import ServiceContext +from llama_index.optimization.optimizer import BaseTokenUsageOptimizer +from llama_index.prompts.prompts import ( + QuestionAnswerPrompt, + RefinePrompt, + SimpleInputPrompt, +) + +from core.index.query.synthesizer import EnhanceResponseSynthesizer + + +class EnhanceGPTVectorStoreIndexQuery(GPTVectorStoreIndexQuery): + @classmethod + def from_args( + cls, + index_struct: IS, + service_context: ServiceContext, + docstore: Optional[BaseDocumentStore] = None, + node_postprocessors: Optional[List[BaseNodePostprocessor]] = None, + verbose: bool = False, + # response synthesizer args + response_mode: ResponseMode = ResponseMode.DEFAULT, + text_qa_template: Optional[QuestionAnswerPrompt] = None, + refine_template: Optional[RefinePrompt] = None, + simple_template: Optional[SimpleInputPrompt] = None, + response_kwargs: Optional[Dict] = None, + use_async: bool = False, + streaming: bool = False, + optimizer: Optional[BaseTokenUsageOptimizer] = None, + # class-specific args + **kwargs: Any, + ) -> "BaseGPTIndexQuery": + response_synthesizer = EnhanceResponseSynthesizer.from_args( + service_context=service_context, + text_qa_template=text_qa_template, + refine_template=refine_template, + simple_template=simple_template, + response_mode=response_mode, + response_kwargs=response_kwargs, + use_async=use_async, + streaming=streaming, + optimizer=optimizer, + ) + return cls( + index_struct=index_struct, + service_context=service_context, + response_synthesizer=response_synthesizer, + docstore=docstore, + node_postprocessors=node_postprocessors, + verbose=verbose, + **kwargs, + ) diff --git a/api/core/vector_store/weaviate_vector_store_client.py b/api/core/vector_store/weaviate_vector_store_client.py new file mode 100644 index 0000000000..2310278cf9 --- /dev/null +++ b/api/core/vector_store/weaviate_vector_store_client.py @@ -0,0 +1,258 @@ +import json +import weaviate +from dataclasses import field +from typing import List, Any, Dict, Optional + +from core.vector_store.base import BaseVectorStoreClient, BaseGPTVectorStoreIndex, EnhanceVectorStore +from llama_index import ServiceContext, GPTWeaviateIndex, GPTVectorStoreIndex +from llama_index.data_structs.data_structs_v2 import WeaviateIndexDict, Node +from llama_index.data_structs.node_v2 import DocumentRelationship +from llama_index.readers.weaviate.client import _class_name, NODE_SCHEMA, _logger +from llama_index.vector_stores import WeaviateVectorStore +from llama_index.vector_stores.types import VectorStoreQuery, VectorStoreQueryResult, VectorStoreQueryMode +from llama_index.readers.weaviate.utils import ( + parse_get_response, + validate_client, +) + + +class WeaviateVectorStoreClient(BaseVectorStoreClient): + + def __init__(self, endpoint: str, api_key: str, grpc_enabled: bool): + self._client = self.init_from_config(endpoint, api_key, grpc_enabled) + + def init_from_config(self, endpoint: str, api_key: str, grpc_enabled: bool): + auth_config = weaviate.auth.AuthApiKey(api_key=api_key) + + weaviate.connect.connection.has_grpc = grpc_enabled + + return weaviate.Client( + url=endpoint, + auth_client_secret=auth_config, + timeout_config=(5, 15), + startup_period=None + ) + + def get_index(self, service_context: ServiceContext, config: dict) -> GPTVectorStoreIndex: + index_struct = WeaviateIndexDict() + + if self._client is None: + raise Exception("Vector client is not initialized.") + + # {"class_prefix": "Gpt_index_xxx"} + class_prefix = config.get('class_prefix') + if not class_prefix: + raise Exception("class_prefix cannot be None.") + + return GPTWeaviateEnhanceIndex( + service_context=service_context, + index_struct=index_struct, + vector_store=WeaviateWithSimilaritiesVectorStore( + weaviate_client=self._client, + class_prefix=class_prefix + ) + ) + + def to_index_config(self, index_id: str) -> dict: + return {"class_prefix": index_id} + + +class WeaviateWithSimilaritiesVectorStore(WeaviateVectorStore, EnhanceVectorStore): + def query(self, query: VectorStoreQuery) -> VectorStoreQueryResult: + """Query index for top k most similar nodes.""" + nodes = self.weaviate_query( + self._client, + self._class_prefix, + query, + ) + nodes = nodes[: query.similarity_top_k] + node_idxs = [str(i) for i in range(len(nodes))] + + similarities = [] + for node in nodes: + similarities.append(node.extra_info['similarity']) + del node.extra_info['similarity'] + + return VectorStoreQueryResult(nodes=nodes, ids=node_idxs, similarities=similarities) + + def weaviate_query( + self, + client: Any, + class_prefix: str, + query_spec: VectorStoreQuery, + ) -> List[Node]: + """Convert to LlamaIndex list.""" + validate_client(client) + + class_name = _class_name(class_prefix) + prop_names = [p["name"] for p in NODE_SCHEMA] + vector = query_spec.query_embedding + + # build query + query = client.query.get(class_name, prop_names).with_additional(["id", "vector", "certainty"]) + if query_spec.mode == VectorStoreQueryMode.DEFAULT: + _logger.debug("Using vector search") + if vector is not None: + query = query.with_near_vector( + { + "vector": vector, + } + ) + elif query_spec.mode == VectorStoreQueryMode.HYBRID: + _logger.debug(f"Using hybrid search with alpha {query_spec.alpha}") + query = query.with_hybrid( + query=query_spec.query_str, + alpha=query_spec.alpha, + vector=vector, + ) + query = query.with_limit(query_spec.similarity_top_k) + _logger.debug(f"Using limit of {query_spec.similarity_top_k}") + + # execute query + query_result = query.do() + + # parse results + parsed_result = parse_get_response(query_result) + entries = parsed_result[class_name] + results = [self._to_node(entry) for entry in entries] + return results + + def _to_node(self, entry: Dict) -> Node: + """Convert to Node.""" + extra_info_str = entry["extra_info"] + if extra_info_str == "": + extra_info = None + else: + extra_info = json.loads(extra_info_str) + + if 'certainty' in entry['_additional']: + if extra_info: + extra_info['similarity'] = entry['_additional']['certainty'] + else: + extra_info = {'similarity': entry['_additional']['certainty']} + + node_info_str = entry["node_info"] + if node_info_str == "": + node_info = None + else: + node_info = json.loads(node_info_str) + + relationships_str = entry["relationships"] + relationships: Dict[DocumentRelationship, str] + if relationships_str == "": + relationships = field(default_factory=dict) + else: + relationships = { + DocumentRelationship(k): v for k, v in json.loads(relationships_str).items() + } + + return Node( + text=entry["text"], + doc_id=entry["doc_id"], + embedding=entry["_additional"]["vector"], + extra_info=extra_info, + node_info=node_info, + relationships=relationships, + ) + + def delete(self, doc_id: str, **delete_kwargs: Any) -> None: + """Delete a document. + + Args: + doc_id (str): document id + + """ + delete_document(self._client, doc_id, self._class_prefix) + + def delete_node(self, node_id: str): + """ + Delete node from the index. + + :param node_id: node id + """ + delete_node(self._client, node_id, self._class_prefix) + + def exists_by_node_id(self, node_id: str) -> bool: + """ + Get node from the index by node id. + + :param node_id: node id + """ + entry = get_by_node_id(self._client, node_id, self._class_prefix) + return True if entry else False + + +class GPTWeaviateEnhanceIndex(GPTWeaviateIndex, BaseGPTVectorStoreIndex): + pass + + +def delete_document(client: Any, ref_doc_id: str, class_prefix: str) -> None: + """Delete entry.""" + validate_client(client) + # make sure that each entry + class_name = _class_name(class_prefix) + where_filter = { + "path": ["ref_doc_id"], + "operator": "Equal", + "valueString": ref_doc_id, + } + query = ( + client.query.get(class_name).with_additional(["id"]).with_where(where_filter) + ) + + query_result = query.do() + parsed_result = parse_get_response(query_result) + entries = parsed_result[class_name] + for entry in entries: + client.data_object.delete(entry["_additional"]["id"], class_name) + + while len(entries) > 0: + query_result = query.do() + parsed_result = parse_get_response(query_result) + entries = parsed_result[class_name] + for entry in entries: + client.data_object.delete(entry["_additional"]["id"], class_name) + + +def delete_node(client: Any, node_id: str, class_prefix: str) -> None: + """Delete entry.""" + validate_client(client) + # make sure that each entry + class_name = _class_name(class_prefix) + where_filter = { + "path": ["doc_id"], + "operator": "Equal", + "valueString": node_id, + } + query = ( + client.query.get(class_name).with_additional(["id"]).with_where(where_filter) + ) + + query_result = query.do() + parsed_result = parse_get_response(query_result) + entries = parsed_result[class_name] + for entry in entries: + client.data_object.delete(entry["_additional"]["id"], class_name) + + +def get_by_node_id(client: Any, node_id: str, class_prefix: str) -> Optional[Dict]: + """Delete entry.""" + validate_client(client) + # make sure that each entry + class_name = _class_name(class_prefix) + where_filter = { + "path": ["doc_id"], + "operator": "Equal", + "valueString": node_id, + } + query = ( + client.query.get(class_name).with_additional(["id"]).with_where(where_filter) + ) + + query_result = query.do() + parsed_result = parse_get_response(query_result) + entries = parsed_result[class_name] + if len(entries) == 0: + return None + + return entries[0] diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh new file mode 100644 index 0000000000..50b8cbd86a --- /dev/null +++ b/api/docker/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -e + +if [[ "${MIGRATION_ENABLED}" == "true" ]]; then + echo "Running migrations" + flask db upgrade +fi + +if [[ "${MODE}" == "worker" ]]; then + celery -A app.celery worker -P ${CELERY_WORKER_CLASS:-gevent} -c ${CELERY_WORKER_AMOUNT:-1} --loglevel INFO +else + if [[ "${DEBUG}" == "true" ]]; then + flask run --host=${DIFY_BIND_ADDRESS:-0.0.0.0} --port=${DIFY_PORT:-5001} --debug + else + gunicorn \ + --bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \ + --workers ${SERVER_WORKER_AMOUNT:-1} \ + --worker-class ${SERVER_WORKER_CLASS:-gevent} \ + --timeout ${GUNICORN_TIMEOUT:-200} \ + --preload \ + app:app + fi +fi \ No newline at end of file diff --git a/api/events/app_event.py b/api/events/app_event.py new file mode 100644 index 0000000000..938478d3b7 --- /dev/null +++ b/api/events/app_event.py @@ -0,0 +1,10 @@ +from blinker import signal + +# sender: app +app_was_created = signal('app-was-created') + +# sender: app +app_was_deleted = signal('app-was-deleted') + +# sender: app, kwargs: old_app_model_config, new_app_model_config +app_model_config_was_updated = signal('app-model-config-was-updated') diff --git a/api/events/dataset_event.py b/api/events/dataset_event.py new file mode 100644 index 0000000000..d4a2b6f313 --- /dev/null +++ b/api/events/dataset_event.py @@ -0,0 +1,4 @@ +from blinker import signal + +# sender: dataset +dataset_was_deleted = signal('dataset-was-deleted') diff --git a/api/events/document_event.py b/api/events/document_event.py new file mode 100644 index 0000000000..f95326630b --- /dev/null +++ b/api/events/document_event.py @@ -0,0 +1,4 @@ +from blinker import signal + +# sender: document +document_was_deleted = signal('document-was-deleted') diff --git a/api/events/event_handlers/__init__.py b/api/events/event_handlers/__init__.py new file mode 100644 index 0000000000..c858ac0880 --- /dev/null +++ b/api/events/event_handlers/__init__.py @@ -0,0 +1,9 @@ +from .create_installed_app_when_app_created import handle +from .delete_installed_app_when_app_deleted import handle +from .create_provider_when_tenant_created import handle +from .create_provider_when_tenant_updated import handle +from .clean_when_document_deleted import handle +from .clean_when_dataset_deleted import handle +from .update_app_dataset_join_when_app_model_config_updated import handle +from .generate_conversation_name_when_first_message_created import handle +from .generate_conversation_summary_when_few_message_created import handle diff --git a/api/events/event_handlers/clean_when_dataset_deleted.py b/api/events/event_handlers/clean_when_dataset_deleted.py new file mode 100644 index 0000000000..e9975c92bc --- /dev/null +++ b/api/events/event_handlers/clean_when_dataset_deleted.py @@ -0,0 +1,8 @@ +from events.dataset_event import dataset_was_deleted +from tasks.clean_dataset_task import clean_dataset_task + + +@dataset_was_deleted.connect +def handle(sender, **kwargs): + dataset = sender + clean_dataset_task.delay(dataset.id, dataset.tenant_id, dataset.indexing_technique, dataset.index_struct) diff --git a/api/events/event_handlers/clean_when_document_deleted.py b/api/events/event_handlers/clean_when_document_deleted.py new file mode 100644 index 0000000000..d6553b385e --- /dev/null +++ b/api/events/event_handlers/clean_when_document_deleted.py @@ -0,0 +1,9 @@ +from events.document_event import document_was_deleted +from tasks.clean_document_task import clean_document_task + + +@document_was_deleted.connect +def handle(sender, **kwargs): + document_id = sender + dataset_id = kwargs.get('dataset_id') + clean_document_task.delay(document_id, dataset_id) diff --git a/api/events/event_handlers/create_installed_app_when_app_created.py b/api/events/event_handlers/create_installed_app_when_app_created.py new file mode 100644 index 0000000000..31084ce0fe --- /dev/null +++ b/api/events/event_handlers/create_installed_app_when_app_created.py @@ -0,0 +1,16 @@ +from events.app_event import app_was_created +from extensions.ext_database import db +from models.model import InstalledApp + + +@app_was_created.connect +def handle(sender, **kwargs): + """Create an installed app when an app is created.""" + app = sender + installed_app = InstalledApp( + tenant_id=app.tenant_id, + app_id=app.id, + app_owner_tenant_id=app.tenant_id + ) + db.session.add(installed_app) + db.session.commit() diff --git a/api/events/event_handlers/create_provider_when_tenant_created.py b/api/events/event_handlers/create_provider_when_tenant_created.py new file mode 100644 index 0000000000..e967a5d071 --- /dev/null +++ b/api/events/event_handlers/create_provider_when_tenant_created.py @@ -0,0 +1,9 @@ +from events.tenant_event import tenant_was_updated +from services.provider_service import ProviderService + + +@tenant_was_updated.connect +def handle(sender, **kwargs): + tenant = sender + if tenant.status == 'normal': + ProviderService.create_system_provider(tenant) diff --git a/api/events/event_handlers/create_provider_when_tenant_updated.py b/api/events/event_handlers/create_provider_when_tenant_updated.py new file mode 100644 index 0000000000..81a7d40ff6 --- /dev/null +++ b/api/events/event_handlers/create_provider_when_tenant_updated.py @@ -0,0 +1,9 @@ +from events.tenant_event import tenant_was_created +from services.provider_service import ProviderService + + +@tenant_was_created.connect +def handle(sender, **kwargs): + tenant = sender + if tenant.status == 'normal': + ProviderService.create_system_provider(tenant) diff --git a/api/events/event_handlers/delete_installed_app_when_app_deleted.py b/api/events/event_handlers/delete_installed_app_when_app_deleted.py new file mode 100644 index 0000000000..1d6271a466 --- /dev/null +++ b/api/events/event_handlers/delete_installed_app_when_app_deleted.py @@ -0,0 +1,12 @@ +from events.app_event import app_was_deleted +from extensions.ext_database import db +from models.model import InstalledApp + + +@app_was_deleted.connect +def handle(sender, **kwargs): + app = sender + installed_apps = db.session.query(InstalledApp).filter(InstalledApp.app_id == app.id).all() + for installed_app in installed_apps: + db.session.delete(installed_app) + db.session.commit() diff --git a/api/events/event_handlers/generate_conversation_name_when_first_message_created.py b/api/events/event_handlers/generate_conversation_name_when_first_message_created.py new file mode 100644 index 0000000000..4c1bbee53e --- /dev/null +++ b/api/events/event_handlers/generate_conversation_name_when_first_message_created.py @@ -0,0 +1,29 @@ +import logging + +from core.generator.llm_generator import LLMGenerator +from events.message_event import message_was_created +from extensions.ext_database import db + + +@message_was_created.connect +def handle(sender, **kwargs): + message = sender + conversation = kwargs.get('conversation') + is_first_message = kwargs.get('is_first_message') + + if is_first_message: + if conversation.mode == 'chat': + app_model = conversation.app + if not app_model: + return + + # generate conversation name + try: + name = LLMGenerator.generate_conversation_name(app_model.tenant_id, message.query, message.answer) + conversation.name = name + except: + conversation.name = 'New Chat' + logging.exception('generate_conversation_name failed') + + db.session.add(conversation) + db.session.commit() diff --git a/api/events/event_handlers/generate_conversation_summary_when_few_message_created.py b/api/events/event_handlers/generate_conversation_summary_when_few_message_created.py new file mode 100644 index 0000000000..df62a90b8e --- /dev/null +++ b/api/events/event_handlers/generate_conversation_summary_when_few_message_created.py @@ -0,0 +1,14 @@ +from events.message_event import message_was_created +from tasks.generate_conversation_summary_task import generate_conversation_summary_task + + +@message_was_created.connect +def handle(sender, **kwargs): + message = sender + conversation = kwargs.get('conversation') + is_first_message = kwargs.get('is_first_message') + + if not is_first_message and conversation.mode == 'chat' and not conversation.summary: + history_message_count = conversation.message_count + if history_message_count >= 5: + generate_conversation_summary_task.delay(conversation.id) diff --git a/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py b/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py new file mode 100644 index 0000000000..d165b014d6 --- /dev/null +++ b/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py @@ -0,0 +1,66 @@ +from events.app_event import app_model_config_was_updated +from extensions.ext_database import db +from models.dataset import AppDatasetJoin +from models.model import AppModelConfig + + +@app_model_config_was_updated.connect +def handle(sender, **kwargs): + app_model = sender + app_model_config = kwargs.get('app_model_config') + + dataset_ids = get_dataset_ids_from_model_config(app_model_config) + + app_dataset_joins = db.session.query(AppDatasetJoin).filter( + AppDatasetJoin.app_id == app_model.id + ).all() + + removed_dataset_ids = [] + if not app_dataset_joins: + added_dataset_ids = dataset_ids + else: + old_dataset_ids = set() + for app_dataset_join in app_dataset_joins: + old_dataset_ids.add(app_dataset_join.dataset_id) + + added_dataset_ids = dataset_ids - old_dataset_ids + removed_dataset_ids = old_dataset_ids - dataset_ids + + if removed_dataset_ids: + for dataset_id in removed_dataset_ids: + db.session.query(AppDatasetJoin).filter( + AppDatasetJoin.app_id == app_model.id, + AppDatasetJoin.dataset_id == dataset_id + ).delete() + + if added_dataset_ids: + for dataset_id in added_dataset_ids: + app_dataset_join = AppDatasetJoin( + app_id=app_model.id, + dataset_id=dataset_id + ) + db.session.add(app_dataset_join) + + db.session.commit() + + +def get_dataset_ids_from_model_config(app_model_config: AppModelConfig) -> set: + dataset_ids = set() + if not app_model_config: + return dataset_ids + + agent_mode = app_model_config.agent_mode_dict + if agent_mode.get('enabled') is False: + return dataset_ids + + if not agent_mode.get('tools'): + return dataset_ids + + tools = agent_mode.get('tools') + for tool in tools: + tool_type = list(tool.keys())[0] + tool_config = list(tool.values())[0] + if tool_type == "dataset": + dataset_ids.add(tool_config.get("id")) + + return dataset_ids diff --git a/api/events/message_event.py b/api/events/message_event.py new file mode 100644 index 0000000000..21da83f249 --- /dev/null +++ b/api/events/message_event.py @@ -0,0 +1,4 @@ +from blinker import signal + +# sender: message, kwargs: conversation +message_was_created = signal('message-was-created') diff --git a/api/events/tenant_event.py b/api/events/tenant_event.py new file mode 100644 index 0000000000..942f709917 --- /dev/null +++ b/api/events/tenant_event.py @@ -0,0 +1,7 @@ +from blinker import signal + +# sender: tenant +tenant_was_created = signal('tenant-was-created') + +# sender: tenant +tenant_was_updated = signal('tenant-was-updated') diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py new file mode 100644 index 0000000000..f738b984d9 --- /dev/null +++ b/api/extensions/ext_celery.py @@ -0,0 +1,23 @@ +from celery import Task, Celery +from flask import Flask + + +def init_app(app: Flask) -> Celery: + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery( + app.name, + task_cls=FlaskTask, + broker=app.config["CELERY_BROKER_URL"], + backend=app.config["CELERY_BACKEND"], + task_ignore_result=True, + ) + celery_app.conf.update( + result_backend=app.config["CELERY_RESULT_BACKEND"], + ) + celery_app.set_default() + app.extensions["celery"] = celery_app + return celery_app diff --git a/api/extensions/ext_database.py b/api/extensions/ext_database.py new file mode 100644 index 0000000000..9121c6ead9 --- /dev/null +++ b/api/extensions/ext_database.py @@ -0,0 +1,7 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def init_app(app): + db.init_app(app) diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py new file mode 100644 index 0000000000..f7d5cffdda --- /dev/null +++ b/api/extensions/ext_login.py @@ -0,0 +1,7 @@ +import flask_login + +login_manager = flask_login.LoginManager() + + +def init_app(app): + login_manager.init_app(app) diff --git a/api/extensions/ext_migrate.py b/api/extensions/ext_migrate.py new file mode 100644 index 0000000000..e7b278fc38 --- /dev/null +++ b/api/extensions/ext_migrate.py @@ -0,0 +1,5 @@ +import flask_migrate + + +def init(app, db): + flask_migrate.Migrate(app, db) diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py new file mode 100644 index 0000000000..c3e021e798 --- /dev/null +++ b/api/extensions/ext_redis.py @@ -0,0 +1,18 @@ +import redis + + +redis_client = redis.Redis() + + +def init_app(app): + redis_client.connection_pool = redis.ConnectionPool(**{ + 'host': app.config.get('REDIS_HOST', 'localhost'), + 'port': app.config.get('REDIS_PORT', 6379), + 'password': app.config.get('REDIS_PASSWORD', None), + 'db': app.config.get('REDIS_DB', 0), + 'encoding': 'utf-8', + 'encoding_errors': 'strict', + 'decode_responses': False + }) + + app.extensions['redis'] = redis_client diff --git a/api/extensions/ext_sentry.py b/api/extensions/ext_sentry.py new file mode 100644 index 0000000000..f05c10bc08 --- /dev/null +++ b/api/extensions/ext_sentry.py @@ -0,0 +1,20 @@ +import sentry_sdk +from sentry_sdk.integrations.celery import CeleryIntegration +from sentry_sdk.integrations.flask import FlaskIntegration +from werkzeug.exceptions import HTTPException + + +def init_app(app): + if app.config.get('SENTRY_DSN'): + sentry_sdk.init( + dsn=app.config.get('SENTRY_DSN'), + integrations=[ + FlaskIntegration(), + CeleryIntegration() + ], + ignore_errors=[HTTPException, ValueError], + traces_sample_rate=app.config.get('SENTRY_TRACES_SAMPLE_RATE', 1.0), + profiles_sample_rate=app.config.get('SENTRY_PROFILES_SAMPLE_RATE', 1.0), + environment=app.config.get('DEPLOY_ENV'), + release=f"dify-{app.config.get('CURRENT_VERSION')}-{app.config.get('COMMIT_SHA')}" + ) diff --git a/api/extensions/ext_session.py b/api/extensions/ext_session.py new file mode 100644 index 0000000000..5b454d469e --- /dev/null +++ b/api/extensions/ext_session.py @@ -0,0 +1,168 @@ +import redis +from flask import request +from flask_session import Session, SqlAlchemySessionInterface, RedisSessionInterface +from flask_session.sessions import total_seconds +from itsdangerous import want_bytes + +from extensions.ext_database import db + +sess = Session() + + +def init_app(app): + sqlalchemy_session_interface = CustomSqlAlchemySessionInterface( + app, + db, + app.config.get('SESSION_SQLALCHEMY_TABLE', 'sessions'), + app.config.get('SESSION_KEY_PREFIX', 'session:'), + app.config.get('SESSION_USE_SIGNER', False), + app.config.get('SESSION_PERMANENT', True) + ) + + session_type = app.config.get('SESSION_TYPE') + if session_type == 'sqlalchemy': + app.session_interface = sqlalchemy_session_interface + elif session_type == 'redis': + sess_redis_client = redis.Redis() + sess_redis_client.connection_pool = redis.ConnectionPool(**{ + 'host': app.config.get('SESSION_REDIS_HOST', 'localhost'), + 'port': app.config.get('SESSION_REDIS_PORT', 6379), + 'password': app.config.get('SESSION_REDIS_PASSWORD', None), + 'db': app.config.get('SESSION_REDIS_DB', 2), + 'encoding': 'utf-8', + 'encoding_errors': 'strict', + 'decode_responses': False + }) + + app.extensions['session_redis'] = sess_redis_client + + app.session_interface = CustomRedisSessionInterface( + sess_redis_client, + app.config.get('SESSION_KEY_PREFIX', 'session:'), + app.config.get('SESSION_USE_SIGNER', False), + app.config.get('SESSION_PERMANENT', True) + ) + + +class CustomSqlAlchemySessionInterface(SqlAlchemySessionInterface): + + def __init__( + self, + app, + db, + table, + key_prefix, + use_signer=False, + permanent=True, + sequence=None, + autodelete=False, + ): + if db is None: + from flask_sqlalchemy import SQLAlchemy + + db = SQLAlchemy(app) + self.db = db + self.key_prefix = key_prefix + self.use_signer = use_signer + self.permanent = permanent + self.autodelete = autodelete + self.sequence = sequence + self.has_same_site_capability = hasattr(self, "get_cookie_samesite") + + class Session(self.db.Model): + __tablename__ = table + + if sequence: + id = self.db.Column( # noqa: A003, VNE003, A001 + self.db.Integer, self.db.Sequence(sequence), primary_key=True + ) + else: + id = self.db.Column( # noqa: A003, VNE003, A001 + self.db.Integer, primary_key=True + ) + + session_id = self.db.Column(self.db.String(255), unique=True) + data = self.db.Column(self.db.LargeBinary) + expiry = self.db.Column(self.db.DateTime) + + def __init__(self, session_id, data, expiry): + self.session_id = session_id + self.data = data + self.expiry = expiry + + def __repr__(self): + return f"" + + self.sql_session_model = Session + + def save_session(self, *args, **kwargs): + if request.blueprint == 'service_api': + return + elif request.method == 'OPTIONS': + return + elif request.endpoint and request.endpoint == 'health': + return + return super().save_session(*args, **kwargs) + + +class CustomRedisSessionInterface(RedisSessionInterface): + + def save_session(self, app, session, response): + if request.blueprint == 'service_api': + return + elif request.method == 'OPTIONS': + return + elif request.endpoint and request.endpoint == 'health': + return + + if not self.should_set_cookie(app, session): + return + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + if not session: + if session.modified: + self.redis.delete(self.key_prefix + session.sid) + response.delete_cookie( + app.config["SESSION_COOKIE_NAME"], domain=domain, path=path + ) + return + + # Modification case. There are upsides and downsides to + # emitting a set-cookie header each request. The behavior + # is controlled by the :meth:`should_set_cookie` method + # which performs a quick check to figure out if the cookie + # should be set or not. This is controlled by the + # SESSION_REFRESH_EACH_REQUEST config flag as well as + # the permanent flag on the session itself. + # if not self.should_set_cookie(app, session): + # return + conditional_cookie_kwargs = {} + httponly = self.get_cookie_httponly(app) + secure = self.get_cookie_secure(app) + if self.has_same_site_capability: + conditional_cookie_kwargs["samesite"] = self.get_cookie_samesite(app) + expires = self.get_expiration_time(app, session) + + if session.permanent: + value = self.serializer.dumps(dict(session)) + if value is not None: + self.redis.setex( + name=self.key_prefix + session.sid, + value=value, + time=total_seconds(app.permanent_session_lifetime), + ) + + if self.use_signer: + session_id = self._get_signer(app).sign(want_bytes(session.sid)).decode("utf-8") + else: + session_id = session.sid + response.set_cookie( + app.config["SESSION_COOKIE_NAME"], + session_id, + expires=expires, + httponly=httponly, + domain=domain, + path=path, + secure=secure, + **conditional_cookie_kwargs, + ) diff --git a/api/extensions/ext_storage.py b/api/extensions/ext_storage.py new file mode 100644 index 0000000000..dc44892024 --- /dev/null +++ b/api/extensions/ext_storage.py @@ -0,0 +1,108 @@ +import os +import shutil +from contextlib import closing + +import boto3 +from botocore.exceptions import ClientError +from flask import Flask + + +class Storage: + def __init__(self): + self.storage_type = None + self.bucket_name = None + self.client = None + self.folder = None + + def init_app(self, app: Flask): + self.storage_type = app.config.get('STORAGE_TYPE') + if self.storage_type == 's3': + self.bucket_name = app.config.get('S3_BUCKET_NAME') + self.client = boto3.client( + 's3', + aws_secret_access_key=app.config.get('S3_SECRET_KEY'), + aws_access_key_id=app.config.get('S3_ACCESS_KEY'), + endpoint_url=app.config.get('S3_ENDPOINT'), + region_name=app.config.get('S3_REGION') + ) + else: + self.folder = app.config.get('STORAGE_LOCAL_PATH') + if not os.path.isabs(self.folder): + self.folder = os.path.join(app.root_path, self.folder) + + def save(self, filename, data): + if self.storage_type == 's3': + self.client.put_object(Bucket=self.bucket_name, Key=filename, Body=data) + else: + if not self.folder or self.folder.endswith('/'): + filename = self.folder + filename + else: + filename = self.folder + '/' + filename + + folder = os.path.dirname(filename) + os.makedirs(folder, exist_ok=True) + + with open(os.path.join(os.getcwd(), filename), "wb") as f: + f.write(data) + + def load(self, filename): + if self.storage_type == 's3': + try: + with closing(self.client) as client: + data = client.get_object(Bucket=self.bucket_name, Key=filename)['Body'].read() + except ClientError as ex: + if ex.response['Error']['Code'] == 'NoSuchKey': + raise FileNotFoundError("File not found") + else: + raise + else: + if not self.folder or self.folder.endswith('/'): + filename = self.folder + filename + else: + filename = self.folder + '/' + filename + + if not os.path.exists(filename): + raise FileNotFoundError("File not found") + + with open(filename, "rb") as f: + data = f.read() + + return data + + def download(self, filename, target_filepath): + if self.storage_type == 's3': + with closing(self.client) as client: + client.download_file(self.bucket_name, filename, target_filepath) + else: + if not self.folder or self.folder.endswith('/'): + filename = self.folder + filename + else: + filename = self.folder + '/' + filename + + if not os.path.exists(filename): + raise FileNotFoundError("File not found") + + shutil.copyfile(filename, target_filepath) + + def exists(self, filename): + if self.storage_type == 's3': + with closing(self.client) as client: + try: + client.head_object(Bucket=self.bucket_name, Key=filename) + return True + except: + return False + else: + if not self.folder or self.folder.endswith('/'): + filename = self.folder + filename + else: + filename = self.folder + '/' + filename + + return os.path.exists(filename) + + +storage = Storage() + + +def init_app(app: Flask): + storage.init_app(app) diff --git a/api/extensions/ext_vector_store.py b/api/extensions/ext_vector_store.py new file mode 100644 index 0000000000..4ed7a93422 --- /dev/null +++ b/api/extensions/ext_vector_store.py @@ -0,0 +1,7 @@ +from core.vector_store.vector_store import VectorStore + +vector_store = VectorStore() + + +def init_app(app): + vector_store.init_app(app) diff --git a/api/libs/__init__.py b/api/libs/__init__.py new file mode 100644 index 0000000000..380474e035 --- /dev/null +++ b/api/libs/__init__.py @@ -0,0 +1 @@ +# -*- coding:utf-8 -*- diff --git a/api/libs/ecc_aes.py b/api/libs/ecc_aes.py new file mode 100644 index 0000000000..aef3214535 --- /dev/null +++ b/api/libs/ecc_aes.py @@ -0,0 +1,82 @@ +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from Crypto.PublicKey import ECC +from Crypto.Util.Padding import pad, unpad + + +class ECC_AES: + def __init__(self, curve='P-256'): + self.curve = curve + self._aes_key = None + self._private_key = None + + def _derive_aes_key(self, ecc_key, nonce): + if not self._aes_key: + hasher = SHA256.new() + hasher.update(ecc_key.export_key(format='DER') + nonce.encode()) + self._aes_key = hasher.digest()[:32] + return self._aes_key + + def generate_key_pair(self): + private_key = ECC.generate(curve=self.curve) + public_key = private_key.public_key() + + pem_private = private_key.export_key(format='PEM') + pem_public = public_key.export_key(format='PEM') + + return pem_private, pem_public + + def load_private_key(self, private_key_pem): + self._private_key = ECC.import_key(private_key_pem) + self._aes_key = None + + def encrypt(self, text, nonce): + if not self._private_key: + raise ValueError("Private key not loaded") + + # Generate AES key using ECC private key and nonce + aes_key = self._derive_aes_key(self._private_key, nonce) + + # Encrypt data using AES key + cipher = AES.new(aes_key, AES.MODE_ECB) + padded_text = pad(text.encode(), AES.block_size) + ciphertext = cipher.encrypt(padded_text) + + return ciphertext + + def decrypt(self, ciphertext, nonce): + if not self._private_key: + raise ValueError("Private key not loaded") + + # Generate AES key using ECC private key and nonce + aes_key = self._derive_aes_key(self._private_key, nonce) + + # Decrypt data using AES key + cipher = AES.new(aes_key, AES.MODE_ECB) + padded_plaintext = cipher.decrypt(ciphertext) + plaintext = unpad(padded_plaintext, AES.block_size) + + return plaintext.decode() + + +if __name__ == '__main__': + ecc_aes = ECC_AES() + + # Generate key pairs for the user + private_key, public_key = ecc_aes.generate_key_pair() + ecc_aes.load_private_key(private_key) + nonce = "THIS-IS-USER-ID" + + print(private_key) + + # Encrypt a message + message = "Hello, this is a secret message!" + encrypted_message = ecc_aes.encrypt(message, nonce) + print(f"Encrypted message: {encrypted_message.hex()}") + + # Decrypt the message + decrypted_message = ecc_aes.decrypt(encrypted_message, nonce) + print(f"Decrypted message: {decrypted_message}") + + # Check if the original message and decrypted message are the same + assert message == decrypted_message, "Original message and decrypted message do not match" diff --git a/api/libs/exception.py b/api/libs/exception.py new file mode 100644 index 0000000000..567062f064 --- /dev/null +++ b/api/libs/exception.py @@ -0,0 +1,17 @@ +from typing import Optional + +from werkzeug.exceptions import HTTPException + + +class BaseHTTPException(HTTPException): + error_code: str = 'unknown' + data: Optional[dict] = None + + def __init__(self, description=None, response=None): + super().__init__(description, response) + + self.data = { + "code": self.error_code, + "message": self.description, + "status": self.code, + } \ No newline at end of file diff --git a/api/libs/external_api.py b/api/libs/external_api.py new file mode 100644 index 0000000000..b5cc8fb9c5 --- /dev/null +++ b/api/libs/external_api.py @@ -0,0 +1,115 @@ +import re +import sys + +from flask import got_request_exception, current_app +from flask_restful import Api, http_status_message +from werkzeug.datastructures import Headers +from werkzeug.exceptions import HTTPException + + +class ExternalApi(Api): + + def handle_error(self, e): + """Error handler for the API transforms a raised exception into a Flask + response, with the appropriate HTTP status code and body. + + :param e: the raised Exception object + :type e: Exception + + """ + got_request_exception.send(current_app, exception=e) + + headers = Headers() + if isinstance(e, HTTPException): + if e.response is not None: + resp = e.get_response() + return resp + + status_code = e.code + default_data = { + 'code': re.sub(r'(?= 500: + exc_info = sys.exc_info() + if exc_info[1] is None: + exc_info = None + current_app.log_exception(exc_info) + + if status_code == 406 and self.default_mediatype is None: + # if we are handling NotAcceptable (406), make sure that + # make_response uses a representation we support as the + # default mediatype (so that make_response doesn't throw + # another NotAcceptable error). + supported_mediatypes = list(self.representations.keys()) # only supported application/json + fallback_mediatype = supported_mediatypes[0] if supported_mediatypes else "text/plain" + data = { + 'code': 'not_acceptable', + 'message': data.get('message') + } + resp = self.make_response( + data, + status_code, + headers, + fallback_mediatype = fallback_mediatype + ) + elif status_code == 400: + if isinstance(data.get('message'), dict): + param_key, param_value = list(data.get('message').items())[0] + data = { + 'code': 'invalid_param', + 'message': param_value, + 'params': param_key + } + else: + if 'code' not in data: + data['code'] = 'unknown' + + resp = self.make_response(data, status_code, headers) + else: + if 'code' not in data: + data['code'] = 'unknown' + + resp = self.make_response(data, status_code, headers) + + if status_code == 401: + resp = self.unauthorized(resp) + return resp diff --git a/api/libs/helper.py b/api/libs/helper.py new file mode 100644 index 0000000000..bbf01cbad7 --- /dev/null +++ b/api/libs/helper.py @@ -0,0 +1,149 @@ +# -*- coding:utf-8 -*- +import re +import subprocess +import uuid +from datetime import datetime +from zoneinfo import available_timezones +import random +import string + +from flask_restful import fields + + +def run(script): + return subprocess.getstatusoutput('source /root/.bashrc && ' + script) + + +class TimestampField(fields.Raw): + def format(self, value): + return int(value.timestamp()) + + +def email(email): + # Define a regex pattern for email addresses + pattern = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" + # Check if the email matches the pattern + if re.match(pattern, email) is not None: + return email + + error = ('{email} is not a valid email.' + .format(email=email)) + raise ValueError(error) + + +def uuid_value(value): + if value == '': + return str(value) + + try: + uuid_obj = uuid.UUID(value) + return str(uuid_obj) + except ValueError: + error = ('{value} is not a valid uuid.' + .format(value=value)) + raise ValueError(error) + + +def timestamp_value(timestamp): + try: + int_timestamp = int(timestamp) + if int_timestamp < 0: + raise ValueError + return int_timestamp + except ValueError: + error = ('{timestamp} is not a valid timestamp.' + .format(timestamp=timestamp)) + raise ValueError(error) + + +class str_len(object): + """ Restrict input to an integer in a range (inclusive) """ + + def __init__(self, max_length, argument='argument'): + self.max_length = max_length + self.argument = argument + + def __call__(self, value): + length = len(value) + if length > self.max_length: + error = ('Invalid {arg}: {val}. {arg} cannot exceed length {length}' + .format(arg=self.argument, val=value, length=self.max_length)) + raise ValueError(error) + + return value + + +class float_range(object): + """ Restrict input to an float in a range (inclusive) """ + def __init__(self, low, high, argument='argument'): + self.low = low + self.high = high + self.argument = argument + + def __call__(self, value): + value = _get_float(value) + if value < self.low or value > self.high: + error = ('Invalid {arg}: {val}. {arg} must be within the range {lo} - {hi}' + .format(arg=self.argument, val=value, lo=self.low, hi=self.high)) + raise ValueError(error) + + return value + + +class datetime_string(object): + def __init__(self, format, argument='argument'): + self.format = format + self.argument = argument + + def __call__(self, value): + try: + datetime.strptime(value, self.format) + except ValueError: + error = ('Invalid {arg}: {val}. {arg} must be conform to the format {format}' + .format(arg=self.argument, val=value, lo=self.format)) + raise ValueError(error) + + return value + + +def _get_float(value): + try: + return float(value) + except (TypeError, ValueError): + raise ValueError('{0} is not a valid float'.format(value)) + + +def supported_language(lang): + if lang in ['en-US', 'zh-Hans']: + return lang + + error = ('{lang} is not a valid language.' + .format(lang=lang)) + raise ValueError(error) + + +def timezone(timezone_string): + if timezone_string and timezone_string in available_timezones(): + return timezone_string + + error = ('{timezone_string} is not a valid timezone.' + .format(timezone_string=timezone_string)) + raise ValueError(error) + + +def generate_string(n): + letters_digits = string.ascii_letters + string.digits + result = "" + for i in range(n): + result += random.choice(letters_digits) + + return result + + +def get_remote_ip(request): + if request.headers.get('CF-Connecting-IP'): + return request.headers.get('Cf-Connecting-Ip') + elif request.headers.getlist("X-Forwarded-For"): + return request.headers.getlist("X-Forwarded-For")[0] + else: + return request.remote_addr diff --git a/api/libs/infinite_scroll_pagination.py b/api/libs/infinite_scroll_pagination.py new file mode 100644 index 0000000000..076cb383b8 --- /dev/null +++ b/api/libs/infinite_scroll_pagination.py @@ -0,0 +1,7 @@ +# -*- coding:utf-8 -*- + +class InfiniteScrollPagination: + def __init__(self, data, limit, has_more): + self.data = data + self.limit = limit + self.has_more = has_more diff --git a/api/libs/oauth.py b/api/libs/oauth.py new file mode 100644 index 0000000000..ce41f0c22c --- /dev/null +++ b/api/libs/oauth.py @@ -0,0 +1,136 @@ +import urllib.parse +from dataclasses import dataclass + +import requests + + +@dataclass +class OAuthUserInfo: + id: str + name: str + email: str + + +class OAuth: + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_authorization_url(self): + raise NotImplementedError() + + def get_access_token(self, code: str): + raise NotImplementedError() + + def get_raw_user_info(self, token: str): + raise NotImplementedError() + + def get_user_info(self, token: str) -> OAuthUserInfo: + raw_info = self.get_raw_user_info(token) + return self._transform_user_info(raw_info) + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + raise NotImplementedError() + + +class GitHubOAuth(OAuth): + _AUTH_URL = 'https://github.com/login/oauth/authorize' + _TOKEN_URL = 'https://github.com/login/oauth/access_token' + _USER_INFO_URL = 'https://api.github.com/user' + _EMAIL_INFO_URL = 'https://api.github.com/user/emails' + + def get_authorization_url(self): + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'scope': 'user:email' # Request only basic user information + } + return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str): + data = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri + } + headers = {'Accept': 'application/json'} + response = requests.post(self._TOKEN_URL, data=data, headers=headers) + + response_json = response.json() + access_token = response_json.get('access_token') + + if not access_token: + raise ValueError(f"Error in GitHub OAuth: {response_json}") + + return access_token + + def get_raw_user_info(self, token: str): + headers = {'Authorization': f"token {token}"} + response = requests.get(self._USER_INFO_URL, headers=headers) + response.raise_for_status() + user_info = response.json() + + email_response = requests.get(self._EMAIL_INFO_URL, headers=headers) + email_info = email_response.json() + primary_email = next((email for email in email_info if email['primary'] == True), None) + + return {**user_info, 'email': primary_email['email']} + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + email = raw_info.get('email') + if not email: + email = f"{raw_info['id']}+{raw_info['login']}@users.noreply.github.com" + return OAuthUserInfo( + id=str(raw_info['id']), + name=raw_info['name'], + email=email + ) + + +class GoogleOAuth(OAuth): + _AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + _TOKEN_URL = 'https://oauth2.googleapis.com/token' + _USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' + + def get_authorization_url(self): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': self.redirect_uri, + 'scope': 'openid email' + } + return f"{self._AUTH_URL}?{urllib.parse.urlencode(params)}" + + def get_access_token(self, code: str): + data = { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': self.redirect_uri + } + headers = {'Accept': 'application/json'} + response = requests.post(self._TOKEN_URL, data=data, headers=headers) + + response_json = response.json() + access_token = response_json.get('access_token') + + if not access_token: + raise ValueError(f"Error in Google OAuth: {response_json}") + + return access_token + + def get_raw_user_info(self, token: str): + headers = {'Authorization': f"Bearer {token}"} + response = requests.get(self._USER_INFO_URL, headers=headers) + response.raise_for_status() + return response.json() + + def _transform_user_info(self, raw_info: dict) -> OAuthUserInfo: + return OAuthUserInfo( + id=str(raw_info['sub']), + name=None, + email=raw_info['email'] + ) diff --git a/api/libs/password.py b/api/libs/password.py new file mode 100644 index 0000000000..dde77c1046 --- /dev/null +++ b/api/libs/password.py @@ -0,0 +1,26 @@ +# -*- coding:utf-8 -*- +import base64 +import binascii +import hashlib +import re + +password_pattern = r"^(?=.*[a-zA-Z])(?=.*\d).{8,}$" + +def valid_password(password): + # Define a regex pattern for password rules + pattern = password_pattern + # Check if the password matches the pattern + if re.match(pattern, password) is not None: + return password + + raise ValueError('Not a valid password.') + + +def hash_password(password_str, salt_byte): + dk = hashlib.pbkdf2_hmac('sha256', password_str.encode('utf-8'), salt_byte, 10000) + return binascii.hexlify(dk) + + +def compare_password(password_str, password_hashed_base64, salt_base64): + # compare password for login + return hash_password(password_str, base64.b64decode(salt_base64)) == base64.b64decode(password_hashed_base64) diff --git a/api/libs/rsa.py b/api/libs/rsa.py new file mode 100644 index 0000000000..8741989a9a --- /dev/null +++ b/api/libs/rsa.py @@ -0,0 +1,58 @@ +# -*- coding:utf-8 -*- +import hashlib + +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA + +from extensions.ext_redis import redis_client +from extensions.ext_storage import storage + + +# TODO: PKCS1_OAEP is no longer recommended for new systems and protocols. It is recommended to migrate to PKCS1_PSS. + + +def generate_key_pair(tenant_id): + private_key = RSA.generate(2048) + public_key = private_key.publickey() + + pem_private = private_key.export_key() + pem_public = public_key.export_key() + + filepath = "privkeys/{tenant_id}".format(tenant_id=tenant_id) + "/private.pem" + + storage.save(filepath, pem_private) + + return pem_public.decode() + + +def encrypt(text, public_key): + if isinstance(public_key, str): + public_key = public_key.encode() + + rsa_key = RSA.import_key(public_key) + cipher = PKCS1_OAEP.new(rsa_key) + encrypted_text = cipher.encrypt(text.encode()) + return encrypted_text + + +def decrypt(encrypted_text, tenant_id): + filepath = "privkeys/{tenant_id}".format(tenant_id=tenant_id) + "/private.pem" + + cache_key = 'tenant_privkey:{hash}'.format(hash=hashlib.sha3_256(filepath.encode()).hexdigest()) + private_key = redis_client.get(cache_key) + if not private_key: + try: + private_key = storage.load(filepath) + except FileNotFoundError: + raise PrivkeyNotFoundError("Private key not found, tenant_id: {tenant_id}".format(tenant_id=tenant_id)) + + redis_client.setex(cache_key, 120, private_key) + + rsa_key = RSA.import_key(private_key) + cipher = PKCS1_OAEP.new(rsa_key) + decrypted_text = cipher.decrypt(encrypted_text) + return decrypted_text.decode() + + +class PrivkeyNotFoundError(Exception): + pass diff --git a/api/migrations/README b/api/migrations/README new file mode 100644 index 0000000000..0e04844159 --- /dev/null +++ b/api/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/api/migrations/alembic.ini b/api/migrations/alembic.ini new file mode 100644 index 0000000000..ec9d45c26a --- /dev/null +++ b/api/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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/api/migrations/env.py b/api/migrations/env.py new file mode 100644 index 0000000000..0ac25ee989 --- /dev/null +++ b/api/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# 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. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# 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. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def include_object(object, name, type_, reflected, compare_to): + if type_ == "foreign_key_constraint": + return False + else: + return True + + +def run_migrations_offline(): + """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 = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + process_revision_directives=process_revision_directives, + include_object=include_object, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/migrations/script.py.mako b/api/migrations/script.py.mako new file mode 100644 index 0000000000..2c0156303a --- /dev/null +++ b/api/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${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(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/api/migrations/versions/64b051264f32_init.py b/api/migrations/versions/64b051264f32_init.py new file mode 100644 index 0000000000..84e1f2af17 --- /dev/null +++ b/api/migrations/versions/64b051264f32_init.py @@ -0,0 +1,793 @@ +"""init + +Revision ID: 64b051264f32 +Revises: +Create Date: 2023-05-13 14:26:59.085018 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '64b051264f32' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('account_integrates', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('account_id', postgresql.UUID(), nullable=False), + sa.Column('provider', sa.String(length=16), nullable=False), + sa.Column('open_id', sa.String(length=255), nullable=False), + sa.Column('encrypted_token', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='account_integrate_pkey'), + sa.UniqueConstraint('account_id', 'provider', name='unique_account_provider'), + sa.UniqueConstraint('provider', 'open_id', name='unique_provider_open_id') + ) + op.create_table('accounts', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('password', sa.String(length=255), nullable=True), + sa.Column('password_salt', sa.String(length=255), nullable=True), + sa.Column('avatar', sa.String(length=255), nullable=True), + sa.Column('interface_language', sa.String(length=255), nullable=True), + sa.Column('interface_theme', sa.String(length=255), nullable=True), + sa.Column('timezone', sa.String(length=255), nullable=True), + sa.Column('last_login_at', sa.DateTime(), nullable=True), + sa.Column('last_login_ip', sa.String(length=255), nullable=True), + sa.Column('status', sa.String(length=16), server_default=sa.text("'active'::character varying"), nullable=False), + sa.Column('initialized_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='account_pkey') + ) + with op.batch_alter_table('accounts', schema=None) as batch_op: + batch_op.create_index('account_email_idx', ['email'], unique=False) + + op.create_table('api_requests', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('api_token_id', postgresql.UUID(), nullable=False), + sa.Column('path', sa.String(length=255), nullable=False), + sa.Column('request', sa.Text(), nullable=True), + sa.Column('response', sa.Text(), nullable=True), + sa.Column('ip', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='api_request_pkey') + ) + with op.batch_alter_table('api_requests', schema=None) as batch_op: + batch_op.create_index('api_request_token_idx', ['tenant_id', 'api_token_id'], unique=False) + + op.create_table('api_tokens', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=True), + sa.Column('dataset_id', postgresql.UUID(), nullable=True), + sa.Column('type', sa.String(length=16), nullable=False), + sa.Column('token', sa.String(length=255), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='api_token_pkey') + ) + with op.batch_alter_table('api_tokens', schema=None) as batch_op: + batch_op.create_index('api_token_app_id_type_idx', ['app_id', 'type'], unique=False) + batch_op.create_index('api_token_token_idx', ['token', 'type'], unique=False) + + op.create_table('app_dataset_joins', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_dataset_join_pkey') + ) + with op.batch_alter_table('app_dataset_joins', schema=None) as batch_op: + batch_op.create_index('app_dataset_join_app_dataset_idx', ['dataset_id', 'app_id'], unique=False) + + op.create_table('app_model_configs', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('provider', sa.String(length=255), nullable=False), + sa.Column('model_id', sa.String(length=255), nullable=False), + sa.Column('configs', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('opening_statement', sa.Text(), nullable=True), + sa.Column('suggested_questions', sa.Text(), nullable=True), + sa.Column('suggested_questions_after_answer', sa.Text(), nullable=True), + sa.Column('more_like_this', sa.Text(), nullable=True), + sa.Column('model', sa.Text(), nullable=True), + sa.Column('user_input_form', sa.Text(), nullable=True), + sa.Column('pre_prompt', sa.Text(), nullable=True), + sa.Column('agent_mode', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id', name='app_model_config_pkey') + ) + with op.batch_alter_table('app_model_configs', schema=None) as batch_op: + batch_op.create_index('app_app_id_idx', ['app_id'], unique=False) + + op.create_table('apps', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('mode', sa.String(length=255), nullable=False), + sa.Column('icon', sa.String(length=255), nullable=True), + sa.Column('icon_background', sa.String(length=255), nullable=True), + sa.Column('app_model_config_id', postgresql.UUID(), nullable=True), + sa.Column('status', sa.String(length=255), server_default=sa.text("'normal'::character varying"), nullable=False), + sa.Column('enable_site', sa.Boolean(), nullable=False), + sa.Column('enable_api', sa.Boolean(), nullable=False), + sa.Column('api_rpm', sa.Integer(), nullable=False), + sa.Column('api_rph', sa.Integer(), nullable=False), + sa.Column('is_demo', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('is_public', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_pkey') + ) + with op.batch_alter_table('apps', schema=None) as batch_op: + batch_op.create_index('app_tenant_id_idx', ['tenant_id'], unique=False) + + op.execute('CREATE SEQUENCE task_id_sequence;') + op.execute('CREATE SEQUENCE taskset_id_sequence;') + + op.create_table('celery_taskmeta', + sa.Column('id', sa.Integer(), nullable=False, + server_default=sa.text('nextval(\'task_id_sequence\')')), + sa.Column('task_id', sa.String(length=155), nullable=True), + sa.Column('status', sa.String(length=50), nullable=True), + sa.Column('result', sa.PickleType(), nullable=True), + sa.Column('date_done', sa.DateTime(), nullable=True), + sa.Column('traceback', sa.Text(), nullable=True), + sa.Column('name', sa.String(length=155), nullable=True), + sa.Column('args', sa.LargeBinary(), nullable=True), + sa.Column('kwargs', sa.LargeBinary(), nullable=True), + sa.Column('worker', sa.String(length=155), nullable=True), + sa.Column('retries', sa.Integer(), nullable=True), + sa.Column('queue', sa.String(length=155), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('task_id') + ) + op.create_table('celery_tasksetmeta', + sa.Column('id', sa.Integer(), nullable=False, + server_default=sa.text('nextval(\'taskset_id_sequence\')')), + sa.Column('taskset_id', sa.String(length=155), nullable=True), + sa.Column('result', sa.PickleType(), nullable=True), + sa.Column('date_done', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('taskset_id') + ) + op.create_table('conversations', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('app_model_config_id', postgresql.UUID(), nullable=False), + sa.Column('model_provider', sa.String(length=255), nullable=False), + sa.Column('override_model_configs', sa.Text(), nullable=True), + sa.Column('model_id', sa.String(length=255), nullable=False), + sa.Column('mode', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('summary', sa.Text(), nullable=True), + sa.Column('inputs', sa.JSON(), nullable=True), + sa.Column('introduction', sa.Text(), nullable=True), + sa.Column('system_instruction', sa.Text(), nullable=True), + sa.Column('system_instruction_tokens', sa.Integer(), server_default=sa.text('0'), nullable=False), + sa.Column('status', sa.String(length=255), nullable=False), + sa.Column('from_source', sa.String(length=255), nullable=False), + sa.Column('from_end_user_id', postgresql.UUID(), nullable=True), + sa.Column('from_account_id', postgresql.UUID(), nullable=True), + sa.Column('read_at', sa.DateTime(), nullable=True), + sa.Column('read_account_id', postgresql.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='conversation_pkey') + ) + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.create_index('conversation_app_from_user_idx', ['app_id', 'from_source', 'from_end_user_id'], unique=False) + + op.create_table('dataset_keyword_tables', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('keyword_table', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('id', name='dataset_keyword_table_pkey'), + sa.UniqueConstraint('dataset_id') + ) + with op.batch_alter_table('dataset_keyword_tables', schema=None) as batch_op: + batch_op.create_index('dataset_keyword_table_dataset_id_idx', ['dataset_id'], unique=False) + + op.create_table('dataset_process_rules', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('mode', sa.String(length=255), server_default=sa.text("'automatic'::character varying"), nullable=False), + sa.Column('rules', sa.Text(), nullable=True), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='dataset_process_rule_pkey') + ) + with op.batch_alter_table('dataset_process_rules', schema=None) as batch_op: + batch_op.create_index('dataset_process_rule_dataset_id_idx', ['dataset_id'], unique=False) + + op.create_table('dataset_queries', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('source', sa.String(length=255), nullable=False), + sa.Column('source_app_id', postgresql.UUID(), nullable=True), + sa.Column('created_by_role', sa.String(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='dataset_query_pkey') + ) + with op.batch_alter_table('dataset_queries', schema=None) as batch_op: + batch_op.create_index('dataset_query_dataset_id_idx', ['dataset_id'], unique=False) + + op.create_table('datasets', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('provider', sa.String(length=255), server_default=sa.text("'vendor'::character varying"), nullable=False), + sa.Column('permission', sa.String(length=255), server_default=sa.text("'only_me'::character varying"), nullable=False), + sa.Column('data_source_type', sa.String(length=255), nullable=True), + sa.Column('indexing_technique', sa.String(length=255), nullable=True), + sa.Column('index_struct', sa.Text(), nullable=True), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_by', postgresql.UUID(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='dataset_pkey') + ) + with op.batch_alter_table('datasets', schema=None) as batch_op: + batch_op.create_index('dataset_tenant_idx', ['tenant_id'], unique=False) + + op.create_table('dify_setups', + sa.Column('version', sa.String(length=255), nullable=False), + sa.Column('setup_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('version', name='dify_setup_pkey') + ) + op.create_table('document_segments', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('document_id', postgresql.UUID(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('word_count', sa.Integer(), nullable=False), + sa.Column('tokens', sa.Integer(), nullable=False), + sa.Column('keywords', sa.JSON(), nullable=True), + sa.Column('index_node_id', sa.String(length=255), nullable=True), + sa.Column('index_node_hash', sa.String(length=255), nullable=True), + sa.Column('hit_count', sa.Integer(), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('disabled_at', sa.DateTime(), nullable=True), + sa.Column('disabled_by', postgresql.UUID(), nullable=True), + sa.Column('status', sa.String(length=255), server_default=sa.text("'waiting'::character varying"), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('indexing_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('stopped_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id', name='document_segment_pkey') + ) + with op.batch_alter_table('document_segments', schema=None) as batch_op: + batch_op.create_index('document_segment_dataset_id_idx', ['dataset_id'], unique=False) + batch_op.create_index('document_segment_dataset_node_idx', ['dataset_id', 'index_node_id'], unique=False) + batch_op.create_index('document_segment_document_id_idx', ['document_id'], unique=False) + batch_op.create_index('document_segment_tenant_dataset_idx', ['dataset_id', 'tenant_id'], unique=False) + batch_op.create_index('document_segment_tenant_document_idx', ['document_id', 'tenant_id'], unique=False) + + op.create_table('documents', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('dataset_id', postgresql.UUID(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('data_source_type', sa.String(length=255), nullable=False), + sa.Column('data_source_info', sa.Text(), nullable=True), + sa.Column('dataset_process_rule_id', postgresql.UUID(), nullable=True), + sa.Column('batch', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_from', sa.String(length=255), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_api_request_id', postgresql.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('processing_started_at', sa.DateTime(), nullable=True), + sa.Column('file_id', sa.Text(), nullable=True), + sa.Column('word_count', sa.Integer(), nullable=True), + sa.Column('parsing_completed_at', sa.DateTime(), nullable=True), + sa.Column('cleaning_completed_at', sa.DateTime(), nullable=True), + sa.Column('splitting_completed_at', sa.DateTime(), nullable=True), + sa.Column('tokens', sa.Integer(), nullable=True), + sa.Column('indexing_latency', sa.Float(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('is_paused', sa.Boolean(), server_default=sa.text('false'), nullable=True), + sa.Column('paused_by', postgresql.UUID(), nullable=True), + sa.Column('paused_at', sa.DateTime(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('stopped_at', sa.DateTime(), nullable=True), + sa.Column('indexing_status', sa.String(length=255), server_default=sa.text("'waiting'::character varying"), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('disabled_at', sa.DateTime(), nullable=True), + sa.Column('disabled_by', postgresql.UUID(), nullable=True), + sa.Column('archived', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('archived_reason', sa.String(length=255), nullable=True), + sa.Column('archived_by', postgresql.UUID(), nullable=True), + sa.Column('archived_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('doc_type', sa.String(length=40), nullable=True), + sa.Column('doc_metadata', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id', name='document_pkey') + ) + with op.batch_alter_table('documents', schema=None) as batch_op: + batch_op.create_index('document_dataset_id_idx', ['dataset_id'], unique=False) + batch_op.create_index('document_is_paused_idx', ['is_paused'], unique=False) + + op.create_table('embeddings', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('hash', sa.String(length=64), nullable=False), + sa.Column('embedding', sa.LargeBinary(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='embedding_pkey'), + sa.UniqueConstraint('hash', name='embedding_hash_idx') + ) + op.create_table('end_users', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=True), + sa.Column('type', sa.String(length=255), nullable=False), + sa.Column('external_user_id', sa.String(length=255), nullable=True), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('is_anonymous', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('session_id', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='end_user_pkey') + ) + with op.batch_alter_table('end_users', schema=None) as batch_op: + batch_op.create_index('end_user_session_id_idx', ['session_id', 'type'], unique=False) + batch_op.create_index('end_user_tenant_session_id_idx', ['tenant_id', 'session_id', 'type'], unique=False) + + op.create_table('installed_apps', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('app_owner_tenant_id', postgresql.UUID(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('is_pinned', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='installed_app_pkey'), + sa.UniqueConstraint('tenant_id', 'app_id', name='unique_tenant_app') + ) + with op.batch_alter_table('installed_apps', schema=None) as batch_op: + batch_op.create_index('installed_app_app_id_idx', ['app_id'], unique=False) + batch_op.create_index('installed_app_tenant_id_idx', ['tenant_id'], unique=False) + + op.create_table('invitation_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('batch', sa.String(length=255), nullable=False), + sa.Column('code', sa.String(length=32), nullable=False), + sa.Column('status', sa.String(length=16), server_default=sa.text("'unused'::character varying"), nullable=False), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('used_by_tenant_id', postgresql.UUID(), nullable=True), + sa.Column('used_by_account_id', postgresql.UUID(), nullable=True), + sa.Column('deprecated_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='invitation_code_pkey') + ) + with op.batch_alter_table('invitation_codes', schema=None) as batch_op: + batch_op.create_index('invitation_codes_batch_idx', ['batch'], unique=False) + batch_op.create_index('invitation_codes_code_idx', ['code', 'status'], unique=False) + + op.create_table('message_agent_thoughts', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('message_id', postgresql.UUID(), nullable=False), + sa.Column('message_chain_id', postgresql.UUID(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('thought', sa.Text(), nullable=True), + sa.Column('tool', sa.Text(), nullable=True), + sa.Column('tool_input', sa.Text(), nullable=True), + sa.Column('observation', sa.Text(), nullable=True), + sa.Column('tool_process_data', sa.Text(), nullable=True), + sa.Column('message', sa.Text(), nullable=True), + sa.Column('message_token', sa.Integer(), nullable=True), + sa.Column('message_unit_price', sa.Numeric(), nullable=True), + sa.Column('answer', sa.Text(), nullable=True), + sa.Column('answer_token', sa.Integer(), nullable=True), + sa.Column('answer_unit_price', sa.Numeric(), nullable=True), + sa.Column('tokens', sa.Integer(), nullable=True), + sa.Column('total_price', sa.Numeric(), nullable=True), + sa.Column('currency', sa.String(), nullable=True), + sa.Column('latency', sa.Float(), nullable=True), + sa.Column('created_by_role', sa.String(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='message_agent_thought_pkey') + ) + with op.batch_alter_table('message_agent_thoughts', schema=None) as batch_op: + batch_op.create_index('message_agent_thought_message_chain_id_idx', ['message_chain_id'], unique=False) + batch_op.create_index('message_agent_thought_message_id_idx', ['message_id'], unique=False) + + op.create_table('message_chains', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('message_id', postgresql.UUID(), nullable=False), + sa.Column('type', sa.String(length=255), nullable=False), + sa.Column('input', sa.Text(), nullable=True), + sa.Column('output', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='message_chain_pkey') + ) + with op.batch_alter_table('message_chains', schema=None) as batch_op: + batch_op.create_index('message_chain_message_id_idx', ['message_id'], unique=False) + + op.create_table('message_feedbacks', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('conversation_id', postgresql.UUID(), nullable=False), + sa.Column('message_id', postgresql.UUID(), nullable=False), + sa.Column('rating', sa.String(length=255), nullable=False), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('from_source', sa.String(length=255), nullable=False), + sa.Column('from_end_user_id', postgresql.UUID(), nullable=True), + sa.Column('from_account_id', postgresql.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='message_feedback_pkey') + ) + with op.batch_alter_table('message_feedbacks', schema=None) as batch_op: + batch_op.create_index('message_feedback_app_idx', ['app_id'], unique=False) + batch_op.create_index('message_feedback_conversation_idx', ['conversation_id', 'from_source', 'rating'], unique=False) + batch_op.create_index('message_feedback_message_idx', ['message_id', 'from_source'], unique=False) + + op.create_table('operation_logs', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('account_id', postgresql.UUID(), nullable=False), + sa.Column('action', sa.String(length=255), nullable=False), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('created_ip', sa.String(length=255), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='operation_log_pkey') + ) + with op.batch_alter_table('operation_logs', schema=None) as batch_op: + batch_op.create_index('operation_log_account_action_idx', ['tenant_id', 'account_id', 'action'], unique=False) + + op.create_table('pinned_conversations', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('conversation_id', postgresql.UUID(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='pinned_conversation_pkey') + ) + with op.batch_alter_table('pinned_conversations', schema=None) as batch_op: + batch_op.create_index('pinned_conversation_conversation_idx', ['app_id', 'conversation_id', 'created_by'], unique=False) + + op.create_table('providers', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('provider_name', sa.String(length=40), nullable=False), + sa.Column('provider_type', sa.String(length=40), nullable=False, server_default=sa.text("'custom'::character varying")), + sa.Column('encrypted_config', sa.Text(), nullable=True), + sa.Column('is_valid', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('last_used', sa.DateTime(), nullable=True), + sa.Column('quota_type', sa.String(length=40), nullable=True, server_default=sa.text("''::character varying")), + sa.Column('quota_limit', sa.Integer(), nullable=True), + sa.Column('quota_used', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='provider_pkey'), + sa.UniqueConstraint('tenant_id', 'provider_name', 'provider_type', 'quota_type', name='unique_provider_name_type_quota') + ) + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.create_index('provider_tenant_id_provider_idx', ['tenant_id', 'provider_name'], unique=False) + + op.create_table('recommended_apps', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('description', sa.JSON(), nullable=False), + sa.Column('copyright', sa.String(length=255), nullable=False), + sa.Column('privacy_policy', sa.String(length=255), nullable=False), + sa.Column('category', sa.String(length=255), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('is_listed', sa.Boolean(), nullable=False), + sa.Column('install_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='recommended_app_pkey') + ) + with op.batch_alter_table('recommended_apps', schema=None) as batch_op: + batch_op.create_index('recommended_app_app_id_idx', ['app_id'], unique=False) + batch_op.create_index('recommended_app_is_listed_idx', ['is_listed'], unique=False) + + op.create_table('saved_messages', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('message_id', postgresql.UUID(), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='saved_message_pkey') + ) + with op.batch_alter_table('saved_messages', schema=None) as batch_op: + batch_op.create_index('saved_message_message_idx', ['app_id', 'message_id', 'created_by'], unique=False) + + op.create_table('sessions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('session_id', sa.String(length=255), nullable=True), + sa.Column('data', sa.LargeBinary(), nullable=True), + sa.Column('expiry', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id') + ) + op.create_table('sites', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('icon', sa.String(length=255), nullable=True), + sa.Column('icon_background', sa.String(length=255), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('default_language', sa.String(length=255), nullable=False), + sa.Column('copyright', sa.String(length=255), nullable=True), + sa.Column('privacy_policy', sa.String(length=255), nullable=True), + sa.Column('customize_domain', sa.String(length=255), nullable=True), + sa.Column('customize_token_strategy', sa.String(length=255), nullable=False), + sa.Column('prompt_public', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('status', sa.String(length=255), server_default=sa.text("'normal'::character varying"), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('code', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id', name='site_pkey') + ) + with op.batch_alter_table('sites', schema=None) as batch_op: + batch_op.create_index('site_app_id_idx', ['app_id'], unique=False) + batch_op.create_index('site_code_idx', ['code', 'status'], unique=False) + + op.create_table('tenant_account_joins', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('account_id', postgresql.UUID(), nullable=False), + sa.Column('role', sa.String(length=16), server_default='normal', nullable=False), + sa.Column('invited_by', postgresql.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='tenant_account_join_pkey'), + sa.UniqueConstraint('tenant_id', 'account_id', name='unique_tenant_account_join') + ) + with op.batch_alter_table('tenant_account_joins', schema=None) as batch_op: + batch_op.create_index('tenant_account_join_account_id_idx', ['account_id'], unique=False) + batch_op.create_index('tenant_account_join_tenant_id_idx', ['tenant_id'], unique=False) + + op.create_table('tenants', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('encrypt_public_key', sa.Text(), nullable=True), + sa.Column('plan', sa.String(length=255), server_default=sa.text("'basic'::character varying"), nullable=False), + sa.Column('status', sa.String(length=255), server_default=sa.text("'normal'::character varying"), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='tenant_pkey') + ) + op.create_table('upload_files', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', postgresql.UUID(), nullable=False), + sa.Column('storage_type', sa.String(length=255), nullable=False), + sa.Column('key', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('size', sa.Integer(), nullable=False), + sa.Column('extension', sa.String(length=255), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('used', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('used_by', postgresql.UUID(), nullable=True), + sa.Column('used_at', sa.DateTime(), nullable=True), + sa.Column('hash', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id', name='upload_file_pkey') + ) + with op.batch_alter_table('upload_files', schema=None) as batch_op: + batch_op.create_index('upload_file_tenant_idx', ['tenant_id'], unique=False) + + op.create_table('message_annotations', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('conversation_id', postgresql.UUID(), nullable=False), + sa.Column('message_id', postgresql.UUID(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('account_id', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id', name='message_annotation_pkey') + ) + with op.batch_alter_table('message_annotations', schema=None) as batch_op: + batch_op.create_index('message_annotation_app_idx', ['app_id'], unique=False) + batch_op.create_index('message_annotation_conversation_idx', ['conversation_id'], unique=False) + batch_op.create_index('message_annotation_message_idx', ['message_id'], unique=False) + + op.create_table('messages', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('model_provider', sa.String(length=255), nullable=False), + sa.Column('model_id', sa.String(length=255), nullable=False), + sa.Column('override_model_configs', sa.Text(), nullable=True), + sa.Column('conversation_id', postgresql.UUID(), nullable=False), + sa.Column('inputs', sa.JSON(), nullable=True), + sa.Column('query', sa.Text(), nullable=False), + sa.Column('message', sa.JSON(), nullable=False), + sa.Column('message_tokens', sa.Integer(), server_default=sa.text('0'), nullable=False), + sa.Column('message_unit_price', sa.Numeric(precision=10, scale=4), nullable=False), + sa.Column('answer', sa.Text(), nullable=False), + sa.Column('answer_tokens', sa.Integer(), server_default=sa.text('0'), nullable=False), + sa.Column('answer_unit_price', sa.Numeric(precision=10, scale=4), nullable=False), + sa.Column('provider_response_latency', sa.Float(), server_default=sa.text('0'), nullable=False), + sa.Column('total_price', sa.Numeric(precision=10, scale=7), nullable=True), + sa.Column('currency', sa.String(length=255), nullable=False), + sa.Column('from_source', sa.String(length=255), nullable=False), + sa.Column('from_end_user_id', postgresql.UUID(), nullable=True), + sa.Column('from_account_id', postgresql.UUID(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('agent_based', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.PrimaryKeyConstraint('id', name='message_pkey') + ) + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.create_index('message_account_idx', ['app_id', 'from_source', 'from_account_id'], unique=False) + batch_op.create_index('message_app_id_idx', ['app_id', 'created_at'], unique=False) + batch_op.create_index('message_conversation_id_idx', ['conversation_id'], unique=False) + batch_op.create_index('message_end_user_idx', ['app_id', 'from_source', 'from_end_user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('messages', schema=None) as batch_op: + batch_op.drop_index('message_end_user_idx') + batch_op.drop_index('message_conversation_id_idx') + batch_op.drop_index('message_app_id_idx') + batch_op.drop_index('message_account_idx') + + op.drop_table('messages') + with op.batch_alter_table('message_annotations', schema=None) as batch_op: + batch_op.drop_index('message_annotation_message_idx') + batch_op.drop_index('message_annotation_conversation_idx') + batch_op.drop_index('message_annotation_app_idx') + + op.drop_table('message_annotations') + with op.batch_alter_table('upload_files', schema=None) as batch_op: + batch_op.drop_index('upload_file_tenant_idx') + + op.drop_table('upload_files') + op.drop_table('tenants') + with op.batch_alter_table('tenant_account_joins', schema=None) as batch_op: + batch_op.drop_index('tenant_account_join_tenant_id_idx') + batch_op.drop_index('tenant_account_join_account_id_idx') + + op.drop_table('tenant_account_joins') + with op.batch_alter_table('sites', schema=None) as batch_op: + batch_op.drop_index('site_code_idx') + batch_op.drop_index('site_app_id_idx') + + op.drop_table('sites') + op.drop_table('sessions') + with op.batch_alter_table('saved_messages', schema=None) as batch_op: + batch_op.drop_index('saved_message_message_idx') + + op.drop_table('saved_messages') + with op.batch_alter_table('recommended_apps', schema=None) as batch_op: + batch_op.drop_index('recommended_app_is_listed_idx') + batch_op.drop_index('recommended_app_app_id_idx') + + op.drop_table('recommended_apps') + with op.batch_alter_table('providers', schema=None) as batch_op: + batch_op.drop_index('provider_tenant_id_provider_idx') + + op.drop_table('providers') + with op.batch_alter_table('pinned_conversations', schema=None) as batch_op: + batch_op.drop_index('pinned_conversation_conversation_idx') + + op.drop_table('pinned_conversations') + with op.batch_alter_table('operation_logs', schema=None) as batch_op: + batch_op.drop_index('operation_log_account_action_idx') + + op.drop_table('operation_logs') + with op.batch_alter_table('message_feedbacks', schema=None) as batch_op: + batch_op.drop_index('message_feedback_message_idx') + batch_op.drop_index('message_feedback_conversation_idx') + batch_op.drop_index('message_feedback_app_idx') + + op.drop_table('message_feedbacks') + with op.batch_alter_table('message_chains', schema=None) as batch_op: + batch_op.drop_index('message_chain_message_id_idx') + + op.drop_table('message_chains') + with op.batch_alter_table('message_agent_thoughts', schema=None) as batch_op: + batch_op.drop_index('message_agent_thought_message_id_idx') + batch_op.drop_index('message_agent_thought_message_chain_id_idx') + + op.drop_table('message_agent_thoughts') + with op.batch_alter_table('invitation_codes', schema=None) as batch_op: + batch_op.drop_index('invitation_codes_code_idx') + batch_op.drop_index('invitation_codes_batch_idx') + + op.drop_table('invitation_codes') + with op.batch_alter_table('installed_apps', schema=None) as batch_op: + batch_op.drop_index('installed_app_tenant_id_idx') + batch_op.drop_index('installed_app_app_id_idx') + + op.drop_table('installed_apps') + with op.batch_alter_table('end_users', schema=None) as batch_op: + batch_op.drop_index('end_user_tenant_session_id_idx') + batch_op.drop_index('end_user_session_id_idx') + + op.drop_table('end_users') + op.drop_table('embeddings') + with op.batch_alter_table('documents', schema=None) as batch_op: + batch_op.drop_index('document_is_paused_idx') + batch_op.drop_index('document_dataset_id_idx') + + op.drop_table('documents') + with op.batch_alter_table('document_segments', schema=None) as batch_op: + batch_op.drop_index('document_segment_tenant_document_idx') + batch_op.drop_index('document_segment_tenant_dataset_idx') + batch_op.drop_index('document_segment_document_id_idx') + batch_op.drop_index('document_segment_dataset_node_idx') + batch_op.drop_index('document_segment_dataset_id_idx') + + op.drop_table('document_segments') + op.drop_table('dify_setups') + with op.batch_alter_table('datasets', schema=None) as batch_op: + batch_op.drop_index('dataset_tenant_idx') + + op.drop_table('datasets') + with op.batch_alter_table('dataset_queries', schema=None) as batch_op: + batch_op.drop_index('dataset_query_dataset_id_idx') + + op.drop_table('dataset_queries') + with op.batch_alter_table('dataset_process_rules', schema=None) as batch_op: + batch_op.drop_index('dataset_process_rule_dataset_id_idx') + + op.drop_table('dataset_process_rules') + with op.batch_alter_table('dataset_keyword_tables', schema=None) as batch_op: + batch_op.drop_index('dataset_keyword_table_dataset_id_idx') + + op.drop_table('dataset_keyword_tables') + with op.batch_alter_table('conversations', schema=None) as batch_op: + batch_op.drop_index('conversation_app_from_user_idx') + + op.drop_table('conversations') + op.drop_table('celery_tasksetmeta') + op.drop_table('celery_taskmeta') + + op.execute('DROP SEQUENCE taskset_id_sequence;') + op.execute('DROP SEQUENCE task_id_sequence;') + with op.batch_alter_table('apps', schema=None) as batch_op: + batch_op.drop_index('app_tenant_id_idx') + + op.drop_table('apps') + with op.batch_alter_table('app_model_configs', schema=None) as batch_op: + batch_op.drop_index('app_app_id_idx') + + op.drop_table('app_model_configs') + with op.batch_alter_table('app_dataset_joins', schema=None) as batch_op: + batch_op.drop_index('app_dataset_join_app_dataset_idx') + + op.drop_table('app_dataset_joins') + with op.batch_alter_table('api_tokens', schema=None) as batch_op: + batch_op.drop_index('api_token_token_idx') + batch_op.drop_index('api_token_app_id_type_idx') + + op.drop_table('api_tokens') + with op.batch_alter_table('api_requests', schema=None) as batch_op: + batch_op.drop_index('api_request_token_idx') + + op.drop_table('api_requests') + with op.batch_alter_table('accounts', schema=None) as batch_op: + batch_op.drop_index('account_email_idx') + + op.drop_table('accounts') + op.drop_table('account_integrates') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py new file mode 100644 index 0000000000..44d37d3052 --- /dev/null +++ b/api/models/__init__.py @@ -0,0 +1 @@ +# -*- coding:utf-8 -*- \ No newline at end of file diff --git a/api/models/account.py b/api/models/account.py new file mode 100644 index 0000000000..de2d3bd71f --- /dev/null +++ b/api/models/account.py @@ -0,0 +1,180 @@ +import enum +from typing import List + +from flask_login import UserMixin +from extensions.ext_database import db +from sqlalchemy.dialects.postgresql import UUID + + +class AccountStatus(str, enum.Enum): + PENDING = 'pending' + UNINITIALIZED = 'uninitialized' + ACTIVE = 'active' + BANNED = 'banned' + CLOSED = 'closed' + + +class Account(UserMixin, db.Model): + __tablename__ = 'accounts' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='account_pkey'), + db.Index('account_email_idx', 'email') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + name = db.Column(db.String(255), nullable=False) + email = db.Column(db.String(255), nullable=False) + password = db.Column(db.String(255), nullable=True) + password_salt = db.Column(db.String(255), nullable=True) + avatar = db.Column(db.String(255)) + interface_language = db.Column(db.String(255)) + interface_theme = db.Column(db.String(255)) + timezone = db.Column(db.String(255)) + last_login_at = db.Column(db.DateTime) + last_login_ip = db.Column(db.String(255)) + status = db.Column(db.String(16), nullable=False, server_default=db.text("'active'::character varying")) + initialized_at = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + _current_tenant: db.Model = None + + @property + def current_tenant(self): + return self._current_tenant + + @current_tenant.setter + def current_tenant(self, value): + tenant = value + ta = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, account_id=self.id).first() + if ta: + tenant.current_role = ta.role + else: + tenant = None + self._current_tenant = tenant + + @property + def current_tenant_id(self): + return self._current_tenant.id + + @current_tenant_id.setter + def current_tenant_id(self, value): + try: + tenant_account_join = db.session.query(Tenant, TenantAccountJoin) \ + .filter(Tenant.id == value) \ + .filter(TenantAccountJoin.tenant_id == Tenant.id) \ + .filter(TenantAccountJoin.account_id == self.id) \ + .one_or_none() + + if tenant_account_join: + tenant, ta = tenant_account_join + tenant.current_role = ta.role + else: + tenant = None + except: + tenant = None + + self._current_tenant = tenant + + def get_status(self) -> AccountStatus: + status_str = self.status + return AccountStatus(status_str) + + @classmethod + def get_by_openid(cls, provider: str, open_id: str) -> db.Model: + account_integrate = db.session.query(AccountIntegrate). \ + filter(AccountIntegrate.provider == provider, AccountIntegrate.open_id == open_id). \ + one_or_none() + if account_integrate: + return db.session.query(Account). \ + filter(Account.id == account_integrate.account_id). \ + one_or_none() + return None + + def get_integrates(self) -> List[db.Model]: + ai = db.Model + return db.session.query(ai).filter( + ai.account_id == self.id + ).all() + + +class Tenant(db.Model): + __tablename__ = 'tenants' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='tenant_pkey'), + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + name = db.Column(db.String(255), nullable=False) + encrypt_public_key = db.Column(db.Text) + plan = db.Column(db.String(255), nullable=False, server_default=db.text("'basic'::character varying")) + status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying")) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + def get_accounts(self) -> List[db.Model]: + Account = db.Model + return db.session.query(Account).filter( + Account.id == TenantAccountJoin.account_id, + TenantAccountJoin.tenant_id == self.id + ).all() + + +class TenantAccountJoinRole(enum.Enum): + OWNER = 'owner' + ADMIN = 'admin' + NORMAL = 'normal' + + +class TenantAccountJoin(db.Model): + __tablename__ = 'tenant_account_joins' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='tenant_account_join_pkey'), + db.Index('tenant_account_join_account_id_idx', 'account_id'), + db.Index('tenant_account_join_tenant_id_idx', 'tenant_id'), + db.UniqueConstraint('tenant_id', 'account_id', name='unique_tenant_account_join') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + account_id = db.Column(UUID, nullable=False) + role = db.Column(db.String(16), nullable=False, server_default='normal') + invited_by = db.Column(UUID, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class AccountIntegrate(db.Model): + __tablename__ = 'account_integrates' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='account_integrate_pkey'), + db.UniqueConstraint('account_id', 'provider', name='unique_account_provider'), + db.UniqueConstraint('provider', 'open_id', name='unique_provider_open_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + account_id = db.Column(UUID, nullable=False) + provider = db.Column(db.String(16), nullable=False) + open_id = db.Column(db.String(255), nullable=False) + encrypted_token = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class InvitationCode(db.Model): + __tablename__ = 'invitation_codes' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='invitation_code_pkey'), + db.Index('invitation_codes_batch_idx', 'batch'), + db.Index('invitation_codes_code_idx', 'code', 'status') + ) + + id = db.Column(db.Integer, nullable=False) + batch = db.Column(db.String(255), nullable=False) + code = db.Column(db.String(32), nullable=False) + status = db.Column(db.String(16), nullable=False, server_default=db.text("'unused'::character varying")) + used_at = db.Column(db.DateTime) + used_by_tenant_id = db.Column(UUID) + used_by_account_id = db.Column(UUID) + deprecated_at = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) diff --git a/api/models/dataset.py b/api/models/dataset.py new file mode 100644 index 0000000000..29588c1f38 --- /dev/null +++ b/api/models/dataset.py @@ -0,0 +1,415 @@ +import json +import pickle +from json import JSONDecodeError + +from sqlalchemy import func +from sqlalchemy.dialects.postgresql import UUID + +from extensions.ext_database import db +from models.account import Account +from models.model import App, UploadFile + +class Dataset(db.Model): + __tablename__ = 'datasets' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='dataset_pkey'), + db.Index('dataset_tenant_idx', 'tenant_id'), + ) + + INDEXING_TECHNIQUE_LIST = ['high_quality', 'economy'] + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + provider = db.Column(db.String(255), nullable=False, + server_default=db.text("'vendor'::character varying")) + permission = db.Column(db.String(255), nullable=False, + server_default=db.text("'only_me'::character varying")) + data_source_type = db.Column(db.String(255)) + indexing_technique = db.Column(db.String(255), nullable=True) + index_struct = db.Column(db.Text, nullable=True) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_by = db.Column(UUID, nullable=True) + updated_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def dataset_keyword_table(self): + dataset_keyword_table = db.session.query(DatasetKeywordTable).filter( + DatasetKeywordTable.dataset_id == self.id).first() + if dataset_keyword_table: + return dataset_keyword_table + + return None + + @property + def index_struct_dict(self): + return json.loads(self.index_struct) if self.index_struct else None + + @property + def created_by_account(self): + return Account.query.get(self.created_by) + + @property + def latest_process_rule(self): + return DatasetProcessRule.query.filter(DatasetProcessRule.dataset_id == self.id) \ + .order_by(DatasetProcessRule.created_at.desc()).first() + + @property + def app_count(self): + return db.session.query(func.count(AppDatasetJoin.id)).filter(AppDatasetJoin.dataset_id == self.id).scalar() + + @property + def document_count(self): + return db.session.query(func.count(Document.id)).filter(Document.dataset_id == self.id).scalar() + + @property + def word_count(self): + return Document.query.with_entities(func.coalesce(func.sum(Document.word_count))) \ + .filter(Document.dataset_id == self.id).scalar() + + +class DatasetProcessRule(db.Model): + __tablename__ = 'dataset_process_rules' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='dataset_process_rule_pkey'), + db.Index('dataset_process_rule_dataset_id_idx', 'dataset_id'), + ) + + id = db.Column(UUID, nullable=False, + server_default=db.text('uuid_generate_v4()')) + dataset_id = db.Column(UUID, nullable=False) + mode = db.Column(db.String(255), nullable=False, + server_default=db.text("'automatic'::character varying")) + rules = db.Column(db.Text, nullable=True) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + + MODES = ['automatic', 'custom'] + PRE_PROCESSING_RULES = ['remove_stopwords', 'remove_extra_spaces', 'remove_urls_emails'] + AUTOMATIC_RULES = { + 'pre_processing_rules': [ + {'id': 'remove_extra_spaces', 'enabled': True}, + {'id': 'remove_urls_emails', 'enabled': False} + ], + 'segmentation': { + 'delimiter': '\n', + 'max_tokens': 1000 + } + } + + def to_dict(self): + return { + 'id': self.id, + 'dataset_id': self.dataset_id, + 'mode': self.mode, + 'rules': self.rules_dict, + 'created_by': self.created_by, + 'created_at': self.created_at, + } + + @property + def rules_dict(self): + try: + return json.loads(self.rules) if self.rules else None + except JSONDecodeError: + return None + + +class Document(db.Model): + __tablename__ = 'documents' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='document_pkey'), + db.Index('document_dataset_id_idx', 'dataset_id'), + db.Index('document_is_paused_idx', 'is_paused'), + ) + + # initial fields + id = db.Column(UUID, nullable=False, + server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + dataset_id = db.Column(UUID, nullable=False) + position = db.Column(db.Integer, nullable=False) + data_source_type = db.Column(db.String(255), nullable=False) + data_source_info = db.Column(db.Text, nullable=True) + dataset_process_rule_id = db.Column(UUID, nullable=True) + batch = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(255), nullable=False) + created_from = db.Column(db.String(255), nullable=False) + created_by = db.Column(UUID, nullable=False) + created_api_request_id = db.Column(UUID, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + + # start processing + processing_started_at = db.Column(db.DateTime, nullable=True) + + # parsing + file_id = db.Column(db.Text, nullable=True) + word_count = db.Column(db.Integer, nullable=True) + parsing_completed_at = db.Column(db.DateTime, nullable=True) + + # cleaning + cleaning_completed_at = db.Column(db.DateTime, nullable=True) + + # split + splitting_completed_at = db.Column(db.DateTime, nullable=True) + + # indexing + tokens = db.Column(db.Integer, nullable=True) + indexing_latency = db.Column(db.Float, nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + + # pause + is_paused = db.Column(db.Boolean, nullable=True, server_default=db.text('false')) + paused_by = db.Column(UUID, nullable=True) + paused_at = db.Column(db.DateTime, nullable=True) + + # error + error = db.Column(db.Text, nullable=True) + stopped_at = db.Column(db.DateTime, nullable=True) + + # basic fields + indexing_status = db.Column(db.String( + 255), nullable=False, server_default=db.text("'waiting'::character varying")) + enabled = db.Column(db.Boolean, nullable=False, + server_default=db.text('true')) + disabled_at = db.Column(db.DateTime, nullable=True) + disabled_by = db.Column(UUID, nullable=True) + archived = db.Column(db.Boolean, nullable=False, + server_default=db.text('false')) + archived_reason = db.Column(db.String(255), nullable=True) + archived_by = db.Column(UUID, nullable=True) + archived_at = db.Column(db.DateTime, nullable=True) + updated_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + doc_type = db.Column(db.String(40), nullable=True) + doc_metadata = db.Column(db.JSON, nullable=True) + + DATA_SOURCES = ['upload_file'] + + @property + def display_status(self): + status = None + if self.indexing_status == 'waiting': + status = 'queuing' + elif self.indexing_status not in ['completed', 'error', 'waiting'] and self.is_paused: + status = 'paused' + elif self.indexing_status in ['parsing', 'cleaning', 'splitting', 'indexing']: + status = 'indexing' + elif self.indexing_status == 'error': + status = 'error' + elif self.indexing_status == 'completed' and not self.archived and self.enabled: + status = 'available' + elif self.indexing_status == 'completed' and not self.archived and not self.enabled: + status = 'disabled' + elif self.indexing_status == 'completed' and self.archived: + status = 'archived' + return status + + @property + def data_source_info_dict(self): + if self.data_source_info: + try: + data_source_info_dict = json.loads(self.data_source_info) + except JSONDecodeError: + data_source_info_dict = {} + + return data_source_info_dict + return None + + @property + def data_source_detail_dict(self): + if self.data_source_info: + if self.data_source_type == 'upload_file': + data_source_info_dict = json.loads(self.data_source_info) + file_detail = db.session.query(UploadFile). \ + filter(UploadFile.id == data_source_info_dict['upload_file_id']). \ + one_or_none() + if file_detail: + return { + 'upload_file': { + 'id': file_detail.id, + 'name': file_detail.name, + 'size': file_detail.size, + 'extension': file_detail.extension, + 'mime_type': file_detail.mime_type, + 'created_by': file_detail.created_by, + 'created_at': file_detail.created_at.timestamp() + } + } + return {} + + @property + def average_segment_length(self): + if self.word_count and self.word_count != 0 and self.segment_count and self.segment_count != 0: + return self.word_count//self.segment_count + return 0 + + @property + def dataset_process_rule(self): + if self.dataset_process_rule_id: + return DatasetProcessRule.query.get(self.dataset_process_rule_id) + return None + + @property + def dataset(self): + return Dataset.query.get(self.dataset_id) + + @property + def segment_count(self): + return DocumentSegment.query.filter(DocumentSegment.document_id == self.id).count() + + @property + def hit_count(self): + return DocumentSegment.query.with_entities(func.coalesce(func.sum(DocumentSegment.hit_count))) \ + .filter(DocumentSegment.document_id == self.id).scalar() + + +class DocumentSegment(db.Model): + __tablename__ = 'document_segments' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='document_segment_pkey'), + db.Index('document_segment_dataset_id_idx', 'dataset_id'), + db.Index('document_segment_document_id_idx', 'document_id'), + db.Index('document_segment_tenant_dataset_idx', 'dataset_id', 'tenant_id'), + db.Index('document_segment_tenant_document_idx', 'document_id', 'tenant_id'), + db.Index('document_segment_dataset_node_idx', 'dataset_id', 'index_node_id'), + ) + + # initial fields + id = db.Column(UUID, nullable=False, + server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + dataset_id = db.Column(UUID, nullable=False) + document_id = db.Column(UUID, nullable=False) + position = db.Column(db.Integer, nullable=False) + content = db.Column(db.Text, nullable=False) + word_count = db.Column(db.Integer, nullable=False) + tokens = db.Column(db.Integer, nullable=False) + + # indexing fields + keywords = db.Column(db.JSON, nullable=True) + index_node_id = db.Column(db.String(255), nullable=True) + index_node_hash = db.Column(db.String(255), nullable=True) + + # basic fields + hit_count = db.Column(db.Integer, nullable=False, default=0) + enabled = db.Column(db.Boolean, nullable=False, + server_default=db.text('true')) + disabled_at = db.Column(db.DateTime, nullable=True) + disabled_by = db.Column(UUID, nullable=True) + status = db.Column(db.String(255), nullable=False, + server_default=db.text("'waiting'::character varying")) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, + server_default=db.text('CURRENT_TIMESTAMP(0)')) + indexing_at = db.Column(db.DateTime, nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + error = db.Column(db.Text, nullable=True) + stopped_at = db.Column(db.DateTime, nullable=True) + + @property + def dataset(self): + return db.session.query(Dataset).filter(Dataset.id == self.dataset_id).first() + + @property + def document(self): + return db.session.query(Document).filter(Document.id == self.document_id).first() + + @property + def embedding(self): + embedding = db.session.query(Embedding).filter(Embedding.hash == self.index_node_hash).first() \ + if self.index_node_hash else None + + if embedding: + return embedding.embedding + + return None + + @property + def previous_segment(self): + return db.session.query(DocumentSegment).filter( + DocumentSegment.document_id == self.document_id, + DocumentSegment.position == self.position - 1 + ).first() + + @property + def next_segment(self): + return db.session.query(DocumentSegment).filter( + DocumentSegment.document_id == self.document_id, + DocumentSegment.position == self.position + 1 + ).first() + + +class AppDatasetJoin(db.Model): + __tablename__ = 'app_dataset_joins' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='app_dataset_join_pkey'), + db.Index('app_dataset_join_app_dataset_idx', 'dataset_id', 'app_id'), + ) + + id = db.Column(UUID, primary_key=True, nullable=False, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + dataset_id = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.current_timestamp()) + + @property + def app(self): + return App.query.get(self.app_id) + + +class DatasetQuery(db.Model): + __tablename__ = 'dataset_queries' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='dataset_query_pkey'), + db.Index('dataset_query_dataset_id_idx', 'dataset_id'), + ) + + id = db.Column(UUID, primary_key=True, nullable=False, server_default=db.text('uuid_generate_v4()')) + dataset_id = db.Column(UUID, nullable=False) + content = db.Column(db.Text, nullable=False) + source = db.Column(db.String(255), nullable=False) + source_app_id = db.Column(UUID, nullable=True) + created_by_role = db.Column(db.String, nullable=False) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.current_timestamp()) + + +class DatasetKeywordTable(db.Model): + __tablename__ = 'dataset_keyword_tables' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='dataset_keyword_table_pkey'), + db.Index('dataset_keyword_table_dataset_id_idx', 'dataset_id'), + ) + + id = db.Column(UUID, primary_key=True, server_default=db.text('uuid_generate_v4()')) + dataset_id = db.Column(UUID, nullable=False, unique=True) + keyword_table = db.Column(db.Text, nullable=False) + + @property + def keyword_table_dict(self): + return json.loads(self.keyword_table) if self.keyword_table else None + + +class Embedding(db.Model): + __tablename__ = 'embeddings' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='embedding_pkey'), + db.UniqueConstraint('hash', name='embedding_hash_idx') + ) + + id = db.Column(UUID, primary_key=True, server_default=db.text('uuid_generate_v4()')) + hash = db.Column(db.String(64), nullable=False) + embedding = db.Column(db.LargeBinary, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + def set_embedding(self, embedding_data: list[float]): + self.embedding = pickle.dumps(embedding_data, protocol=pickle.HIGHEST_PROTOCOL) + + def get_embedding(self) -> list[float]: + return pickle.loads(self.embedding) diff --git a/api/models/model.py b/api/models/model.py new file mode 100644 index 0000000000..9b4e2a38bc --- /dev/null +++ b/api/models/model.py @@ -0,0 +1,622 @@ +import json + +from flask import current_app +from flask_login import UserMixin +from sqlalchemy.dialects.postgresql import UUID + +from libs.helper import generate_string +from extensions.ext_database import db +from .account import Account, Tenant + + +class DifySetup(db.Model): + __tablename__ = 'dify_setups' + __table_args__ = ( + db.PrimaryKeyConstraint('version', name='dify_setup_pkey'), + ) + + version = db.Column(db.String(255), nullable=False) + setup_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class App(db.Model): + __tablename__ = 'apps' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='app_pkey'), + db.Index('app_tenant_id_idx', 'tenant_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + name = db.Column(db.String(255), nullable=False) + mode = db.Column(db.String(255), nullable=False) + icon = db.Column(db.String(255)) + icon_background = db.Column(db.String(255)) + app_model_config_id = db.Column(UUID, nullable=True) + status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying")) + enable_site = db.Column(db.Boolean, nullable=False) + enable_api = db.Column(db.Boolean, nullable=False) + api_rpm = db.Column(db.Integer, nullable=False) + api_rph = db.Column(db.Integer, nullable=False) + is_demo = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + is_public = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def site(self): + site = db.session.query(Site).filter(Site.app_id == self.id).first() + return site + + @property + def app_model_config(self): + app_model_config = db.session.query(AppModelConfig).filter( + AppModelConfig.id == self.app_model_config_id).first() + return app_model_config + + @property + def api_base_url(self): + return current_app.config['API_URL'] + '/v1' + + @property + def tenant(self): + tenant = db.session.query(Tenant).filter(Tenant.id == self.tenant_id).first() + return tenant + + +class AppModelConfig(db.Model): + __tablename__ = 'app_model_configs' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='app_model_config_pkey'), + db.Index('app_app_id_idx', 'app_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + provider = db.Column(db.String(255), nullable=False) + model_id = db.Column(db.String(255), nullable=False) + configs = db.Column(db.JSON, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + opening_statement = db.Column(db.Text) + suggested_questions = db.Column(db.Text) + suggested_questions_after_answer = db.Column(db.Text) + more_like_this = db.Column(db.Text) + model = db.Column(db.Text) + user_input_form = db.Column(db.Text) + pre_prompt = db.Column(db.Text) + agent_mode = db.Column(db.Text) + + @property + def app(self): + app = db.session.query(App).filter(App.id == self.app_id).first() + return app + + @property + def model_dict(self) -> dict: + return json.loads(self.model) if self.model else None + + @property + def suggested_questions_list(self) -> list: + return json.loads(self.suggested_questions) if self.suggested_questions else [] + + @property + def suggested_questions_after_answer_dict(self) -> dict: + return json.loads(self.suggested_questions_after_answer) if self.suggested_questions_after_answer \ + else {"enabled": False} + + @property + def more_like_this_dict(self) -> dict: + return json.loads(self.more_like_this) if self.more_like_this else {"enabled": False} + + @property + def user_input_form_list(self) -> dict: + return json.loads(self.user_input_form) if self.user_input_form else [] + + @property + def agent_mode_dict(self) -> dict: + return json.loads(self.agent_mode) if self.agent_mode else {"enabled": False, "tools": []} + + +class RecommendedApp(db.Model): + __tablename__ = 'recommended_apps' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='recommended_app_pkey'), + db.Index('recommended_app_app_id_idx', 'app_id'), + db.Index('recommended_app_is_listed_idx', 'is_listed') + ) + + id = db.Column(UUID, primary_key=True, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + description = db.Column(db.JSON, nullable=False) + copyright = db.Column(db.String(255), nullable=False) + privacy_policy = db.Column(db.String(255), nullable=False) + category = db.Column(db.String(255), nullable=False) + position = db.Column(db.Integer, nullable=False, default=0) + is_listed = db.Column(db.Boolean, nullable=False, default=True) + install_count = db.Column(db.Integer, nullable=False, default=0) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def app(self): + app = db.session.query(App).filter(App.id == self.app_id).first() + return app + + # def set_description(self, lang, desc): + # if self.description is None: + # self.description = {} + # self.description[lang] = desc + + def get_description(self, lang): + if self.description and lang in self.description: + return self.description[lang] + else: + return self.description.get('en') + + +class InstalledApp(db.Model): + __tablename__ = 'installed_apps' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='installed_app_pkey'), + db.Index('installed_app_tenant_id_idx', 'tenant_id'), + db.Index('installed_app_app_id_idx', 'app_id'), + db.UniqueConstraint('tenant_id', 'app_id', name='unique_tenant_app') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + app_id = db.Column(UUID, nullable=False) + app_owner_tenant_id = db.Column(UUID, nullable=False) + position = db.Column(db.Integer, nullable=False, default=0) + is_pinned = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + last_used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def app(self): + app = db.session.query(App).filter(App.id == self.app_id).first() + return app + + @property + def tenant(self): + tenant = db.session.query(Tenant).filter(Tenant.id == self.tenant_id).first() + return tenant + + +class Conversation(db.Model): + __tablename__ = 'conversations' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='conversation_pkey'), + db.Index('conversation_app_from_user_idx', 'app_id', 'from_source', 'from_end_user_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + app_model_config_id = db.Column(UUID, nullable=False) + model_provider = db.Column(db.String(255), nullable=False) + override_model_configs = db.Column(db.Text) + model_id = db.Column(db.String(255), nullable=False) + mode = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(255), nullable=False) + summary = db.Column(db.Text) + inputs = db.Column(db.JSON) + introduction = db.Column(db.Text) + system_instruction = db.Column(db.Text) + system_instruction_tokens = db.Column(db.Integer, nullable=False, server_default=db.text('0')) + status = db.Column(db.String(255), nullable=False) + from_source = db.Column(db.String(255), nullable=False) + from_end_user_id = db.Column(UUID) + from_account_id = db.Column(UUID) + read_at = db.Column(db.DateTime) + read_account_id = db.Column(UUID) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + messages = db.relationship("Message", backref="conversation", lazy='select', passive_deletes="all") + message_annotations = db.relationship("MessageAnnotation", backref="conversation", lazy='select', passive_deletes="all") + + @property + def model_config(self): + model_config = {} + if self.override_model_configs: + override_model_configs = json.loads(self.override_model_configs) + + if 'model' in override_model_configs: + model_config['model'] = override_model_configs['model'] + model_config['pre_prompt'] = override_model_configs['pre_prompt'] + model_config['agent_mode'] = override_model_configs['agent_mode'] + model_config['opening_statement'] = override_model_configs['opening_statement'] + model_config['suggested_questions'] = override_model_configs['suggested_questions'] + model_config['suggested_questions_after_answer'] = override_model_configs[ + 'suggested_questions_after_answer'] \ + if 'suggested_questions_after_answer' in override_model_configs else {"enabled": False} + model_config['more_like_this'] = override_model_configs['more_like_this'] \ + if 'more_like_this' in override_model_configs else {"enabled": False} + model_config['user_input_form'] = override_model_configs['user_input_form'] + else: + model_config['configs'] = override_model_configs + else: + app_model_config = db.session.query(AppModelConfig).filter( + AppModelConfig.id == self.app_model_config_id).first() + + model_config['configs'] = app_model_config.configs + model_config['model'] = app_model_config.model_dict + model_config['pre_prompt'] = app_model_config.pre_prompt + model_config['agent_mode'] = app_model_config.agent_mode_dict + model_config['opening_statement'] = app_model_config.opening_statement + model_config['suggested_questions'] = app_model_config.suggested_questions_list + model_config['suggested_questions_after_answer'] = app_model_config.suggested_questions_after_answer_dict + model_config['more_like_this'] = app_model_config.more_like_this_dict + model_config['user_input_form'] = app_model_config.user_input_form_list + + model_config['model_id'] = self.model_id + model_config['provider'] = self.model_provider + + return model_config + + @property + def summary_or_query(self): + if self.summary: + return self.summary + else: + first_message = self.first_message + if first_message: + return first_message.query + else: + return '' + + @property + def annotated(self): + return db.session.query(MessageAnnotation).filter(MessageAnnotation.conversation_id == self.id).count() > 0 + + @property + def annotation(self): + return db.session.query(MessageAnnotation).filter(MessageAnnotation.conversation_id == self.id).first() + + @property + def message_count(self): + return db.session.query(Message).filter(Message.conversation_id == self.id).count() + + @property + def user_feedback_stats(self): + like = db.session.query(MessageFeedback) \ + .filter(MessageFeedback.conversation_id == self.id, + MessageFeedback.from_source == 'user', + MessageFeedback.rating == 'like').count() + + dislike = db.session.query(MessageFeedback) \ + .filter(MessageFeedback.conversation_id == self.id, + MessageFeedback.from_source == 'user', + MessageFeedback.rating == 'dislike').count() + + return {'like': like, 'dislike': dislike} + + @property + def admin_feedback_stats(self): + like = db.session.query(MessageFeedback) \ + .filter(MessageFeedback.conversation_id == self.id, + MessageFeedback.from_source == 'admin', + MessageFeedback.rating == 'like').count() + + dislike = db.session.query(MessageFeedback) \ + .filter(MessageFeedback.conversation_id == self.id, + MessageFeedback.from_source == 'admin', + MessageFeedback.rating == 'dislike').count() + + return {'like': like, 'dislike': dislike} + + @property + def first_message(self): + return db.session.query(Message).filter(Message.conversation_id == self.id).first() + + @property + def app(self): + return db.session.query(App).filter(App.id == self.app_id).first() + + +class Message(db.Model): + __tablename__ = 'messages' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='message_pkey'), + db.Index('message_app_id_idx', 'app_id', 'created_at'), + db.Index('message_conversation_id_idx', 'conversation_id'), + db.Index('message_end_user_idx', 'app_id', 'from_source', 'from_end_user_id'), + db.Index('message_account_idx', 'app_id', 'from_source', 'from_account_id'), + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + model_provider = db.Column(db.String(255), nullable=False) + model_id = db.Column(db.String(255), nullable=False) + override_model_configs = db.Column(db.Text) + conversation_id = db.Column(UUID, db.ForeignKey('conversations.id'), nullable=False) + inputs = db.Column(db.JSON) + query = db.Column(db.Text, nullable=False) + message = db.Column(db.JSON, nullable=False) + message_tokens = db.Column(db.Integer, nullable=False, server_default=db.text('0')) + message_unit_price = db.Column(db.Numeric(10, 4), nullable=False) + answer = db.Column(db.Text, nullable=False) + answer_tokens = db.Column(db.Integer, nullable=False, server_default=db.text('0')) + answer_unit_price = db.Column(db.Numeric(10, 4), nullable=False) + provider_response_latency = db.Column(db.Float, nullable=False, server_default=db.text('0')) + total_price = db.Column(db.Numeric(10, 7)) + currency = db.Column(db.String(255), nullable=False) + from_source = db.Column(db.String(255), nullable=False) + from_end_user_id = db.Column(UUID) + from_account_id = db.Column(UUID) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + agent_based = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + + @property + def user_feedback(self): + feedback = db.session.query(MessageFeedback).filter(MessageFeedback.message_id == self.id, + MessageFeedback.from_source == 'user').first() + return feedback + + @property + def admin_feedback(self): + feedback = db.session.query(MessageFeedback).filter(MessageFeedback.message_id == self.id, + MessageFeedback.from_source == 'admin').first() + return feedback + + @property + def feedbacks(self): + feedbacks = db.session.query(MessageFeedback).filter(MessageFeedback.message_id == self.id).all() + return feedbacks + + @property + def annotation(self): + annotation = db.session.query(MessageAnnotation).filter(MessageAnnotation.message_id == self.id).first() + return annotation + + @property + def app_model_config(self): + conversation = db.session.query(Conversation).filter(Conversation.id == self.conversation_id).first() + if conversation: + return db.session.query(AppModelConfig).filter( + AppModelConfig.id == conversation.app_model_config_id).first() + + return None + + +class MessageFeedback(db.Model): + __tablename__ = 'message_feedbacks' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='message_feedback_pkey'), + db.Index('message_feedback_app_idx', 'app_id'), + db.Index('message_feedback_message_idx', 'message_id', 'from_source'), + db.Index('message_feedback_conversation_idx', 'conversation_id', 'from_source', 'rating') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + conversation_id = db.Column(UUID, nullable=False) + message_id = db.Column(UUID, nullable=False) + rating = db.Column(db.String(255), nullable=False) + content = db.Column(db.Text) + from_source = db.Column(db.String(255), nullable=False) + from_end_user_id = db.Column(UUID) + from_account_id = db.Column(UUID) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def from_account(self): + account = db.session.query(Account).filter(Account.id == self.from_account_id).first() + return account + + +class MessageAnnotation(db.Model): + __tablename__ = 'message_annotations' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='message_annotation_pkey'), + db.Index('message_annotation_app_idx', 'app_id'), + db.Index('message_annotation_conversation_idx', 'conversation_id'), + db.Index('message_annotation_message_idx', 'message_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + conversation_id = db.Column(UUID, db.ForeignKey('conversations.id'), nullable=False) + message_id = db.Column(UUID, nullable=False) + content = db.Column(db.Text, nullable=False) + account_id = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def account(self): + account = db.session.query(Account).filter(Account.id == self.account_id).first() + return account + + +class OperationLog(db.Model): + __tablename__ = 'operation_logs' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='operation_log_pkey'), + db.Index('operation_log_account_action_idx', 'tenant_id', 'account_id', 'action') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + account_id = db.Column(UUID, nullable=False) + action = db.Column(db.String(255), nullable=False) + content = db.Column(db.JSON) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + created_ip = db.Column(db.String(255), nullable=False) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class EndUser(UserMixin, db.Model): + __tablename__ = 'end_users' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='end_user_pkey'), + db.Index('end_user_session_id_idx', 'session_id', 'type'), + db.Index('end_user_tenant_session_id_idx', 'tenant_id', 'session_id', 'type'), + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + app_id = db.Column(UUID, nullable=True) + type = db.Column(db.String(255), nullable=False) + external_user_id = db.Column(db.String(255), nullable=True) + name = db.Column(db.String(255)) + is_anonymous = db.Column(db.Boolean, nullable=False, server_default=db.text('true')) + session_id = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class Site(db.Model): + __tablename__ = 'sites' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='site_pkey'), + db.Index('site_app_id_idx', 'app_id'), + db.Index('site_code_idx', 'code', 'status') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + title = db.Column(db.String(255), nullable=False) + icon = db.Column(db.String(255)) + icon_background = db.Column(db.String(255)) + description = db.Column(db.String(255)) + default_language = db.Column(db.String(255), nullable=False) + copyright = db.Column(db.String(255)) + privacy_policy = db.Column(db.String(255)) + customize_domain = db.Column(db.String(255)) + customize_token_strategy = db.Column(db.String(255), nullable=False) + prompt_public = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying")) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + code = db.Column(db.String(255)) + + @staticmethod + def generate_code(n): + while True: + result = generate_string(n) + while db.session.query(Site).filter(Site.code == result).count() > 0: + result = generate_string(n) + + return result + + @property + def app_base_url(self): + return current_app.config['APP_URL'] + + +class ApiToken(db.Model): + __tablename__ = 'api_tokens' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='api_token_pkey'), + db.Index('api_token_app_id_type_idx', 'app_id', 'type'), + db.Index('api_token_token_idx', 'token', 'type') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=True) + dataset_id = db.Column(UUID, nullable=True) + type = db.Column(db.String(16), nullable=False) + token = db.Column(db.String(255), nullable=False) + last_used_at = db.Column(db.DateTime, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @staticmethod + def generate_api_key(prefix, n): + while True: + result = prefix + generate_string(n) + while db.session.query(ApiToken).filter(ApiToken.token == result).count() > 0: + result = prefix + generate_string(n) + + return result + + +class UploadFile(db.Model): + __tablename__ = 'upload_files' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='upload_file_pkey'), + db.Index('upload_file_tenant_idx', 'tenant_id') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + storage_type = db.Column(db.String(255), nullable=False) + key = db.Column(db.String(255), nullable=False) + name = db.Column(db.String(255), nullable=False) + size = db.Column(db.Integer, nullable=False) + extension = db.Column(db.String(255), nullable=False) + mime_type = db.Column(db.String(255), nullable=True) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + used = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + used_by = db.Column(UUID, nullable=True) + used_at = db.Column(db.DateTime, nullable=True) + hash = db.Column(db.String(255), nullable=True) + + +class ApiRequest(db.Model): + __tablename__ = 'api_requests' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='api_request_pkey'), + db.Index('api_request_token_idx', 'tenant_id', 'api_token_id') + ) + + id = db.Column(UUID, nullable=False, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + api_token_id = db.Column(UUID, nullable=False) + path = db.Column(db.String(255), nullable=False) + request = db.Column(db.Text, nullable=True) + response = db.Column(db.Text, nullable=True) + ip = db.Column(db.String(255), nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + +class MessageChain(db.Model): + __tablename__ = 'message_chains' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='message_chain_pkey'), + db.Index('message_chain_message_id_idx', 'message_id') + ) + + id = db.Column(UUID, nullable=False, server_default=db.text('uuid_generate_v4()')) + message_id = db.Column(UUID, nullable=False) + type = db.Column(db.String(255), nullable=False) + input = db.Column(db.Text, nullable=True) + output = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.current_timestamp()) + + +class MessageAgentThought(db.Model): + __tablename__ = 'message_agent_thoughts' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='message_agent_thought_pkey'), + db.Index('message_agent_thought_message_id_idx', 'message_id'), + db.Index('message_agent_thought_message_chain_id_idx', 'message_chain_id'), + ) + + id = db.Column(UUID, nullable=False, server_default=db.text('uuid_generate_v4()')) + message_id = db.Column(UUID, nullable=False) + message_chain_id = db.Column(UUID, nullable=False) + position = db.Column(db.Integer, nullable=False) + thought = db.Column(db.Text, nullable=True) + tool = db.Column(db.Text, nullable=True) + tool_input = db.Column(db.Text, nullable=True) + observation = db.Column(db.Text, nullable=True) + # plugin_id = db.Column(UUID, nullable=True) ## for future design + tool_process_data = db.Column(db.Text, nullable=True) + message = db.Column(db.Text, nullable=True) + message_token = db.Column(db.Integer, nullable=True) + message_unit_price = db.Column(db.Numeric, nullable=True) + answer = db.Column(db.Text, nullable=True) + answer_token = db.Column(db.Integer, nullable=True) + answer_unit_price = db.Column(db.Numeric, nullable=True) + tokens = db.Column(db.Integer, nullable=True) + total_price = db.Column(db.Numeric, nullable=True) + currency = db.Column(db.String, nullable=True) + latency = db.Column(db.Float, nullable=True) + created_by_role = db.Column(db.String, nullable=False) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.current_timestamp()) diff --git a/api/models/provider.py b/api/models/provider.py new file mode 100644 index 0000000000..e4ecfa1241 --- /dev/null +++ b/api/models/provider.py @@ -0,0 +1,77 @@ +from enum import Enum + +from sqlalchemy.dialects.postgresql import UUID + +from extensions.ext_database import db + + +class ProviderType(Enum): + CUSTOM = 'custom' + SYSTEM = 'system' + + +class ProviderName(Enum): + OPENAI = 'openai' + AZURE_OPENAI = 'azure_openai' + ANTHROPIC = 'anthropic' + COHERE = 'cohere' + HUGGINGFACEHUB = 'huggingfacehub' + + @staticmethod + def value_of(value): + for member in ProviderName: + if member.value == value: + return member + raise ValueError(f"No matching enum found for value '{value}'") + + +class ProviderQuotaType(Enum): + MONTHLY = 'monthly' + TRIAL = 'trial' + + +class Provider(db.Model): + """ + Provider model representing the API providers and their configurations. + """ + __tablename__ = 'providers' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='provider_pkey'), + db.Index('provider_tenant_id_provider_idx', 'tenant_id', 'provider_name'), + db.UniqueConstraint('tenant_id', 'provider_name', 'provider_type', 'quota_type', name='unique_provider_name_type_quota') + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + tenant_id = db.Column(UUID, nullable=False) + provider_name = db.Column(db.String(40), nullable=False) + provider_type = db.Column(db.String(40), nullable=False, server_default=db.text("'custom'::character varying")) + encrypted_config = db.Column(db.Text, nullable=True) + is_valid = db.Column(db.Boolean, nullable=False, server_default=db.text('false')) + last_used = db.Column(db.DateTime, nullable=True) + + quota_type = db.Column(db.String(40), nullable=True, server_default=db.text("''::character varying")) + quota_limit = db.Column(db.Integer, nullable=True) + quota_used = db.Column(db.Integer, default=0) + + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + updated_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + def __repr__(self): + return f"" + + @property + def token_is_set(self): + """ + Returns True if the encrypted_config is not None, indicating that the token is set. + """ + return self.encrypted_config is not None + + @property + def is_enabled(self): + """ + Returns True if the provider is enabled. + """ + if self.provider_type == ProviderType.SYSTEM.value: + return self.is_valid + else: + return self.is_valid and self.token_is_set diff --git a/api/models/task.py b/api/models/task.py new file mode 100644 index 0000000000..d85cf16d7c --- /dev/null +++ b/api/models/task.py @@ -0,0 +1,37 @@ +from extensions.ext_database import db +from celery import states +from datetime import datetime + + +class CeleryTask(db.Model): + """Task result/status.""" + + __tablename__ = 'celery_taskmeta' + + id = db.Column(db.Integer, db.Sequence('task_id_sequence'), + primary_key=True, autoincrement=True) + task_id = db.Column(db.String(155), unique=True) + status = db.Column(db.String(50), default=states.PENDING) + result = db.Column(db.PickleType, nullable=True) + date_done = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow, nullable=True) + traceback = db.Column(db.Text, nullable=True) + name = db.Column(db.String(155), nullable=True) + args = db.Column(db.LargeBinary, nullable=True) + kwargs = db.Column(db.LargeBinary, nullable=True) + worker = db.Column(db.String(155), nullable=True) + retries = db.Column(db.Integer, nullable=True) + queue = db.Column(db.String(155), nullable=True) + + +class CeleryTaskSet(db.Model): + """TaskSet result.""" + + __tablename__ = 'celery_tasksetmeta' + + id = db.Column(db.Integer, db.Sequence('taskset_id_sequence'), + autoincrement=True, primary_key=True) + taskset_id = db.Column(db.String(155), unique=True) + result = db.Column(db.PickleType, nullable=True) + date_done = db.Column(db.DateTime, default=datetime.utcnow, + nullable=True) diff --git a/api/models/web.py b/api/models/web.py new file mode 100644 index 0000000000..1580ce74c9 --- /dev/null +++ b/api/models/web.py @@ -0,0 +1,36 @@ +from sqlalchemy.dialects.postgresql import UUID + +from extensions.ext_database import db +from models.model import Message + + +class SavedMessage(db.Model): + __tablename__ = 'saved_messages' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='saved_message_pkey'), + db.Index('saved_message_message_idx', 'app_id', 'message_id', 'created_by'), + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + message_id = db.Column(UUID, nullable=False) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def message(self): + return db.session.query(Message).filter(Message.id == self.message_id).first() + + +class PinnedConversation(db.Model): + __tablename__ = 'pinned_conversations' + __table_args__ = ( + db.PrimaryKeyConstraint('id', name='pinned_conversation_pkey'), + db.Index('pinned_conversation_conversation_idx', 'app_id', 'conversation_id', 'created_by'), + ) + + id = db.Column(UUID, server_default=db.text('uuid_generate_v4()')) + app_id = db.Column(UUID, nullable=False) + conversation_id = db.Column(UUID, nullable=False) + created_by = db.Column(UUID, nullable=False) + created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000000..511625ccfd --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,32 @@ +coverage~=7.2.4 +beautifulsoup4==4.12.2 +flask~=2.3.2 +Flask-SQLAlchemy~=3.0.3 +flask-login==0.6.2 +flask-migrate~=4.0.4 +flask-restful==0.3.9 +flask-session2==1.3.1 +flask-cors==3.0.10 +gunicorn~=20.1.0 +gevent~=22.10.2 +langchain==0.0.142 +llama-index==0.5.27 +openai~=0.27.5 +psycopg2-binary~=2.9.6 +pycryptodome==3.17 +python-dotenv==1.0.0 +pytest~=7.3.1 +tiktoken==0.3.3 +Authlib==1.2.0 +boto3~=1.26.123 +tenacity==8.2.2 +cachetools~=5.3.0 +weaviate-client~=3.16.2 +qdrant_client~=1.1.6 +mailchimp-transactional~=1.0.50 +scikit-learn==1.2.2 +sentry-sdk[flask]~=1.21.1 +jieba==0.42.1 +celery==5.2.7 +redis~=4.5.4 +pypdf==3.8.1 \ No newline at end of file diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 0000000000..36a7704385 --- /dev/null +++ b/api/services/__init__.py @@ -0,0 +1,2 @@ +# -*- coding:utf-8 -*- +import services.errors diff --git a/api/services/account_service.py b/api/services/account_service.py new file mode 100644 index 0000000000..8442e0eab8 --- /dev/null +++ b/api/services/account_service.py @@ -0,0 +1,382 @@ +# -*- coding:utf-8 -*- +import base64 +import logging +import secrets +from datetime import datetime +from typing import Optional + +from flask import session +from sqlalchemy import func + +from events.tenant_event import tenant_was_created +from services.errors.account import AccountLoginError, CurrentPasswordIncorrectError, LinkAccountIntegrateError, \ + TenantNotFound, AccountNotLinkTenantError, InvalidActionError, CannotOperateSelfError, MemberNotInTenantError, \ + RoleAlreadyAssignedError, NoPermissionError, AccountRegisterError, AccountAlreadyInTenantError +from libs.helper import get_remote_ip +from libs.password import compare_password, hash_password +from libs.rsa import generate_key_pair +from models.account import * + + +class AccountService: + + @staticmethod + def load_user(account_id: int) -> Account: + # todo: used by flask_login + pass + + @staticmethod + def authenticate(email: str, password: str) -> Account: + """authenticate account with email and password""" + + account = Account.query.filter_by(email=email).first() + if not account: + raise AccountLoginError('Invalid email or password.') + + if account.status == AccountStatus.BANNED.value or account.status == AccountStatus.CLOSED.value: + raise AccountLoginError('Account is banned or closed.') + + if account.status == AccountStatus.PENDING.value: + account.status = AccountStatus.ACTIVE.value + account.initialized_at = datetime.utcnow() + db.session.commit() + + if account.password is None or not compare_password(password, account.password, account.password_salt): + raise AccountLoginError('Invalid email or password.') + return account + + @staticmethod + def update_account_password(account, password, new_password): + """update account password""" + # todo: split validation and update + if account.password and not compare_password(password, account.password, account.password_salt): + raise CurrentPasswordIncorrectError("Current password is incorrect.") + password_hashed = hash_password(new_password, account.password_salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + account.password = base64_password_hashed + db.session.commit() + return account + + @staticmethod + def create_account(email: str, name: str, password: str = None, + interface_language: str = 'en-US', interface_theme: str = 'light', + timezone: str = 'America/New_York', ) -> Account: + """create account""" + account = Account() + account.email = email + account.name = name + + if password: + # generate password salt + salt = secrets.token_bytes(16) + base64_salt = base64.b64encode(salt).decode() + + # encrypt password with salt + password_hashed = hash_password(password, salt) + base64_password_hashed = base64.b64encode(password_hashed).decode() + + account.password = base64_password_hashed + account.password_salt = base64_salt + + account.interface_language = interface_language + account.interface_theme = interface_theme + + if interface_language == 'zh-Hans': + account.timezone = 'Asia/Shanghai' + else: + account.timezone = timezone + + db.session.add(account) + db.session.commit() + return account + + @staticmethod + def link_account_integrate(provider: str, open_id: str, account: Account) -> None: + """Link account integrate""" + try: + # Query whether there is an existing binding record for the same provider + account_integrate: Optional[AccountIntegrate] = AccountIntegrate.query.filter_by(account_id=account.id, + provider=provider).first() + + if account_integrate: + # If it exists, update the record + account_integrate.open_id = open_id + account_integrate.encrypted_token = "" # todo + account_integrate.updated_at = datetime.utcnow() + else: + # If it does not exist, create a new record + account_integrate = AccountIntegrate(account_id=account.id, provider=provider, open_id=open_id, + encrypted_token="") + db.session.add(account_integrate) + + db.session.commit() + logging.info(f'Account {account.id} linked {provider} account {open_id}.') + except Exception as e: + logging.exception(f'Failed to link {provider} account {open_id} to Account {account.id}') + raise LinkAccountIntegrateError('Failed to link account.') from e + + @staticmethod + def close_account(account: Account) -> None: + """todo: Close account""" + account.status = AccountStatus.CLOSED.value + db.session.commit() + + @staticmethod + def update_account(account, **kwargs): + """Update account fields""" + for field, value in kwargs.items(): + if hasattr(account, field): + setattr(account, field, value) + else: + raise AttributeError(f"Invalid field: {field}") + + db.session.commit() + return account + + @staticmethod + def update_last_login(account: Account, request) -> None: + """Update last login time and ip""" + account.last_login_at = datetime.utcnow() + account.last_login_ip = get_remote_ip(request) + db.session.add(account) + db.session.commit() + logging.info(f'Account {account.id} logged in successfully.') + + +class TenantService: + + @staticmethod + def create_tenant(name: str) -> Tenant: + """Create tenant""" + tenant = Tenant(name=name) + + db.session.add(tenant) + db.session.commit() + + tenant.encrypt_public_key = generate_key_pair(tenant.id) + db.session.commit() + return tenant + + @staticmethod + def create_tenant_member(tenant: Tenant, account: Account, role: str = 'normal') -> TenantAccountJoin: + """Create tenant member""" + if role == TenantAccountJoinRole.OWNER.value: + if TenantService.has_roles(tenant, [TenantAccountJoinRole.OWNER]): + logging.error(f'Tenant {tenant.id} has already an owner.') + raise Exception('Tenant already has an owner.') + + ta = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + role=role + ) + db.session.add(ta) + db.session.commit() + return ta + + @staticmethod + def get_join_tenants(account: Account) -> List[Tenant]: + """Get account join tenants""" + return db.session.query(Tenant).join( + TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id + ).filter(TenantAccountJoin.account_id == account.id).all() + + @staticmethod + def get_current_tenant_by_account(account: Account): + """Get tenant by account and add the role""" + tenant = account.current_tenant + if not tenant: + raise TenantNotFound("Tenant not found.") + + ta = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, account_id=account.id).first() + if ta: + tenant.role = ta.role + else: + raise TenantNotFound("Tenant not found for the account.") + return tenant + + @staticmethod + def switch_tenant(account: Account, tenant_id: int = None) -> None: + """Switch the current workspace for the account""" + if not tenant_id: + tenant_account_join = TenantAccountJoin.query.filter_by(account_id=account.id).first() + else: + tenant_account_join = TenantAccountJoin.query.filter_by(account_id=account.id, tenant_id=tenant_id).first() + + # Check if the tenant exists and the account is a member of the tenant + if not tenant_account_join: + raise AccountNotLinkTenantError("Tenant not found or account is not a member of the tenant.") + + # Set the current tenant for the account + account.current_tenant_id = tenant_account_join.tenant_id + session['workspace_id'] = account.current_tenant.id + + @staticmethod + def get_tenant_members(tenant: Tenant) -> List[Account]: + """Get tenant members""" + query = ( + db.session.query(Account, TenantAccountJoin.role) + .select_from(Account) + .join( + TenantAccountJoin, Account.id == TenantAccountJoin.account_id + ) + .filter(TenantAccountJoin.tenant_id == tenant.id) + ) + + # Initialize an empty list to store the updated accounts + updated_accounts = [] + + for account, role in query: + account.role = role + updated_accounts.append(account) + + return updated_accounts + + @staticmethod + def has_roles(tenant: Tenant, roles: List[TenantAccountJoinRole]) -> bool: + """Check if user has any of the given roles for a tenant""" + if not all(isinstance(role, TenantAccountJoinRole) for role in roles): + raise ValueError('all roles must be TenantAccountJoinRole') + + return db.session.query(TenantAccountJoin).filter( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.role.in_([role.value for role in roles]) + ).first() is not None + + @staticmethod + def get_user_role(account: Account, tenant: Tenant) -> Optional[TenantAccountJoinRole]: + """Get the role of the current account for a given tenant""" + join = db.session.query(TenantAccountJoin).filter( + TenantAccountJoin.tenant_id == tenant.id, + TenantAccountJoin.account_id == account.id + ).first() + return join.role if join else None + + @staticmethod + def get_tenant_count() -> int: + """Get tenant count""" + return db.session.query(func.count(Tenant.id)).scalar() + + @staticmethod + def check_member_permission(tenant: Tenant, operator: Account, member: Account, action: str) -> None: + """Check member permission""" + perms = { + 'add': ['owner', 'admin'], + 'remove': ['owner'], + 'update': ['owner'] + } + if action not in ['add', 'remove', 'update']: + raise InvalidActionError("Invalid action.") + + if operator.id == member.id: + raise CannotOperateSelfError("Cannot operate self.") + + ta_operator = TenantAccountJoin.query.filter_by( + tenant_id=tenant.id, + account_id=operator.id + ).first() + + if not ta_operator or ta_operator.role not in perms[action]: + raise NoPermissionError(f'No permission to {action} member.') + + @staticmethod + def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account) -> None: + """Remove member from tenant""" + # todo: check permission + + if operator.id == account.id and TenantService.check_member_permission(tenant, operator, account, 'remove'): + raise CannotOperateSelfError("Cannot operate self.") + + ta = TenantAccountJoin.query.filter_by(tenant_id=tenant.id, account_id=account.id).first() + if not ta: + raise MemberNotInTenantError("Member not in tenant.") + + db.session.delete(ta) + db.session.commit() + + @staticmethod + def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account) -> None: + """Update member role""" + TenantService.check_member_permission(tenant, operator, member, 'update') + + target_member_join = TenantAccountJoin.query.filter_by( + tenant_id=tenant.id, + account_id=member.id + ).first() + + if target_member_join.role == new_role: + raise RoleAlreadyAssignedError("The provided role is already assigned to the member.") + + if new_role == 'owner': + # Find the current owner and change their role to 'admin' + current_owner_join = TenantAccountJoin.query.filter_by( + tenant_id=tenant.id, + role='owner' + ).first() + current_owner_join.role = 'admin' + + # Update the role of the target member + target_member_join.role = new_role + db.session.commit() + + @staticmethod + def dissolve_tenant(tenant: Tenant, operator: Account) -> None: + """Dissolve tenant""" + if not TenantService.check_member_permission(tenant, operator, operator, 'remove'): + raise NoPermissionError('No permission to dissolve tenant.') + db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id).delete() + db.session.delete(tenant) + db.session.commit() + + +class RegisterService: + + @staticmethod + def register(email, name, password: str = None, open_id: str = None, provider: str = None) -> Account: + db.session.begin_nested() + """Register account""" + try: + account = AccountService.create_account(email, name, password) + account.status = AccountStatus.ACTIVE.value + account.initialized_at = datetime.utcnow() + + if open_id is not None or provider is not None: + AccountService.link_account_integrate(provider, open_id, account) + + tenant = TenantService.create_tenant(f"{account.name}'s Workspace") + + TenantService.create_tenant_member(tenant, account, role='owner') + account.current_tenant = tenant + + db.session.commit() + except Exception as e: + db.session.rollback() # todo: do not work + logging.error(f'Register failed: {e}') + raise AccountRegisterError(f'Registration failed: {e}') from e + + tenant_was_created.send(tenant) + + return account + + @staticmethod + def invite_new_member(tenant: Tenant, email: str, role: str = 'normal', + inviter: Account = None) -> TenantAccountJoin: + """Invite new member""" + account = Account.query.filter_by(email=email).first() + + if not account: + name = email.split('@')[0] + account = AccountService.create_account(email, name) + account.status = AccountStatus.PENDING.value + db.session.commit() + else: + TenantService.check_member_permission(tenant, inviter, account, 'add') + ta = TenantAccountJoin.query.filter_by( + tenant_id=tenant.id, + account_id=account.id + ).first() + if ta: + raise AccountAlreadyInTenantError("Account already in tenant.") + + ta = TenantService.create_tenant_member(tenant, account, role) + return ta diff --git a/api/services/app_model_config_service.py b/api/services/app_model_config_service.py new file mode 100644 index 0000000000..70b63fbe19 --- /dev/null +++ b/api/services/app_model_config_service.py @@ -0,0 +1,292 @@ +import re +import uuid + +from core.constant import llm_constant +from models.account import Account +from services.dataset_service import DatasetService +from services.errors.account import NoPermissionError + + +class AppModelConfigService: + @staticmethod + def is_dataset_exists(account: Account, dataset_id: str) -> bool: + # verify if the dataset ID exists + dataset = DatasetService.get_dataset(dataset_id) + + if not dataset: + return False + + if dataset.tenant_id != account.current_tenant_id: + return False + + return True + + @staticmethod + def validate_model_completion_params(cp: dict, model_name: str) -> dict: + # 6. model.completion_params + if not isinstance(cp, dict): + raise ValueError("model.completion_params must be of object type") + + # max_tokens + if 'max_tokens' not in cp: + cp["max_tokens"] = 512 + + if not isinstance(cp["max_tokens"], int) or cp["max_tokens"] <= 0 or cp["max_tokens"] > \ + llm_constant.max_context_token_length[model_name]: + raise ValueError( + "max_tokens must be an integer greater than 0 and not exceeding the maximum value of the corresponding model") + + # temperature + if 'temperature' not in cp: + cp["temperature"] = 1 + + if not isinstance(cp["temperature"], (float, int)) or cp["temperature"] < 0 or cp["temperature"] > 2: + raise ValueError("temperature must be a float between 0 and 2") + + # top_p + if 'top_p' not in cp: + cp["top_p"] = 1 + + if not isinstance(cp["top_p"], (float, int)) or cp["top_p"] < 0 or cp["top_p"] > 2: + raise ValueError("top_p must be a float between 0 and 2") + + # presence_penalty + if 'presence_penalty' not in cp: + cp["presence_penalty"] = 0 + + if not isinstance(cp["presence_penalty"], (float, int)) or cp["presence_penalty"] < -2 or cp["presence_penalty"] > 2: + raise ValueError("presence_penalty must be a float between -2 and 2") + + # presence_penalty + if 'frequency_penalty' not in cp: + cp["frequency_penalty"] = 0 + + if not isinstance(cp["frequency_penalty"], (float, int)) or cp["frequency_penalty"] < -2 or cp["frequency_penalty"] > 2: + raise ValueError("frequency_penalty must be a float between -2 and 2") + + # Filter out extra parameters + filtered_cp = { + "max_tokens": cp["max_tokens"], + "temperature": cp["temperature"], + "top_p": cp["top_p"], + "presence_penalty": cp["presence_penalty"], + "frequency_penalty": cp["frequency_penalty"] + } + + return filtered_cp + + @staticmethod + def validate_configuration(account: Account, config: dict, mode: str) -> dict: + # opening_statement + if 'opening_statement' not in config or not config["opening_statement"]: + config["opening_statement"] = "" + + if not isinstance(config["opening_statement"], str): + raise ValueError("opening_statement must be of string type") + + # suggested_questions + if 'suggested_questions' not in config or not config["suggested_questions"]: + config["suggested_questions"] = [] + + if not isinstance(config["suggested_questions"], list): + raise ValueError("suggested_questions must be of list type") + + for question in config["suggested_questions"]: + if not isinstance(question, str): + raise ValueError("Elements in suggested_questions list must be of string type") + + # suggested_questions_after_answer + if 'suggested_questions_after_answer' not in config or not config["suggested_questions_after_answer"]: + config["suggested_questions_after_answer"] = { + "enabled": False + } + + if not isinstance(config["suggested_questions_after_answer"], dict): + raise ValueError("suggested_questions_after_answer must be of dict type") + + if "enabled" not in config["suggested_questions_after_answer"] or not config["suggested_questions_after_answer"]["enabled"]: + config["suggested_questions_after_answer"]["enabled"] = False + + if not isinstance(config["suggested_questions_after_answer"]["enabled"], bool): + raise ValueError("enabled in suggested_questions_after_answer must be of boolean type") + + # more_like_this + if 'more_like_this' not in config or not config["more_like_this"]: + config["more_like_this"] = { + "enabled": False + } + + if not isinstance(config["more_like_this"], dict): + raise ValueError("more_like_this must be of dict type") + + if "enabled" not in config["more_like_this"] or not config["more_like_this"]["enabled"]: + config["more_like_this"]["enabled"] = False + + if not isinstance(config["more_like_this"]["enabled"], bool): + raise ValueError("enabled in more_like_this must be of boolean type") + + # model + if 'model' not in config: + raise ValueError("model is required") + + if not isinstance(config["model"], dict): + raise ValueError("model must be of object type") + + # model.provider + if 'provider' not in config["model"] or config["model"]["provider"] != "openai": + raise ValueError("model.provider must be 'openai'") + + # model.name + if 'name' not in config["model"]: + raise ValueError("model.name is required") + + if config["model"]["name"] not in llm_constant.models_by_mode[mode]: + raise ValueError("model.name must be in the specified model list") + + # model.completion_params + if 'completion_params' not in config["model"]: + raise ValueError("model.completion_params is required") + + config["model"]["completion_params"] = AppModelConfigService.validate_model_completion_params( + config["model"]["completion_params"], + config["model"]["name"] + ) + + # user_input_form + if "user_input_form" not in config or not config["user_input_form"]: + config["user_input_form"] = [] + + if not isinstance(config["user_input_form"], list): + raise ValueError("user_input_form must be a list of objects") + + variables = [] + for item in config["user_input_form"]: + key = list(item.keys())[0] + if key not in ["text-input", "select"]: + raise ValueError("Keys in user_input_form list can only be 'text-input' or 'select'") + + form_item = item[key] + if 'label' not in form_item: + raise ValueError("label is required in user_input_form") + + if not isinstance(form_item["label"], str): + raise ValueError("label in user_input_form must be of string type") + + if 'variable' not in form_item: + raise ValueError("variable is required in user_input_form") + + if not isinstance(form_item["variable"], str): + raise ValueError("variable in user_input_form must be of string type") + + pattern = re.compile(r"^(?!\d)[\u4e00-\u9fa5A-Za-z0-9_\U0001F300-\U0001F64F\U0001F680-\U0001F6FF]{1,100}$") + if pattern.match(form_item["variable"]) is None: + raise ValueError("variable in user_input_form must be a string, " + "and cannot start with a number") + + variables.append(form_item["variable"]) + + if 'required' not in form_item or not form_item["required"]: + form_item["required"] = False + + if not isinstance(form_item["required"], bool): + raise ValueError("required in user_input_form must be of boolean type") + + if key == "select": + if 'options' not in form_item or not form_item["options"]: + form_item["options"] = [] + + if not isinstance(form_item["options"], list): + raise ValueError("options in user_input_form must be a list of strings") + + if "default" in form_item and form_item['default'] \ + and form_item["default"] not in form_item["options"]: + raise ValueError("default value in user_input_form must be in the options list") + + # pre_prompt + if "pre_prompt" not in config or not config["pre_prompt"]: + config["pre_prompt"] = "" + + if not isinstance(config["pre_prompt"], str): + raise ValueError("pre_prompt must be of string type") + + template_vars = re.findall(r"\{\{(\w+)\}\}", config["pre_prompt"]) + for var in template_vars: + if var not in variables: + raise ValueError("Template variables in pre_prompt must be defined in user_input_form") + + # agent_mode + if "agent_mode" not in config or not config["agent_mode"]: + config["agent_mode"] = { + "enabled": False, + "tools": [] + } + + if not isinstance(config["agent_mode"], dict): + raise ValueError("agent_mode must be of object type") + + if "enabled" not in config["agent_mode"] or not config["agent_mode"]["enabled"]: + config["agent_mode"]["enabled"] = False + + if not isinstance(config["agent_mode"]["enabled"], bool): + raise ValueError("enabled in agent_mode must be of boolean type") + + if "tools" not in config["agent_mode"] or not config["agent_mode"]["tools"]: + config["agent_mode"]["tools"] = [] + + if not isinstance(config["agent_mode"]["tools"], list): + raise ValueError("tools in agent_mode must be a list of objects") + + for tool in config["agent_mode"]["tools"]: + key = list(tool.keys())[0] + if key not in ["sensitive-word-avoidance", "dataset"]: + raise ValueError("Keys in agent_mode.tools list can only be 'sensitive-word-avoidance' or 'dataset'") + + tool_item = tool[key] + + if "enabled" not in tool_item or not tool_item["enabled"]: + tool_item["enabled"] = False + + if not isinstance(tool_item["enabled"], bool): + raise ValueError("enabled in agent_mode.tools must be of boolean type") + + if key == "sensitive-word-avoidance": + if "words" not in tool_item or not tool_item["words"]: + tool_item["words"] = "" + + if not isinstance(tool_item["words"], str): + raise ValueError("words in sensitive-word-avoidance must be of string type") + + if "canned_response" not in tool_item or not tool_item["canned_response"]: + tool_item["canned_response"] = "" + + if not isinstance(tool_item["canned_response"], str): + raise ValueError("canned_response in sensitive-word-avoidance must be of string type") + elif key == "dataset": + if 'id' not in tool_item: + raise ValueError("id is required in dataset") + + try: + uuid.UUID(tool_item["id"]) + except ValueError: + raise ValueError("id in dataset must be of UUID type") + + if not AppModelConfigService.is_dataset_exists(account, tool_item["id"]): + raise ValueError("Dataset ID does not exist, please check your permission.") + + # Filter out extra parameters + filtered_config = { + "opening_statement": config["opening_statement"], + "suggested_questions": config["suggested_questions"], + "suggested_questions_after_answer": config["suggested_questions_after_answer"], + "more_like_this": config["more_like_this"], + "model": { + "provider": config["model"]["provider"], + "name": config["model"]["name"], + "completion_params": config["model"]["completion_params"] + }, + "user_input_form": config["user_input_form"], + "pre_prompt": config["pre_prompt"], + "agent_mode": config["agent_mode"] + } + + return filtered_config diff --git a/api/services/completion_service.py b/api/services/completion_service.py new file mode 100644 index 0000000000..f94cd23d3e --- /dev/null +++ b/api/services/completion_service.py @@ -0,0 +1,506 @@ +import json +import logging +import threading +import time +import uuid +from typing import Generator, Union, Any + +from flask import current_app, Flask +from redis.client import PubSub +from sqlalchemy import and_ + +from core.completion import Completion +from core.conversation_message_task import PubHandler, ConversationTaskStoppedException +from core.llm.error import LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, LLMRateLimitError, \ + LLMAuthorizationError, ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.model import Conversation, AppModelConfig, App, Account, EndUser, Message +from services.app_model_config_service import AppModelConfigService +from services.errors.app import MoreLikeThisDisabledError +from services.errors.app_model_config import AppModelConfigBrokenError +from services.errors.completion import CompletionStoppedError +from services.errors.conversation import ConversationNotExistsError, ConversationCompletedError +from services.errors.message import MessageNotExistsError + + +class CompletionService: + + @classmethod + def completion(cls, app_model: App, user: Union[Account | EndUser], args: Any, + from_source: str, streaming: bool = True, + is_model_config_override: bool = False) -> Union[dict | Generator]: + # is streaming mode + inputs = args['inputs'] + query = args['query'] + conversation_id = args['conversation_id'] if 'conversation_id' in args else None + + conversation = None + if conversation_id: + conversation_filter = [ + Conversation.id == args['conversation_id'], + Conversation.app_id == app_model.id, + Conversation.status == 'normal' + ] + + if from_source == 'console': + conversation_filter.append(Conversation.from_account_id == user.id) + else: + conversation_filter.append(Conversation.from_end_user_id == user.id if user else None) + + conversation = db.session.query(Conversation).filter(and_(*conversation_filter)).first() + + if not conversation: + raise ConversationNotExistsError() + + if conversation.status != 'normal': + raise ConversationCompletedError() + + if not conversation.override_model_configs: + app_model_config = db.session.query(AppModelConfig).get(conversation.app_model_config_id) + + if not app_model_config: + raise AppModelConfigBrokenError() + else: + conversation_override_model_configs = json.loads(conversation.override_model_configs) + app_model_config = AppModelConfig( + id=conversation.app_model_config_id, + app_id=app_model.id, + provider="", + model_id="", + configs="", + opening_statement=conversation_override_model_configs['opening_statement'], + suggested_questions=json.dumps(conversation_override_model_configs['suggested_questions']), + model=json.dumps(conversation_override_model_configs['model']), + user_input_form=json.dumps(conversation_override_model_configs['user_input_form']), + pre_prompt=conversation_override_model_configs['pre_prompt'], + agent_mode=json.dumps(conversation_override_model_configs['agent_mode']), + ) + + if is_model_config_override: + # build new app model config + if 'model' not in args['model_config']: + raise ValueError('model_config.model is required') + + if 'completion_params' not in args['model_config']['model']: + raise ValueError('model_config.model.completion_params is required') + + completion_params = AppModelConfigService.validate_model_completion_params( + cp=args['model_config']['model']['completion_params'], + model_name=app_model_config.model_dict["name"] + ) + + app_model_config_model = app_model_config.model_dict + app_model_config_model['completion_params'] = completion_params + + app_model_config = AppModelConfig( + id=app_model_config.id, + app_id=app_model.id, + provider="", + model_id="", + configs="", + opening_statement=app_model_config.opening_statement, + suggested_questions=app_model_config.suggested_questions, + model=json.dumps(app_model_config_model), + user_input_form=app_model_config.user_input_form, + pre_prompt=app_model_config.pre_prompt, + agent_mode=app_model_config.agent_mode, + ) + else: + if app_model.app_model_config_id is None: + raise AppModelConfigBrokenError() + + app_model_config = app_model.app_model_config + + if not app_model_config: + raise AppModelConfigBrokenError() + + if is_model_config_override: + if not isinstance(user, Account): + raise Exception("Only account can override model config") + + # validate config + model_config = AppModelConfigService.validate_configuration( + account=user, + config=args['model_config'], + mode=app_model.mode + ) + + app_model_config = AppModelConfig( + id=app_model_config.id, + app_id=app_model.id, + provider="", + model_id="", + configs="", + opening_statement=model_config['opening_statement'], + suggested_questions=json.dumps(model_config['suggested_questions']), + suggested_questions_after_answer=json.dumps(model_config['suggested_questions_after_answer']), + more_like_this=json.dumps(model_config['more_like_this']), + model=json.dumps(model_config['model']), + user_input_form=json.dumps(model_config['user_input_form']), + pre_prompt=model_config['pre_prompt'], + agent_mode=json.dumps(model_config['agent_mode']), + ) + + # clean input by app_model_config form rules + inputs = cls.get_cleaned_inputs(inputs, app_model_config) + + generate_task_id = str(uuid.uuid4()) + + pubsub = redis_client.pubsub() + pubsub.subscribe(PubHandler.generate_channel_name(user, generate_task_id)) + + user = cls.get_real_user_instead_of_proxy_obj(user) + + generate_worker_thread = threading.Thread(target=cls.generate_worker, kwargs={ + 'flask_app': current_app._get_current_object(), + 'generate_task_id': generate_task_id, + 'app_model': app_model, + 'app_model_config': app_model_config, + 'query': query, + 'inputs': inputs, + 'user': user, + 'conversation': conversation, + 'streaming': streaming, + 'is_model_config_override': is_model_config_override + }) + + generate_worker_thread.start() + + # wait for 5 minutes to close the thread + cls.countdown_and_close(generate_worker_thread, pubsub, user, generate_task_id) + + return cls.compact_response(pubsub, streaming) + + @classmethod + def get_real_user_instead_of_proxy_obj(cls, user: Union[Account, EndUser]): + if isinstance(user, Account): + user = db.session.query(Account).get(user.id) + elif isinstance(user, EndUser): + user = db.session.query(EndUser).get(user.id) + else: + raise Exception("Unknown user type") + + return user + + @classmethod + def generate_worker(cls, flask_app: Flask, generate_task_id: str, app_model: App, app_model_config: AppModelConfig, + query: str, inputs: dict, user: Union[Account, EndUser], + conversation: Conversation, streaming: bool, is_model_config_override: bool): + with flask_app.app_context(): + try: + if conversation: + # fixed the state of the conversation object when it detached from the original session + conversation = db.session.query(Conversation).filter_by(id=conversation.id).first() + + # run + Completion.generate( + task_id=generate_task_id, + app=app_model, + app_model_config=app_model_config, + query=query, + inputs=inputs, + user=user, + conversation=conversation, + streaming=streaming, + is_override=is_model_config_override, + ) + except ConversationTaskStoppedException: + pass + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, ProviderTokenNotInitError, QuotaExceededError, + ModelCurrentlyNotSupportError) as e: + db.session.rollback() + PubHandler.pub_error(user, generate_task_id, e) + except LLMAuthorizationError: + db.session.rollback() + PubHandler.pub_error(user, generate_task_id, LLMAuthorizationError('Incorrect API key provided')) + except Exception as e: + db.session.rollback() + logging.exception("Unknown Error in completion") + PubHandler.pub_error(user, generate_task_id, e) + + @classmethod + def countdown_and_close(cls, worker_thread, pubsub, user, generate_task_id) -> threading.Thread: + # wait for 5 minutes to close the thread + timeout = 300 + + def close_pubsub(): + sleep_iterations = 0 + while sleep_iterations < timeout and worker_thread.is_alive(): + time.sleep(1) + sleep_iterations += 1 + + if worker_thread.is_alive(): + PubHandler.stop(user, generate_task_id) + try: + pubsub.close() + except: + pass + + countdown_thread = threading.Thread(target=close_pubsub) + countdown_thread.start() + + return countdown_thread + + @classmethod + def generate_more_like_this(cls, app_model: App, user: Union[Account | EndUser], + message_id: str, streaming: bool = True) -> Union[dict | Generator]: + if not user: + raise ValueError('user cannot be None') + + message = db.session.query(Message).filter( + Message.id == message_id, + Message.app_id == app_model.id, + Message.from_source == ('api' if isinstance(user, EndUser) else 'console'), + Message.from_end_user_id == (user.id if isinstance(user, EndUser) else None), + Message.from_account_id == (user.id if isinstance(user, Account) else None), + ).first() + + if not message: + raise MessageNotExistsError() + + current_app_model_config = app_model.app_model_config + more_like_this = current_app_model_config.more_like_this_dict + + if not current_app_model_config.more_like_this or more_like_this.get("enabled", False) is False: + raise MoreLikeThisDisabledError() + + app_model_config = message.app_model_config + + if message.override_model_configs: + override_model_configs = json.loads(message.override_model_configs) + pre_prompt = override_model_configs.get("pre_prompt", '') + elif app_model_config: + pre_prompt = app_model_config.pre_prompt + else: + raise AppModelConfigBrokenError() + + generate_task_id = str(uuid.uuid4()) + + pubsub = redis_client.pubsub() + pubsub.subscribe(PubHandler.generate_channel_name(user, generate_task_id)) + + user = cls.get_real_user_instead_of_proxy_obj(user) + + generate_worker_thread = threading.Thread(target=cls.generate_more_like_this_worker, kwargs={ + 'flask_app': current_app._get_current_object(), + 'generate_task_id': generate_task_id, + 'app_model': app_model, + 'app_model_config': app_model_config, + 'message': message, + 'pre_prompt': pre_prompt, + 'user': user, + 'streaming': streaming + }) + + generate_worker_thread.start() + + cls.countdown_and_close(generate_worker_thread, pubsub, user, generate_task_id) + + return cls.compact_response(pubsub, streaming) + + @classmethod + def generate_more_like_this_worker(cls, flask_app: Flask, generate_task_id: str, app_model: App, + app_model_config: AppModelConfig, message: Message, pre_prompt: str, + user: Union[Account, EndUser], streaming: bool): + with flask_app.app_context(): + try: + # run + Completion.generate_more_like_this( + task_id=generate_task_id, + app=app_model, + user=user, + message=message, + pre_prompt=pre_prompt, + app_model_config=app_model_config, + streaming=streaming + ) + except ConversationTaskStoppedException: + pass + except (LLMBadRequestError, LLMAPIConnectionError, LLMAPIUnavailableError, + LLMRateLimitError, ProviderTokenNotInitError, QuotaExceededError, + ModelCurrentlyNotSupportError) as e: + db.session.rollback() + PubHandler.pub_error(user, generate_task_id, e) + except LLMAuthorizationError: + db.session.rollback() + PubHandler.pub_error(user, generate_task_id, LLMAuthorizationError('Incorrect API key provided')) + except Exception as e: + db.session.rollback() + logging.exception("Unknown Error in completion") + PubHandler.pub_error(user, generate_task_id, e) + + @classmethod + def get_cleaned_inputs(cls, user_inputs: dict, app_model_config: AppModelConfig): + if user_inputs is None: + user_inputs = {} + + filtered_inputs = {} + + # Filter input variables from form configuration, handle required fields, default values, and option values + input_form_config = app_model_config.user_input_form_list + for config in input_form_config: + input_config = list(config.values())[0] + variable = input_config["variable"] + + input_type = list(config.keys())[0] + + if variable not in user_inputs or not user_inputs[variable]: + if "required" in input_config and input_config["required"]: + raise ValueError(f"{variable} is required in input form") + else: + filtered_inputs[variable] = input_config["default"] if "default" in input_config else "" + continue + + value = user_inputs[variable] + + if input_type == "select": + options = input_config["options"] if "options" in input_config else [] + if value not in options: + raise ValueError(f"{variable} in input form must be one of the following: {options}") + else: + if 'max_length' in variable: + max_length = variable['max_length'] + if len(value) > max_length: + raise ValueError(f'{variable} in input form must be less than {max_length} characters') + + filtered_inputs[variable] = value + + return filtered_inputs + + @classmethod + def compact_response(cls, pubsub: PubSub, streaming: bool = False) -> Union[dict | Generator]: + generate_channel = list(pubsub.channels.keys())[0].decode('utf-8') + if not streaming: + try: + for message in pubsub.listen(): + if message["type"] == "message": + result = message["data"].decode('utf-8') + result = json.loads(result) + if result.get('error'): + cls.handle_error(result) + + return cls.get_message_response_data(result.get('data')) + except ValueError as e: + if e.args[0] != "I/O operation on closed file.": # ignore this error + raise CompletionStoppedError() + else: + logging.exception(e) + raise + finally: + try: + pubsub.unsubscribe(generate_channel) + except ConnectionError: + pass + else: + def generate() -> Generator: + try: + for message in pubsub.listen(): + if message["type"] == "message": + result = message["data"].decode('utf-8') + result = json.loads(result) + if result.get('error'): + cls.handle_error(result) + + event = result.get('event') + if event == "end": + logging.debug("{} finished".format(generate_channel)) + break + + if event == 'message': + yield "data: " + json.dumps(cls.get_message_response_data(result.get('data'))) + "\n\n" + elif event == 'chain': + yield "data: " + json.dumps(cls.get_chain_response_data(result.get('data'))) + "\n\n" + elif event == 'agent_thought': + yield "data: " + json.dumps(cls.get_agent_thought_response_data(result.get('data'))) + "\n\n" + except ValueError as e: + if e.args[0] != "I/O operation on closed file.": # ignore this error + logging.exception(e) + raise + finally: + try: + pubsub.unsubscribe(generate_channel) + except ConnectionError: + pass + + return generate() + + @classmethod + def get_message_response_data(cls, data: dict): + response_data = { + 'event': 'message', + 'task_id': data.get('task_id'), + 'id': data.get('message_id'), + 'answer': data.get('text'), + 'created_at': int(time.time()) + } + + if data.get('mode') == 'chat': + response_data['conversation_id'] = data.get('conversation_id') + + return response_data + + @classmethod + def get_chain_response_data(cls, data: dict): + response_data = { + 'event': 'chain', + 'id': data.get('chain_id'), + 'task_id': data.get('task_id'), + 'message_id': data.get('message_id'), + 'type': data.get('type'), + 'input': data.get('input'), + 'output': data.get('output'), + 'created_at': int(time.time()) + } + + if data.get('mode') == 'chat': + response_data['conversation_id'] = data.get('conversation_id') + + return response_data + + @classmethod + def get_agent_thought_response_data(cls, data: dict): + response_data = { + 'event': 'agent_thought', + 'id': data.get('agent_thought_id'), + 'chain_id': data.get('chain_id'), + 'task_id': data.get('task_id'), + 'message_id': data.get('message_id'), + 'position': data.get('position'), + 'thought': data.get('thought'), + 'tool': data.get('tool'), # todo use real dataset obj replace it + 'tool_input': data.get('tool_input'), + 'observation': data.get('observation'), + 'answer': data.get('answer') if not data.get('thought') else '', + 'created_at': int(time.time()) + } + + if data.get('mode') == 'chat': + response_data['conversation_id'] = data.get('conversation_id') + + return response_data + + @classmethod + def handle_error(cls, result: dict): + logging.debug("error: %s", result) + error = result.get('error') + description = result.get('description') + + # handle errors + llm_errors = { + 'LLMBadRequestError': LLMBadRequestError, + 'LLMAPIConnectionError': LLMAPIConnectionError, + 'LLMAPIUnavailableError': LLMAPIUnavailableError, + 'LLMRateLimitError': LLMRateLimitError, + 'ProviderTokenNotInitError': ProviderTokenNotInitError, + 'QuotaExceededError': QuotaExceededError, + 'ModelCurrentlyNotSupportError': ModelCurrentlyNotSupportError + } + + if error in llm_errors: + raise llm_errors[error](description) + elif error == 'LLMAuthorizationError': + raise LLMAuthorizationError('Incorrect API key provided') + else: + raise Exception(description) diff --git a/api/services/conversation_service.py b/api/services/conversation_service.py new file mode 100644 index 0000000000..968209c71e --- /dev/null +++ b/api/services/conversation_service.py @@ -0,0 +1,94 @@ +from typing import Union, Optional + +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from extensions.ext_database import db +from models.account import Account +from models.model import Conversation, App, EndUser +from services.errors.conversation import ConversationNotExistsError, LastConversationNotExistsError + + +class ConversationService: + @classmethod + def pagination_by_last_id(cls, app_model: App, user: Optional[Union[Account | EndUser]], + last_id: Optional[str], limit: int, + include_ids: Optional[list] = None, exclude_ids: Optional[list] = None) -> InfiniteScrollPagination: + if not user: + return InfiniteScrollPagination(data=[], limit=limit, has_more=False) + + base_query = db.session.query(Conversation).filter( + Conversation.app_id == app_model.id, + Conversation.from_source == ('api' if isinstance(user, EndUser) else 'console'), + Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), + Conversation.from_account_id == (user.id if isinstance(user, Account) else None), + ) + + if include_ids is not None: + base_query = base_query.filter(Conversation.id.in_(include_ids)) + + if exclude_ids is not None: + base_query = base_query.filter(~Conversation.id.in_(exclude_ids)) + + if last_id: + last_conversation = base_query.filter( + Conversation.id == last_id, + ).first() + + if not last_conversation: + raise LastConversationNotExistsError() + + conversations = base_query.filter( + Conversation.created_at < last_conversation.created_at, + Conversation.id != last_conversation.id + ).order_by(Conversation.created_at.desc()).limit(limit).all() + else: + conversations = base_query.order_by(Conversation.created_at.desc()).limit(limit).all() + + has_more = False + if len(conversations) == limit: + current_page_first_conversation = conversations[-1] + rest_count = base_query.filter( + Conversation.created_at < current_page_first_conversation.created_at, + Conversation.id != current_page_first_conversation.id + ).count() + + if rest_count > 0: + has_more = True + + return InfiniteScrollPagination( + data=conversations, + limit=limit, + has_more=has_more + ) + + @classmethod + def rename(cls, app_model: App, conversation_id: str, + user: Optional[Union[Account | EndUser]], name: str): + conversation = cls.get_conversation(app_model, conversation_id, user) + + conversation.name = name + db.session.commit() + + return conversation + + @classmethod + def get_conversation(cls, app_model: App, conversation_id: str, user: Optional[Union[Account | EndUser]]): + conversation = db.session.query(Conversation) \ + .filter( + Conversation.id == conversation_id, + Conversation.app_id == app_model.id, + Conversation.from_source == ('api' if isinstance(user, EndUser) else 'console'), + Conversation.from_end_user_id == (user.id if isinstance(user, EndUser) else None), + Conversation.from_account_id == (user.id if isinstance(user, Account) else None), + ).first() + + if not conversation: + raise ConversationNotExistsError() + + return conversation + + @classmethod + def delete(cls, app_model: App, conversation_id: str, user: Optional[Union[Account | EndUser]]): + conversation = cls.get_conversation(app_model, conversation_id, user) + + db.session.delete(conversation) + db.session.commit() diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py new file mode 100644 index 0000000000..39004c3437 --- /dev/null +++ b/api/services/dataset_service.py @@ -0,0 +1,521 @@ +import json +import logging +import datetime +import time +import random +from typing import Optional +from extensions.ext_redis import redis_client +from flask_login import current_user + +from core.index.index_builder import IndexBuilder +from events.dataset_event import dataset_was_deleted +from events.document_event import document_was_deleted +from extensions.ext_database import db +from models.account import Account +from models.dataset import Dataset, Document, DatasetQuery, DatasetProcessRule, AppDatasetJoin +from models.model import UploadFile +from services.errors.account import NoPermissionError +from services.errors.dataset import DatasetNameDuplicateError +from services.errors.document import DocumentIndexingError +from services.errors.file import FileNotExistsError +from tasks.document_indexing_task import document_indexing_task + + +class DatasetService: + + @staticmethod + def get_datasets(page, per_page, provider="vendor", tenant_id=None, user=None): + if user: + permission_filter = db.or_(Dataset.created_by == user.id, + Dataset.permission == 'all_team_members') + else: + permission_filter = Dataset.permission == 'all_team_members' + datasets = Dataset.query.filter( + db.and_(Dataset.provider == provider, Dataset.tenant_id == tenant_id, permission_filter)) \ + .paginate( + page=page, + per_page=per_page, + max_per_page=100, + error_out=False + ) + + return datasets.items, datasets.total + + @staticmethod + def get_process_rules(dataset_id): + # get the latest process rule + dataset_process_rule = db.session.query(DatasetProcessRule). \ + filter(DatasetProcessRule.dataset_id == dataset_id). \ + order_by(DatasetProcessRule.created_at.desc()). \ + limit(1). \ + one_or_none() + if dataset_process_rule: + mode = dataset_process_rule.mode + rules = dataset_process_rule.rules_dict + else: + mode = DocumentService.DEFAULT_RULES['mode'] + rules = DocumentService.DEFAULT_RULES['rules'] + return { + 'mode': mode, + 'rules': rules + } + + @staticmethod + def get_datasets_by_ids(ids, tenant_id): + datasets = Dataset.query.filter(Dataset.id.in_(ids), + Dataset.tenant_id == tenant_id).paginate( + page=1, per_page=len(ids), max_per_page=len(ids), error_out=False) + return datasets.items, datasets.total + + @staticmethod + def create_empty_dataset(tenant_id: str, name: str, indexing_technique: Optional[str], account: Account): + # check if dataset name already exists + if Dataset.query.filter_by(name=name, tenant_id=tenant_id).first(): + raise DatasetNameDuplicateError( + f'Dataset with name {name} already exists.') + + dataset = Dataset(name=name, indexing_technique=indexing_technique, data_source_type='upload_file') + # dataset = Dataset(name=name, provider=provider, config=config) + dataset.created_by = account.id + dataset.updated_by = account.id + dataset.tenant_id = tenant_id + db.session.add(dataset) + db.session.commit() + return dataset + + @staticmethod + def get_dataset(dataset_id): + dataset = Dataset.query.filter_by( + id=dataset_id + ).first() + if dataset is None: + return None + else: + return dataset + + @staticmethod + def update_dataset(dataset_id, data, user): + dataset = DatasetService.get_dataset(dataset_id) + DatasetService.check_dataset_permission(dataset, user) + + filtered_data = {k: v for k, v in data.items() if v is not None or k == 'description'} + + filtered_data['updated_by'] = user.id + filtered_data['updated_at'] = datetime.datetime.now() + + dataset.query.filter_by(id=dataset_id).update(filtered_data) + + db.session.commit() + + return dataset + + @staticmethod + def delete_dataset(dataset_id, user): + # todo: cannot delete dataset if it is being processed + + dataset = DatasetService.get_dataset(dataset_id) + + if dataset is None: + return False + + DatasetService.check_dataset_permission(dataset, user) + + dataset_was_deleted.send(dataset) + + db.session.delete(dataset) + db.session.commit() + return True + + @staticmethod + def check_dataset_permission(dataset, user): + if dataset.tenant_id != user.current_tenant_id: + logging.debug( + f'User {user.id} does not have permission to access dataset {dataset.id}') + raise NoPermissionError( + 'You do not have permission to access this dataset.') + if dataset.permission == 'only_me' and dataset.created_by != user.id: + logging.debug( + f'User {user.id} does not have permission to access dataset {dataset.id}') + raise NoPermissionError( + 'You do not have permission to access this dataset.') + + @staticmethod + def get_dataset_queries(dataset_id: str, page: int, per_page: int): + dataset_queries = DatasetQuery.query.filter_by(dataset_id=dataset_id) \ + .order_by(db.desc(DatasetQuery.created_at)) \ + .paginate( + page=page, per_page=per_page, max_per_page=100, error_out=False + ) + return dataset_queries.items, dataset_queries.total + + @staticmethod + def get_related_apps(dataset_id: str): + return AppDatasetJoin.query.filter(AppDatasetJoin.dataset_id == dataset_id) \ + .order_by(db.desc(AppDatasetJoin.created_at)).all() + + +class DocumentService: + DEFAULT_RULES = { + 'mode': 'custom', + 'rules': { + 'pre_processing_rules': [ + {'id': 'remove_extra_spaces', 'enabled': True}, + {'id': 'remove_urls_emails', 'enabled': False} + ], + 'segmentation': { + 'delimiter': '\n', + 'max_tokens': 500 + } + } + } + + DOCUMENT_METADATA_SCHEMA = { + "book": { + "title": str, + "language": str, + "author": str, + "publisher": str, + "publication_date": str, + "isbn": str, + "category": str, + }, + "web_page": { + "title": str, + "url": str, + "language": str, + "publish_date": str, + "author/publisher": str, + "topic/keywords": str, + "description": str, + }, + "paper": { + "title": str, + "language": str, + "author": str, + "publish_date": str, + "journal/conference_name": str, + "volume/issue/page_numbers": str, + "doi": str, + "topic/keywords": str, + "abstract": str, + }, + "social_media_post": { + "platform": str, + "author/username": str, + "publish_date": str, + "post_url": str, + "topic/tags": str, + }, + "wikipedia_entry": { + "title": str, + "language": str, + "web_page_url": str, + "last_edit_date": str, + "editor/contributor": str, + "summary/introduction": str, + }, + "personal_document": { + "title": str, + "author": str, + "creation_date": str, + "last_modified_date": str, + "document_type": str, + "tags/category": str, + }, + "business_document": { + "title": str, + "author": str, + "creation_date": str, + "last_modified_date": str, + "document_type": str, + "department/team": str, + }, + "im_chat_log": { + "chat_platform": str, + "chat_participants/group_name": str, + "start_date": str, + "end_date": str, + "summary": str, + }, + "synced_from_notion": { + "title": str, + "language": str, + "author/creator": str, + "creation_date": str, + "last_modified_date": str, + "notion_page_link": str, + "category/tags": str, + "description": str, + }, + "synced_from_github": { + "repository_name": str, + "repository_description": str, + "repository_owner/organization": str, + "code_filename": str, + "code_file_path": str, + "programming_language": str, + "github_link": str, + "open_source_license": str, + "commit_date": str, + "commit_author": str + } + } + + @staticmethod + def get_document(dataset_id: str, document_id: str) -> Optional[Document]: + document = db.session.query(Document).filter( + Document.id == document_id, + Document.dataset_id == dataset_id + ).first() + + return document + + @staticmethod + def get_document_file_detail(file_id: str): + file_detail = db.session.query(UploadFile). \ + filter(UploadFile.id == file_id). \ + one_or_none() + return file_detail + + @staticmethod + def check_archived(document): + if document.archived: + return True + else: + return False + + @staticmethod + def delete_document(document): + if document.indexing_status in ["parsing", "cleaning", "splitting", "indexing"]: + raise DocumentIndexingError() + + # trigger document_was_deleted signal + document_was_deleted.send(document.id, dataset_id=document.dataset_id) + + db.session.delete(document) + db.session.commit() + + @staticmethod + def pause_document(document): + if document.indexing_status not in ["waiting", "parsing", "cleaning", "splitting", "indexing"]: + raise DocumentIndexingError() + # update document to be paused + document.is_paused = True + document.paused_by = current_user.id + document.paused_at = datetime.datetime.utcnow() + + db.session.add(document) + db.session.commit() + # set document paused flag + indexing_cache_key = 'document_{}_is_paused'.format(document.id) + redis_client.setnx(indexing_cache_key, "True") + + @staticmethod + def recover_document(document): + if not document.is_paused: + raise DocumentIndexingError() + # update document to be recover + document.is_paused = False + document.paused_by = current_user.id + document.paused_at = time.time() + + db.session.add(document) + db.session.commit() + # delete paused flag + indexing_cache_key = 'document_{}_is_paused'.format(document.id) + redis_client.delete(indexing_cache_key) + # trigger async task + document_indexing_task.delay(document.dataset_id, document.id) + + @staticmethod + def get_documents_position(dataset_id): + documents = Document.query.filter_by(dataset_id=dataset_id).all() + if documents: + return len(documents) + 1 + else: + return 1 + + @staticmethod + def save_document_with_dataset_id(dataset: Dataset, document_data: dict, + account: Account, dataset_process_rule: Optional[DatasetProcessRule] = None, + created_from: str = 'web'): + if not dataset.indexing_technique: + if 'indexing_technique' not in document_data \ + or document_data['indexing_technique'] not in Dataset.INDEXING_TECHNIQUE_LIST: + raise ValueError("Indexing technique is required") + + dataset.indexing_technique = document_data["indexing_technique"] + + if dataset.indexing_technique == 'high_quality': + IndexBuilder.get_default_service_context(dataset.tenant_id) + + # save process rule + if not dataset_process_rule: + process_rule = document_data["process_rule"] + if process_rule["mode"] == "custom": + dataset_process_rule = DatasetProcessRule( + dataset_id=dataset.id, + mode=process_rule["mode"], + rules=json.dumps(process_rule["rules"]), + created_by=account.id + ) + elif process_rule["mode"] == "automatic": + dataset_process_rule = DatasetProcessRule( + dataset_id=dataset.id, + mode=process_rule["mode"], + rules=json.dumps(DatasetProcessRule.AUTOMATIC_RULES), + created_by=account.id + ) + db.session.add(dataset_process_rule) + db.session.commit() + + file_name = '' + data_source_info = {} + if document_data["data_source"]["type"] == "upload_file": + file_id = document_data["data_source"]["info"] + file = db.session.query(UploadFile).filter( + UploadFile.tenant_id == dataset.tenant_id, + UploadFile.id == file_id + ).first() + + # raise error if file not found + if not file: + raise FileNotExistsError() + + file_name = file.name + data_source_info = { + "upload_file_id": file_id, + } + + # save document + position = DocumentService.get_documents_position(dataset.id) + document = Document( + tenant_id=dataset.tenant_id, + dataset_id=dataset.id, + position=position, + data_source_type=document_data["data_source"]["type"], + data_source_info=json.dumps(data_source_info), + dataset_process_rule_id=dataset_process_rule.id, + batch=time.strftime('%Y%m%d%H%M%S') + str(random.randint(100000, 999999)), + name=file_name, + created_from=created_from, + created_by=account.id, + # created_api_request_id = db.Column(UUID, nullable=True) + ) + + db.session.add(document) + db.session.commit() + + # trigger async task + document_indexing_task.delay(document.dataset_id, document.id) + + return document + + @staticmethod + def save_document_without_dataset_id(tenant_id: str, document_data: dict, account: Account): + # save dataset + dataset = Dataset( + tenant_id=tenant_id, + name='', + data_source_type=document_data["data_source"]["type"], + indexing_technique=document_data["indexing_technique"], + created_by=account.id + ) + + db.session.add(dataset) + db.session.flush() + + document = DocumentService.save_document_with_dataset_id(dataset, document_data, account) + + cut_length = 18 + cut_name = document.name[:cut_length] + dataset.name = cut_name + '...' if len(document.name) > cut_length else cut_name + dataset.description = 'useful for when you want to answer queries about the ' + document.name + db.session.commit() + + return dataset, document + + @classmethod + def document_create_args_validate(cls, args: dict): + if 'data_source' not in args or not args['data_source']: + raise ValueError("Data source is required") + + if not isinstance(args['data_source'], dict): + raise ValueError("Data source is invalid") + + if 'type' not in args['data_source'] or not args['data_source']['type']: + raise ValueError("Data source type is required") + + if args['data_source']['type'] not in Document.DATA_SOURCES: + raise ValueError("Data source type is invalid") + + if args['data_source']['type'] == 'upload_file': + if 'info' not in args['data_source'] or not args['data_source']['info']: + raise ValueError("Data source info is required") + + if 'process_rule' not in args or not args['process_rule']: + raise ValueError("Process rule is required") + + if not isinstance(args['process_rule'], dict): + raise ValueError("Process rule is invalid") + + if 'mode' not in args['process_rule'] or not args['process_rule']['mode']: + raise ValueError("Process rule mode is required") + + if args['process_rule']['mode'] not in DatasetProcessRule.MODES: + raise ValueError("Process rule mode is invalid") + + if args['process_rule']['mode'] == 'automatic': + args['process_rule']['rules'] = {} + else: + if 'rules' not in args['process_rule'] or not args['process_rule']['rules']: + raise ValueError("Process rule rules is required") + + if not isinstance(args['process_rule']['rules'], dict): + raise ValueError("Process rule rules is invalid") + + if 'pre_processing_rules' not in args['process_rule']['rules'] \ + or args['process_rule']['rules']['pre_processing_rules'] is None: + raise ValueError("Process rule pre_processing_rules is required") + + if not isinstance(args['process_rule']['rules']['pre_processing_rules'], list): + raise ValueError("Process rule pre_processing_rules is invalid") + + unique_pre_processing_rule_dicts = {} + for pre_processing_rule in args['process_rule']['rules']['pre_processing_rules']: + if 'id' not in pre_processing_rule or not pre_processing_rule['id']: + raise ValueError("Process rule pre_processing_rules id is required") + + if pre_processing_rule['id'] not in DatasetProcessRule.PRE_PROCESSING_RULES: + raise ValueError("Process rule pre_processing_rules id is invalid") + + if 'enabled' not in pre_processing_rule or pre_processing_rule['enabled'] is None: + raise ValueError("Process rule pre_processing_rules enabled is required") + + if not isinstance(pre_processing_rule['enabled'], bool): + raise ValueError("Process rule pre_processing_rules enabled is invalid") + + unique_pre_processing_rule_dicts[pre_processing_rule['id']] = pre_processing_rule + + args['process_rule']['rules']['pre_processing_rules'] = list(unique_pre_processing_rule_dicts.values()) + + if 'segmentation' not in args['process_rule']['rules'] \ + or args['process_rule']['rules']['segmentation'] is None: + raise ValueError("Process rule segmentation is required") + + if not isinstance(args['process_rule']['rules']['segmentation'], dict): + raise ValueError("Process rule segmentation is invalid") + + if 'separator' not in args['process_rule']['rules']['segmentation'] \ + or not args['process_rule']['rules']['segmentation']['separator']: + raise ValueError("Process rule segmentation separator is required") + + if not isinstance(args['process_rule']['rules']['segmentation']['separator'], str): + raise ValueError("Process rule segmentation separator is invalid") + + if 'max_tokens' not in args['process_rule']['rules']['segmentation'] \ + or not args['process_rule']['rules']['segmentation']['max_tokens']: + raise ValueError("Process rule segmentation max_tokens is required") + + if not isinstance(args['process_rule']['rules']['segmentation']['max_tokens'], int): + raise ValueError("Process rule segmentation max_tokens is invalid") diff --git a/api/services/errors/__init__.py b/api/services/errors/__init__.py new file mode 100644 index 0000000000..fe77ca86b6 --- /dev/null +++ b/api/services/errors/__init__.py @@ -0,0 +1,7 @@ +# -*- coding:utf-8 -*- +__all__ = [ + 'base', 'conversation', 'message', 'index', 'app_model_config', 'account', 'document', 'dataset', + 'app', 'completion' +] + +from . import * diff --git a/api/services/errors/account.py b/api/services/errors/account.py new file mode 100644 index 0000000000..14612eed75 --- /dev/null +++ b/api/services/errors/account.py @@ -0,0 +1,53 @@ +from services.errors.base import BaseServiceError + + +class AccountNotFound(BaseServiceError): + pass + + +class AccountRegisterError(BaseServiceError): + pass + + +class AccountLoginError(BaseServiceError): + pass + + +class AccountNotLinkTenantError(BaseServiceError): + pass + + +class CurrentPasswordIncorrectError(BaseServiceError): + pass + + +class LinkAccountIntegrateError(BaseServiceError): + pass + + +class TenantNotFound(BaseServiceError): + pass + + +class AccountAlreadyInTenantError(BaseServiceError): + pass + + +class InvalidActionError(BaseServiceError): + pass + + +class CannotOperateSelfError(BaseServiceError): + pass + + +class NoPermissionError(BaseServiceError): + pass + + +class MemberNotInTenantError(BaseServiceError): + pass + + +class RoleAlreadyAssignedError(BaseServiceError): + pass diff --git a/api/services/errors/app.py b/api/services/errors/app.py new file mode 100644 index 0000000000..7c4ca99c2a --- /dev/null +++ b/api/services/errors/app.py @@ -0,0 +1,2 @@ +class MoreLikeThisDisabledError(Exception): + pass diff --git a/api/services/errors/app_model_config.py b/api/services/errors/app_model_config.py new file mode 100644 index 0000000000..c0669ed231 --- /dev/null +++ b/api/services/errors/app_model_config.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class AppModelConfigBrokenError(BaseServiceError): + pass diff --git a/api/services/errors/base.py b/api/services/errors/base.py new file mode 100644 index 0000000000..f5d41e17f1 --- /dev/null +++ b/api/services/errors/base.py @@ -0,0 +1,3 @@ +class BaseServiceError(Exception): + def __init__(self, description: str = None): + self.description = description \ No newline at end of file diff --git a/api/services/errors/completion.py b/api/services/errors/completion.py new file mode 100644 index 0000000000..7fc50a588e --- /dev/null +++ b/api/services/errors/completion.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class CompletionStoppedError(BaseServiceError): + pass diff --git a/api/services/errors/conversation.py b/api/services/errors/conversation.py new file mode 100644 index 0000000000..139dd9a70a --- /dev/null +++ b/api/services/errors/conversation.py @@ -0,0 +1,13 @@ +from services.errors.base import BaseServiceError + + +class LastConversationNotExistsError(BaseServiceError): + pass + + +class ConversationNotExistsError(BaseServiceError): + pass + + +class ConversationCompletedError(Exception): + pass diff --git a/api/services/errors/dataset.py b/api/services/errors/dataset.py new file mode 100644 index 0000000000..254a70ffe3 --- /dev/null +++ b/api/services/errors/dataset.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class DatasetNameDuplicateError(BaseServiceError): + pass diff --git a/api/services/errors/document.py b/api/services/errors/document.py new file mode 100644 index 0000000000..7327b9d032 --- /dev/null +++ b/api/services/errors/document.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class DocumentIndexingError(BaseServiceError): + pass diff --git a/api/services/errors/file.py b/api/services/errors/file.py new file mode 100644 index 0000000000..3674eca3e7 --- /dev/null +++ b/api/services/errors/file.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class FileNotExistsError(BaseServiceError): + pass diff --git a/api/services/errors/index.py b/api/services/errors/index.py new file mode 100644 index 0000000000..8513b6a55d --- /dev/null +++ b/api/services/errors/index.py @@ -0,0 +1,5 @@ +from services.errors.base import BaseServiceError + + +class IndexNotInitializedError(BaseServiceError): + pass diff --git a/api/services/errors/message.py b/api/services/errors/message.py new file mode 100644 index 0000000000..969447df9f --- /dev/null +++ b/api/services/errors/message.py @@ -0,0 +1,17 @@ +from services.errors.base import BaseServiceError + + +class FirstMessageNotExistsError(BaseServiceError): + pass + + +class LastMessageNotExistsError(BaseServiceError): + pass + + +class MessageNotExistsError(BaseServiceError): + pass + + +class SuggestedQuestionsAfterAnswerDisabledError(BaseServiceError): + pass diff --git a/api/services/hit_testing_service.py b/api/services/hit_testing_service.py new file mode 100644 index 0000000000..619df1b873 --- /dev/null +++ b/api/services/hit_testing_service.py @@ -0,0 +1,130 @@ +import logging +import time +from typing import List + +import numpy as np +from llama_index.data_structs.node_v2 import NodeWithScore +from llama_index.indices.query.schema import QueryBundle +from llama_index.indices.vector_store import GPTVectorStoreIndexQuery +from sklearn.manifold import TSNE + +from core.docstore.empty_docstore import EmptyDocumentStore +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from models.account import Account +from models.dataset import Dataset, DocumentSegment, DatasetQuery +from services.errors.index import IndexNotInitializedError + + +class HitTestingService: + @classmethod + def retrieve(cls, dataset: Dataset, query: str, account: Account, limit: int = 10) -> dict: + index = VectorIndex(dataset=dataset).query_index + + if not index: + raise IndexNotInitializedError() + + index_query = GPTVectorStoreIndexQuery( + index_struct=index.index_struct, + service_context=index.service_context, + vector_store=index.query_context.get('vector_store'), + docstore=EmptyDocumentStore(), + response_synthesizer=None, + similarity_top_k=limit + ) + + query_bundle = QueryBundle( + query_str=query, + custom_embedding_strs=[query], + ) + + query_bundle.embedding = index.service_context.embed_model.get_agg_embedding_from_queries( + query_bundle.embedding_strs + ) + + start = time.perf_counter() + nodes = index_query.retrieve(query_bundle=query_bundle) + end = time.perf_counter() + logging.debug(f"Hit testing retrieve in {end - start:0.4f} seconds") + + dataset_query = DatasetQuery( + dataset_id=dataset.id, + content=query, + source='hit_testing', + created_by_role='account', + created_by=account.id + ) + + db.session.add(dataset_query) + db.session.commit() + + return cls.compact_retrieve_response(dataset, query_bundle, nodes) + + @classmethod + def compact_retrieve_response(cls, dataset: Dataset, query_bundle: QueryBundle, nodes: List[NodeWithScore]): + embeddings = [ + query_bundle.embedding + ] + + for node in nodes: + embeddings.append(node.node.embedding) + + tsne_position_data = cls.get_tsne_positions_from_embeddings(embeddings) + + query_position = tsne_position_data.pop(0) + + i = 0 + records = [] + for node in nodes: + index_node_id = node.node.doc_id + + segment = db.session.query(DocumentSegment).filter( + DocumentSegment.dataset_id == dataset.id, + DocumentSegment.enabled == True, + DocumentSegment.status == 'completed', + DocumentSegment.index_node_id == index_node_id + ).first() + + if not segment: + i += 1 + continue + + record = { + "segment": segment, + "score": node.score, + "tsne_position": tsne_position_data[i] + } + + records.append(record) + + i += 1 + + return { + "query": { + "content": query_bundle.query_str, + "tsne_position": query_position, + }, + "records": records + } + + @classmethod + def get_tsne_positions_from_embeddings(cls, embeddings: list): + embedding_length = len(embeddings) + if embedding_length <= 1: + return [{'x': 0, 'y': 0}] + + concatenate_data = np.array(embeddings).reshape(embedding_length, -1) + # concatenate_data = np.concatenate(embeddings) + + perplexity = embedding_length / 2 + 1 + if perplexity >= embedding_length: + perplexity = max(embedding_length - 1, 1) + + tsne = TSNE(n_components=2, perplexity=perplexity, early_exaggeration=12.0) + data_tsne = tsne.fit_transform(concatenate_data) + + tsne_position_data = [] + for i in range(len(data_tsne)): + tsne_position_data.append({'x': float(data_tsne[i][0]), 'y': float(data_tsne[i][1])}) + + return tsne_position_data diff --git a/api/services/message_service.py b/api/services/message_service.py new file mode 100644 index 0000000000..b59fb0f10c --- /dev/null +++ b/api/services/message_service.py @@ -0,0 +1,212 @@ +from typing import Optional, Union, List + +from core.completion import Completion +from core.generator.llm_generator import LLMGenerator +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from extensions.ext_database import db +from models.account import Account +from models.model import App, EndUser, Message, MessageFeedback +from services.conversation_service import ConversationService +from services.errors.message import FirstMessageNotExistsError, MessageNotExistsError, LastMessageNotExistsError, \ + SuggestedQuestionsAfterAnswerDisabledError + + +class MessageService: + @classmethod + def pagination_by_first_id(cls, app_model: App, user: Optional[Union[Account | EndUser]], + conversation_id: str, first_id: Optional[str], limit: int) -> InfiniteScrollPagination: + if not user: + return InfiniteScrollPagination(data=[], limit=limit, has_more=False) + + if not conversation_id: + return InfiniteScrollPagination(data=[], limit=limit, has_more=False) + + conversation = ConversationService.get_conversation( + app_model=app_model, + user=user, + conversation_id=conversation_id + ) + + if first_id: + first_message = db.session.query(Message) \ + .filter(Message.conversation_id == conversation.id, Message.id == first_id).first() + + if not first_message: + raise FirstMessageNotExistsError() + + history_messages = db.session.query(Message).filter( + Message.conversation_id == conversation.id, + Message.created_at < first_message.created_at, + Message.id != first_message.id + ) \ + .order_by(Message.created_at.desc()).limit(limit).all() + else: + history_messages = db.session.query(Message).filter(Message.conversation_id == conversation.id) \ + .order_by(Message.created_at.desc()).limit(limit).all() + + has_more = False + if len(history_messages) == limit: + current_page_first_message = history_messages[-1] + rest_count = db.session.query(Message).filter( + Message.conversation_id == conversation.id, + Message.created_at < current_page_first_message.created_at, + Message.id != current_page_first_message.id + ).count() + + if rest_count > 0: + has_more = True + + history_messages = list(reversed(history_messages)) + + return InfiniteScrollPagination( + data=history_messages, + limit=limit, + has_more=has_more + ) + + @classmethod + def pagination_by_last_id(cls, app_model: App, user: Optional[Union[Account | EndUser]], + last_id: Optional[str], limit: int, conversation_id: Optional[str] = None, + include_ids: Optional[list] = None) -> InfiniteScrollPagination: + if not user: + return InfiniteScrollPagination(data=[], limit=limit, has_more=False) + + base_query = db.session.query(Message) + + if conversation_id is not None: + conversation = ConversationService.get_conversation( + app_model=app_model, + user=user, + conversation_id=conversation_id + ) + + base_query = base_query.filter(Message.conversation_id == conversation.id) + + if include_ids is not None: + base_query = base_query.filter(Message.id.in_(include_ids)) + + if last_id: + last_message = base_query.filter(Message.id == last_id).first() + + if not last_message: + raise LastMessageNotExistsError() + + history_messages = base_query.filter( + Message.created_at < last_message.created_at, + Message.id != last_message.id + ).order_by(Message.created_at.desc()).limit(limit).all() + else: + history_messages = base_query.order_by(Message.created_at.desc()).limit(limit).all() + + has_more = False + if len(history_messages) == limit: + current_page_first_message = history_messages[-1] + rest_count = base_query.filter( + Message.created_at < current_page_first_message.created_at, + Message.id != current_page_first_message.id + ).count() + + if rest_count > 0: + has_more = True + + return InfiniteScrollPagination( + data=history_messages, + limit=limit, + has_more=has_more + ) + + @classmethod + def create_feedback(cls, app_model: App, message_id: str, user: Optional[Union[Account | EndUser]], + rating: Optional[str]) -> MessageFeedback: + if not user: + raise ValueError('user cannot be None') + + message = cls.get_message( + app_model=app_model, + user=user, + message_id=message_id + ) + + feedback = message.user_feedback + + if not rating and feedback: + db.session.delete(feedback) + elif rating and feedback: + feedback.rating = rating + elif not rating and not feedback: + raise ValueError('rating cannot be None when feedback not exists') + else: + feedback = MessageFeedback( + app_id=app_model.id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=rating, + from_source=('user' if isinstance(user, EndUser) else 'admin'), + from_end_user_id=(user.id if isinstance(user, EndUser) else None), + from_account_id=(user.id if isinstance(user, Account) else None), + ) + db.session.add(feedback) + + db.session.commit() + + return feedback + + @classmethod + def get_message(cls, app_model: App, user: Optional[Union[Account | EndUser]], message_id: str): + message = db.session.query(Message).filter( + Message.id == message_id, + Message.app_id == app_model.id, + Message.from_source == ('api' if isinstance(user, EndUser) else 'console'), + Message.from_end_user_id == (user.id if isinstance(user, EndUser) else None), + Message.from_account_id == (user.id if isinstance(user, Account) else None), + ).first() + + if not message: + raise MessageNotExistsError() + + return message + + @classmethod + def get_suggested_questions_after_answer(cls, app_model: App, user: Optional[Union[Account | EndUser]], + message_id: str, check_enabled: bool = True) -> List[Message]: + if not user: + raise ValueError('user cannot be None') + + app_model_config = app_model.app_model_config + suggested_questions_after_answer = app_model_config.suggested_questions_after_answer_dict + + if check_enabled and suggested_questions_after_answer.get("enabled", False) is False: + raise SuggestedQuestionsAfterAnswerDisabledError() + + message = cls.get_message( + app_model=app_model, + user=user, + message_id=message_id + ) + + conversation = ConversationService.get_conversation( + app_model=app_model, + conversation_id=message.conversation_id, + user=user + ) + + # get memory of conversation (read-only) + memory = Completion.get_memory_from_conversation( + tenant_id=app_model.tenant_id, + app_model_config=app_model.app_model_config, + conversation=conversation, + max_token_limit=3000, + message_limit=3, + return_messages=False, + memory_key="histories" + ) + + external_context = memory.load_memory_variables({}) + + questions = LLMGenerator.generate_suggested_questions_after_answer( + tenant_id=app_model.tenant_id, + **external_context + ) + + return questions + diff --git a/api/services/provider_service.py b/api/services/provider_service.py new file mode 100644 index 0000000000..7f6c7c9303 --- /dev/null +++ b/api/services/provider_service.py @@ -0,0 +1,96 @@ +from typing import Union + +from flask import current_app + +from core.llm.provider.llm_provider_service import LLMProviderService +from models.account import Tenant +from models.provider import * + + +class ProviderService: + + @staticmethod + def init_supported_provider(tenant, edition): + """Initialize the model provider, check whether the supported provider has a record""" + + providers = Provider.query.filter_by(tenant_id=tenant.id).all() + + openai_provider_exists = False + azure_openai_provider_exists = False + + # TODO: The cloud version needs to construct the data of the SYSTEM type + + for provider in providers: + if provider.provider_name == ProviderName.OPENAI.value and provider.provider_type == ProviderType.CUSTOM.value: + openai_provider_exists = True + if provider.provider_name == ProviderName.AZURE_OPENAI.value and provider.provider_type == ProviderType.CUSTOM.value: + azure_openai_provider_exists = True + + # Initialize the model provider, check whether the supported provider has a record + + # Create default providers if they don't exist + if not openai_provider_exists: + openai_provider = Provider( + tenant_id=tenant.id, + provider_name=ProviderName.OPENAI.value, + provider_type=ProviderType.CUSTOM.value, + is_valid=False + ) + db.session.add(openai_provider) + + if not azure_openai_provider_exists: + azure_openai_provider = Provider( + tenant_id=tenant.id, + provider_name=ProviderName.AZURE_OPENAI.value, + provider_type=ProviderType.CUSTOM.value, + is_valid=False + ) + db.session.add(azure_openai_provider) + + if not openai_provider_exists or not azure_openai_provider_exists: + db.session.commit() + + @staticmethod + def get_obfuscated_api_key(tenant, provider_name: ProviderName): + llm_provider_service = LLMProviderService(tenant.id, provider_name.value) + return llm_provider_service.get_provider_configs(obfuscated=True) + + @staticmethod + def get_token_type(tenant, provider_name: ProviderName): + llm_provider_service = LLMProviderService(tenant.id, provider_name.value) + return llm_provider_service.get_token_type() + + @staticmethod + def validate_provider_configs(tenant, provider_name: ProviderName, configs: Union[dict | str]): + llm_provider_service = LLMProviderService(tenant.id, provider_name.value) + return llm_provider_service.config_validate(configs) + + @staticmethod + def get_encrypted_token(tenant, provider_name: ProviderName, configs: Union[dict | str]): + llm_provider_service = LLMProviderService(tenant.id, provider_name.value) + return llm_provider_service.get_encrypted_token(configs) + + @staticmethod + def create_system_provider(tenant: Tenant, provider_name: str = ProviderName.OPENAI.value, + is_valid: bool = True): + if current_app.config['EDITION'] != 'CLOUD': + return + + provider = db.session.query(Provider).filter( + Provider.tenant_id == tenant.id, + Provider.provider_name == provider_name, + Provider.provider_type == ProviderType.SYSTEM.value + ).one_or_none() + + if not provider: + provider = Provider( + tenant_id=tenant.id, + provider_name=provider_name, + provider_type=ProviderType.SYSTEM.value, + quota_type=ProviderQuotaType.TRIAL.value, + quota_limit=200, + encrypted_config='', + is_valid=is_valid, + ) + db.session.add(provider) + db.session.commit() diff --git a/api/services/saved_message_service.py b/api/services/saved_message_service.py new file mode 100644 index 0000000000..1a68a1ba34 --- /dev/null +++ b/api/services/saved_message_service.py @@ -0,0 +1,66 @@ +from typing import Optional + +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from extensions.ext_database import db +from models.model import App, EndUser +from models.web import SavedMessage +from services.message_service import MessageService + + +class SavedMessageService: + @classmethod + def pagination_by_last_id(cls, app_model: App, end_user: Optional[EndUser], + last_id: Optional[str], limit: int) -> InfiniteScrollPagination: + saved_messages = db.session.query(SavedMessage).filter( + SavedMessage.app_id == app_model.id, + SavedMessage.created_by == end_user.id + ).order_by(SavedMessage.created_at.desc()).all() + message_ids = [sm.message_id for sm in saved_messages] + + return MessageService.pagination_by_last_id( + app_model=app_model, + user=end_user, + last_id=last_id, + limit=limit, + include_ids=message_ids + ) + + @classmethod + def save(cls, app_model: App, user: Optional[EndUser], message_id: str): + saved_message = db.session.query(SavedMessage).filter( + SavedMessage.app_id == app_model.id, + SavedMessage.message_id == message_id, + SavedMessage.created_by == user.id + ).first() + + if saved_message: + return + + message = MessageService.get_message( + app_model=app_model, + user=user, + message_id=message_id + ) + + saved_message = SavedMessage( + app_id=app_model.id, + message_id=message.id, + created_by=user.id + ) + + db.session.add(saved_message) + db.session.commit() + + @classmethod + def delete(cls, app_model: App, user: Optional[EndUser], message_id: str): + saved_message = db.session.query(SavedMessage).filter( + SavedMessage.app_id == app_model.id, + SavedMessage.message_id == message_id, + SavedMessage.created_by == user.id + ).first() + + if not saved_message: + return + + db.session.delete(saved_message) + db.session.commit() diff --git a/api/services/web_conversation_service.py b/api/services/web_conversation_service.py new file mode 100644 index 0000000000..5cfab25006 --- /dev/null +++ b/api/services/web_conversation_service.py @@ -0,0 +1,74 @@ +from typing import Optional, Union + +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from extensions.ext_database import db +from models.model import App, EndUser +from models.web import PinnedConversation +from services.conversation_service import ConversationService + + +class WebConversationService: + @classmethod + def pagination_by_last_id(cls, app_model: App, end_user: Optional[EndUser], + last_id: Optional[str], limit: int, pinned: Optional[bool] = None) -> InfiniteScrollPagination: + include_ids = None + exclude_ids = None + if pinned is not None: + pinned_conversations = db.session.query(PinnedConversation).filter( + PinnedConversation.app_id == app_model.id, + PinnedConversation.created_by == end_user.id + ).order_by(PinnedConversation.created_at.desc()).all() + pinned_conversation_ids = [pc.conversation_id for pc in pinned_conversations] + if pinned: + include_ids = pinned_conversation_ids + else: + exclude_ids = pinned_conversation_ids + + return ConversationService.pagination_by_last_id( + app_model=app_model, + user=end_user, + last_id=last_id, + limit=limit, + include_ids=include_ids, + exclude_ids=exclude_ids + ) + + @classmethod + def pin(cls, app_model: App, conversation_id: str, user: Optional[EndUser]): + pinned_conversation = db.session.query(PinnedConversation).filter( + PinnedConversation.app_id == app_model.id, + PinnedConversation.conversation_id == conversation_id, + PinnedConversation.created_by == user.id + ).first() + + if pinned_conversation: + return + + conversation = ConversationService.get_conversation( + app_model=app_model, + conversation_id=conversation_id, + user=user + ) + + pinned_conversation = PinnedConversation( + app_id=app_model.id, + conversation_id=conversation.id, + created_by=user.id + ) + + db.session.add(pinned_conversation) + db.session.commit() + + @classmethod + def unpin(cls, app_model: App, conversation_id: str, user: Optional[EndUser]): + pinned_conversation = db.session.query(PinnedConversation).filter( + PinnedConversation.app_id == app_model.id, + PinnedConversation.conversation_id == conversation_id, + PinnedConversation.created_by == user.id + ).first() + + if not pinned_conversation: + return + + db.session.delete(pinned_conversation) + db.session.commit() diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py new file mode 100644 index 0000000000..92227ffa7a --- /dev/null +++ b/api/services/workspace_service.py @@ -0,0 +1,49 @@ +from extensions.ext_database import db +from models.account import Tenant +from models.provider import Provider, ProviderType + + +class WorkspaceService: + @classmethod + def get_tenant_info(cls, tenant: Tenant): + tenant_info = { + 'id': tenant.id, + 'name': tenant.name, + 'plan': tenant.plan, + 'status': tenant.status, + 'created_at': tenant.created_at, + 'providers': [], + 'in_trail': False, + 'trial_end_reason': 'using_custom' + } + + # Get providers + providers = db.session.query(Provider).filter( + Provider.tenant_id == tenant.id + ).all() + + # Add providers to the tenant info + tenant_info['providers'] = providers + + custom_provider = None + system_provider = None + + for provider in providers: + if provider.provider_type == ProviderType.CUSTOM.value: + if provider.is_valid and provider.encrypted_config: + custom_provider = provider + elif provider.provider_type == ProviderType.SYSTEM.value: + if provider.is_valid: + system_provider = provider + + if system_provider and not custom_provider: + quota_used = system_provider.quota_used if system_provider.quota_used is not None else 0 + quota_limit = system_provider.quota_limit if system_provider.quota_limit is not None else 0 + + if quota_used >= quota_limit: + tenant_info['trial_end_reason'] = 'trial_exceeded' + else: + tenant_info['in_trail'] = True + tenant_info['trial_end_reason'] = None + + return tenant_info diff --git a/api/tasks/add_document_to_index_task.py b/api/tasks/add_document_to_index_task.py new file mode 100644 index 0000000000..9ea259227e --- /dev/null +++ b/api/tasks/add_document_to_index_task.py @@ -0,0 +1,99 @@ +import datetime +import logging +import time + +import click +from celery import shared_task +from llama_index.data_structs import Node +from llama_index.data_structs.node_v2 import DocumentRelationship +from werkzeug.exceptions import NotFound + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DocumentSegment, Document + + +@shared_task +def add_document_to_index_task(document_id: str): + """ + Async Add document to index + :param document_id: + + Usage: add_document_to_index.delay(document_id) + """ + logging.info(click.style('Start add document to index: {}'.format(document_id), fg='green')) + start_at = time.perf_counter() + + document = db.session.query(Document).filter(Document.id == document_id).first() + if not document: + raise NotFound('Document not found') + + if document.indexing_status != 'completed': + return + + indexing_cache_key = 'document_{}_indexing'.format(document.id) + + try: + segments = db.session.query(DocumentSegment).filter( + DocumentSegment.document_id == document.id, + DocumentSegment.enabled == True + ) \ + .order_by(DocumentSegment.position.asc()).all() + + nodes = [] + previous_node = None + for segment in segments: + relationships = { + DocumentRelationship.SOURCE: document.id + } + + if previous_node: + relationships[DocumentRelationship.PREVIOUS] = previous_node.doc_id + + previous_node.relationships[DocumentRelationship.NEXT] = segment.index_node_id + + node = Node( + doc_id=segment.index_node_id, + doc_hash=segment.index_node_hash, + text=segment.content, + extra_info=None, + node_info=None, + relationships=relationships + ) + + previous_node = node + + nodes.append(node) + + dataset = document.dataset + + if not dataset: + raise Exception('Document has no dataset') + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + # save vector index + if dataset.indexing_technique == "high_quality": + vector_index.add_nodes( + nodes=nodes, + duplicate_check=True + ) + + # save keyword index + keyword_table_index.add_nodes(nodes) + + end_at = time.perf_counter() + logging.info( + click.style('Document added to index: {} latency: {}'.format(document.id, end_at - start_at), fg='green')) + except Exception as e: + logging.exception("add document to index failed") + document.enabled = False + document.disabled_at = datetime.datetime.utcnow() + document.status = 'error' + document.error = str(e) + db.session.commit() + finally: + redis_client.delete(indexing_cache_key) diff --git a/api/tasks/add_segment_to_index_task.py b/api/tasks/add_segment_to_index_task.py new file mode 100644 index 0000000000..bd3cadfd3c --- /dev/null +++ b/api/tasks/add_segment_to_index_task.py @@ -0,0 +1,88 @@ +import datetime +import logging +import time + +import click +from celery import shared_task +from llama_index.data_structs import Node +from llama_index.data_structs.node_v2 import DocumentRelationship +from werkzeug.exceptions import NotFound + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DocumentSegment + + +@shared_task +def add_segment_to_index_task(segment_id: str): + """ + Async Add segment to index + :param segment_id: + + Usage: add_segment_to_index.delay(segment_id) + """ + logging.info(click.style('Start add segment to index: {}'.format(segment_id), fg='green')) + start_at = time.perf_counter() + + segment = db.session.query(DocumentSegment).filter(DocumentSegment.id == segment_id).first() + if not segment: + raise NotFound('Segment not found') + + if segment.status != 'completed': + return + + indexing_cache_key = 'segment_{}_indexing'.format(segment.id) + + try: + relationships = { + DocumentRelationship.SOURCE: segment.document_id, + } + + previous_segment = segment.previous_segment + if previous_segment: + relationships[DocumentRelationship.PREVIOUS] = previous_segment.index_node_id + + next_segment = segment.next_segment + if next_segment: + relationships[DocumentRelationship.NEXT] = next_segment.index_node_id + + node = Node( + doc_id=segment.index_node_id, + doc_hash=segment.index_node_hash, + text=segment.content, + extra_info=None, + node_info=None, + relationships=relationships + ) + + dataset = segment.dataset + + if not dataset: + raise Exception('Segment has no dataset') + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + # save vector index + if dataset.indexing_technique == "high_quality": + vector_index.add_nodes( + nodes=[node], + duplicate_check=True + ) + + # save keyword index + keyword_table_index.add_nodes([node]) + + end_at = time.perf_counter() + logging.info(click.style('Segment added to index: {} latency: {}'.format(segment.id, end_at - start_at), fg='green')) + except Exception as e: + logging.exception("add segment to index failed") + segment.enabled = False + segment.disabled_at = datetime.datetime.utcnow() + segment.status = 'error' + segment.error = str(e) + db.session.commit() + finally: + redis_client.delete(indexing_cache_key) diff --git a/api/tasks/clean_dataset_task.py b/api/tasks/clean_dataset_task.py new file mode 100644 index 0000000000..3c5ea8eb95 --- /dev/null +++ b/api/tasks/clean_dataset_task.py @@ -0,0 +1,77 @@ +import logging +import time + +import click +from celery import shared_task + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from models.dataset import DocumentSegment, Dataset, DatasetKeywordTable, DatasetQuery, DatasetProcessRule, \ + AppDatasetJoin + + +@shared_task +def clean_dataset_task(dataset_id: str, tenant_id: str, indexing_technique: str, index_struct: str): + """ + Clean dataset when dataset deleted. + :param dataset_id: dataset id + :param tenant_id: tenant id + :param indexing_technique: indexing technique + :param index_struct: index struct dict + + Usage: clean_dataset_task.delay(dataset_id, tenant_id, indexing_technique, index_struct) + """ + logging.info(click.style('Start clean dataset when dataset deleted: {}'.format(dataset_id), fg='green')) + start_at = time.perf_counter() + + try: + dataset = Dataset( + id=dataset_id, + tenant_id=tenant_id, + indexing_technique=indexing_technique, + index_struct=index_struct + ) + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + documents = db.session.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset_id).all() + index_doc_ids = [document.id for document in documents] + segments = db.session.query(DocumentSegment).filter(DocumentSegment.dataset_id == dataset_id).all() + index_node_ids = [segment.index_node_id for segment in segments] + + # delete from vector index + if dataset.indexing_technique == "high_quality": + for index_doc_id in index_doc_ids: + try: + vector_index.del_doc(index_doc_id) + except Exception: + logging.exception("Delete doc index failed when dataset deleted.") + continue + + # delete from keyword index + if index_node_ids: + try: + keyword_table_index.del_nodes(index_node_ids) + except Exception: + logging.exception("Delete nodes index failed when dataset deleted.") + + for document in documents: + db.session.delete(document) + + for segment in segments: + db.session.delete(segment) + + db.session.query(DatasetKeywordTable).filter(DatasetKeywordTable.dataset_id == dataset_id).delete() + db.session.query(DatasetProcessRule).filter(DatasetProcessRule.dataset_id == dataset_id).delete() + db.session.query(DatasetQuery).filter(DatasetQuery.dataset_id == dataset_id).delete() + db.session.query(AppDatasetJoin).filter(AppDatasetJoin.dataset_id == dataset_id).delete() + + db.session.commit() + + end_at = time.perf_counter() + logging.info( + click.style('Cleaned dataset when dataset deleted: {} latency: {}'.format(dataset_id, end_at - start_at), fg='green')) + except Exception: + logging.exception("Cleaned dataset when dataset deleted failed") diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py new file mode 100644 index 0000000000..5ca7f2d5c2 --- /dev/null +++ b/api/tasks/clean_document_task.py @@ -0,0 +1,52 @@ +import logging +import time + +import click +from celery import shared_task + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from models.dataset import DocumentSegment, Dataset + + +@shared_task +def clean_document_task(document_id: str, dataset_id: str): + """ + Clean document when document deleted. + :param document_id: document id + :param dataset_id: dataset id + + Usage: clean_document_task.delay(document_id, dataset_id) + """ + logging.info(click.style('Start clean document when document deleted: {}'.format(document_id), fg='green')) + start_at = time.perf_counter() + + try: + dataset = db.session.query(Dataset).filter(Dataset.id == dataset_id).first() + + if not dataset: + raise Exception('Document has no dataset') + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + segments = db.session.query(DocumentSegment).filter(DocumentSegment.document_id == document_id).all() + index_node_ids = [segment.index_node_id for segment in segments] + + # delete from vector index + if dataset.indexing_technique == "high_quality": + vector_index.del_nodes(index_node_ids) + + # delete from keyword index + if index_node_ids: + keyword_table_index.del_nodes(index_node_ids) + + for segment in segments: + db.session.delete(segment) + + end_at = time.perf_counter() + logging.info( + click.style('Cleaned document when document deleted: {} latency: {}'.format(document_id, end_at - start_at), fg='green')) + except Exception: + logging.exception("Cleaned document when document deleted failed") diff --git a/api/tasks/document_indexing_task.py b/api/tasks/document_indexing_task.py new file mode 100644 index 0000000000..59bbd4dc98 --- /dev/null +++ b/api/tasks/document_indexing_task.py @@ -0,0 +1,56 @@ +import datetime +import logging +import time + +import click +from celery import shared_task +from werkzeug.exceptions import NotFound + +from core.indexing_runner import IndexingRunner, DocumentIsPausedException +from core.llm.error import ProviderTokenNotInitError +from extensions.ext_database import db +from models.dataset import Document + + +@shared_task +def document_indexing_task(dataset_id: str, document_id: str): + """ + Async process document + :param dataset_id: + :param document_id: + + Usage: document_indexing_task.delay(dataset_id, document_id) + """ + logging.info(click.style('Start process document: {}'.format(document_id), fg='green')) + start_at = time.perf_counter() + + document = db.session.query(Document).filter( + Document.id == document_id, + Document.dataset_id == dataset_id + ).first() + + if not document: + raise NotFound('Document not found') + + document.indexing_status = 'parsing' + document.processing_started_at = datetime.datetime.utcnow() + db.session.commit() + + try: + indexing_runner = IndexingRunner() + indexing_runner.run(document) + end_at = time.perf_counter() + logging.info(click.style('Processed document: {} latency: {}'.format(document.id, end_at - start_at), fg='green')) + except DocumentIsPausedException: + logging.info(click.style('Document paused, document id: {}'.format(document.id), fg='yellow')) + except ProviderTokenNotInitError as e: + document.indexing_status = 'error' + document.error = str(e.description) + document.stopped_at = datetime.datetime.utcnow() + db.session.commit() + except Exception as e: + logging.exception("consume document failed") + document.indexing_status = 'error' + document.error = str(e) + document.stopped_at = datetime.datetime.utcnow() + db.session.commit() diff --git a/api/tasks/generate_conversation_summary_task.py b/api/tasks/generate_conversation_summary_task.py new file mode 100644 index 0000000000..b19576f6fc --- /dev/null +++ b/api/tasks/generate_conversation_summary_task.py @@ -0,0 +1,46 @@ +import logging +import time + +import click +from celery import shared_task +from werkzeug.exceptions import NotFound + +from core.generator.llm_generator import LLMGenerator +from extensions.ext_database import db +from models.model import Conversation, Message + + +@shared_task +def generate_conversation_summary_task(conversation_id: str): + """ + Async Generate conversation summary + :param conversation_id: + + Usage: generate_conversation_summary_task.delay(conversation_id) + """ + logging.info(click.style('Start generate conversation summary: {}'.format(conversation_id), fg='green')) + start_at = time.perf_counter() + + conversation = db.session.query(Conversation).filter(Conversation.id == conversation_id).first() + if not conversation: + raise NotFound('Conversation not found') + + try: + # get conversation messages count + history_message_count = conversation.message_count + if history_message_count >= 5: + app_model = conversation.app + if not app_model: + return + + history_messages = db.session.query(Message).filter(Message.conversation_id == conversation.id) \ + .order_by(Message.created_at.asc()).all() + + conversation.summary = LLMGenerator.generate_conversation_summary(app_model.tenant_id, history_messages) + db.session.add(conversation) + db.session.commit() + + end_at = time.perf_counter() + logging.info(click.style('Conversation summary generated: {} latency: {}'.format(conversation_id, end_at - start_at), fg='green')) + except Exception: + logging.exception("generate conversation summary failed") diff --git a/api/tasks/recover_document_indexing_task.py b/api/tasks/recover_document_indexing_task.py new file mode 100644 index 0000000000..c1a5d4336c --- /dev/null +++ b/api/tasks/recover_document_indexing_task.py @@ -0,0 +1,51 @@ +import datetime +import logging +import time + +import click +from celery import shared_task +from werkzeug.exceptions import NotFound + +from core.indexing_runner import IndexingRunner, DocumentIsPausedException +from extensions.ext_database import db +from models.dataset import Document + + +@shared_task +def recover_document_indexing_task(dataset_id: str, document_id: str): + """ + Async recover document + :param dataset_id: + :param document_id: + + Usage: recover_document_indexing_task.delay(dataset_id, document_id) + """ + logging.info(click.style('Recover document: {}'.format(document_id), fg='green')) + start_at = time.perf_counter() + + document = db.session.query(Document).filter( + Document.id == document_id, + Document.dataset_id == dataset_id + ).first() + + if not document: + raise NotFound('Document not found') + + try: + indexing_runner = IndexingRunner() + if document.indexing_status in ["waiting", "parsing", "cleaning"]: + indexing_runner.run(document) + elif document.indexing_status == "splitting": + indexing_runner.run_in_splitting_status(document) + elif document.indexing_status == "indexing": + indexing_runner.run_in_indexing_status(document) + end_at = time.perf_counter() + logging.info(click.style('Processed document: {} latency: {}'.format(document.id, end_at - start_at), fg='green')) + except DocumentIsPausedException: + logging.info(click.style('Document paused, document id: {}'.format(document.id), fg='yellow')) + except Exception as e: + logging.exception("consume document failed") + document.indexing_status = 'error' + document.error = str(e) + document.stopped_at = datetime.datetime.utcnow() + db.session.commit() diff --git a/api/tasks/remove_document_from_index_task.py b/api/tasks/remove_document_from_index_task.py new file mode 100644 index 0000000000..3dc6f9cd77 --- /dev/null +++ b/api/tasks/remove_document_from_index_task.py @@ -0,0 +1,63 @@ +import logging +import time + +import click +from celery import shared_task +from werkzeug.exceptions import NotFound + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DocumentSegment, Document + + +@shared_task +def remove_document_from_index_task(document_id: str): + """ + Async Remove document from index + :param document_id: document id + + Usage: remove_document_from_index.delay(document_id) + """ + logging.info(click.style('Start remove document segments from index: {}'.format(document_id), fg='green')) + start_at = time.perf_counter() + + document = db.session.query(Document).filter(Document.id == document_id).first() + if not document: + raise NotFound('Document not found') + + if document.indexing_status != 'completed': + return + + indexing_cache_key = 'document_{}_indexing'.format(document.id) + + try: + dataset = document.dataset + + if not dataset: + raise Exception('Document has no dataset') + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + # delete from vector index + if dataset.indexing_technique == "high_quality": + vector_index.del_doc(document.id) + + # delete from keyword index + segments = db.session.query(DocumentSegment).filter(DocumentSegment.document_id == document.id).all() + index_node_ids = [segment.index_node_id for segment in segments] + if index_node_ids: + keyword_table_index.del_nodes(index_node_ids) + + end_at = time.perf_counter() + logging.info( + click.style('Document removed from index: {} latency: {}'.format(document.id, end_at - start_at), fg='green')) + except Exception: + logging.exception("remove document from index failed") + if not document.archived: + document.enabled = True + db.session.commit() + finally: + redis_client.delete(indexing_cache_key) diff --git a/api/tasks/remove_segment_from_index_task.py b/api/tasks/remove_segment_from_index_task.py new file mode 100644 index 0000000000..48cebfc4d1 --- /dev/null +++ b/api/tasks/remove_segment_from_index_task.py @@ -0,0 +1,58 @@ +import logging +import time + +import click +from celery import shared_task +from werkzeug.exceptions import NotFound + +from core.index.keyword_table_index import KeywordTableIndex +from core.index.vector_index import VectorIndex +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import DocumentSegment + + +@shared_task +def remove_segment_from_index_task(segment_id: str): + """ + Async Remove segment from index + :param segment_id: + + Usage: remove_segment_from_index.delay(segment_id) + """ + logging.info(click.style('Start remove segment from index: {}'.format(segment_id), fg='green')) + start_at = time.perf_counter() + + segment = db.session.query(DocumentSegment).filter(DocumentSegment.id == segment_id).first() + if not segment: + raise NotFound('Segment not found') + + if segment.status != 'completed': + return + + indexing_cache_key = 'segment_{}_indexing'.format(segment.id) + + try: + dataset = segment.dataset + + if not dataset: + raise Exception('Segment has no dataset') + + vector_index = VectorIndex(dataset=dataset) + keyword_table_index = KeywordTableIndex(dataset=dataset) + + # delete from vector index + if dataset.indexing_technique == "high_quality": + vector_index.del_nodes([segment.index_node_id]) + + # delete from keyword index + keyword_table_index.del_nodes([segment.index_node_id]) + + end_at = time.perf_counter() + logging.info(click.style('Segment removed from index: {} latency: {}'.format(segment.id, end_at - start_at), fg='green')) + except Exception: + logging.exception("remove segment from index failed") + segment.enabled = True + db.session.commit() + finally: + redis_client.delete(indexing_cache_key) diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000000..48de037846 --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,50 @@ +# -*- coding:utf-8 -*- + +import pytest +import flask_migrate + +from app import create_app +from extensions.ext_database import db + + +@pytest.fixture(scope='module') +def test_client(): + # Create a Flask app configured for testing + from config import TestConfig + flask_app = create_app(TestConfig()) + flask_app.config.from_object('config.TestingConfig') + + # Create a test client using the Flask application configured for testing + with flask_app.test_client() as testing_client: + # Establish an application context + with flask_app.app_context(): + yield testing_client # this is where the testing happens! + + +@pytest.fixture(scope='module') +def init_database(test_client): + # Initialize the database + with test_client.application.app_context(): + flask_migrate.upgrade() + + yield # this is where the testing happens! + + # Clean up the database + with test_client.application.app_context(): + flask_migrate.downgrade() + + +@pytest.fixture(scope='module') +def db_session(test_client): + with test_client.application.app_context(): + yield db.session + + +@pytest.fixture(scope='function') +def login_default_user(test_client): + + # todo + + yield # this is where the testing happens! + + test_client.get('/logout', follow_redirects=True) \ No newline at end of file diff --git a/api/tests/test_controllers/__init__.py b/api/tests/test_controllers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/test_controllers/test_account_api.py.bak b/api/tests/test_controllers/test_account_api.py.bak new file mode 100644 index 0000000000..a73c796b78 --- /dev/null +++ b/api/tests/test_controllers/test_account_api.py.bak @@ -0,0 +1,75 @@ +import json +import pytest +from flask import url_for + +from models.model import Account + +# Sample user data for testing +sample_user_data = { + 'name': 'Test User', + 'email': 'test@example.com', + 'interface_language': 'en-US', + 'interface_theme': 'light', + 'timezone': 'America/New_York', + 'password': 'testpassword', + 'new_password': 'newtestpassword', + 'repeat_new_password': 'newtestpassword' +} + +# Create a test user and log them in +@pytest.fixture(scope='function') +def logged_in_user(client, session): + # Create test user and add them to the database + # Replace this with your actual User model and any required fields + + # todo refer to api.controllers.setup.SetupApi.post() to create a user + db_user_data = sample_user_data.copy() + db_user_data['password_salt'] = 'testpasswordsalt' + del db_user_data['new_password'] + del db_user_data['repeat_new_password'] + test_user = Account(**db_user_data) + session.add(test_user) + session.commit() + + # Log in the test user + client.post(url_for('console.loginapi'), data={'email': sample_user_data['email'], 'password': sample_user_data['password']}) + + return test_user + +def test_account_profile(logged_in_user, client): + response = client.get(url_for('console.accountprofileapi')) + assert response.status_code == 200 + assert json.loads(response.data)['name'] == sample_user_data['name'] + +def test_account_name(logged_in_user, client): + new_name = 'New Test User' + response = client.post(url_for('console.accountnameapi'), json={'name': new_name}) + assert response.status_code == 200 + assert json.loads(response.data)['name'] == new_name + +def test_account_interface_language(logged_in_user, client): + new_language = 'zh-CN' + response = client.post(url_for('console.accountinterfacelanguageapi'), json={'interface_language': new_language}) + assert response.status_code == 200 + assert json.loads(response.data)['interface_language'] == new_language + +def test_account_interface_theme(logged_in_user, client): + new_theme = 'dark' + response = client.post(url_for('console.accountinterfacethemeapi'), json={'interface_theme': new_theme}) + assert response.status_code == 200 + assert json.loads(response.data)['interface_theme'] == new_theme + +def test_account_timezone(logged_in_user, client): + new_timezone = 'Asia/Shanghai' + response = client.post(url_for('console.accounttimezoneapi'), json={'timezone': new_timezone}) + assert response.status_code == 200 + assert json.loads(response.data)['timezone'] == new_timezone + +def test_account_password(logged_in_user, client): + response = client.post(url_for('console.accountpasswordapi'), json={ + 'password': sample_user_data['password'], + 'new_password': sample_user_data['new_password'], + 'repeat_new_password': sample_user_data['repeat_new_password'] + }) + assert response.status_code == 200 + assert json.loads(response.data)['result'] == 'success' diff --git a/api/tests/test_controllers/test_login.py b/api/tests/test_controllers/test_login.py new file mode 100644 index 0000000000..559e2f809e --- /dev/null +++ b/api/tests/test_controllers/test_login.py @@ -0,0 +1,108 @@ +import pytest +from app import create_app, db +from flask_login import current_user +from models.model import Account, TenantAccountJoin, Tenant + + +@pytest.fixture +def client(test_client, db_session): + app = create_app() + app.config["TESTING"] = True + with app.app_context(): + db.create_all() + yield test_client + db.drop_all() + + +def test_login_api_post(client, db_session): + # create a tenant, account, and tenant account join + tenant = Tenant(name="Test Tenant", status="normal") + account = Account(email="test@test.com", name="Test User") + account.password_salt = "uQ7K0/0wUJ7VPhf3qBzwNQ==" + account.password = "A9YpfzjK7c/tOwzamrvpJg==" + db.session.add_all([tenant, account]) + db.session.flush() + tenant_account_join = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, is_tenant_owner=True) + db.session.add(tenant_account_join) + db.session.commit() + + # login with correct credentials + response = client.post("/login", json={ + "email": "test@test.com", + "password": "Abc123456", + "remember_me": True + }) + assert response.status_code == 200 + assert response.json == {"result": "success"} + assert current_user == account + assert 'tenant_id' in client.session + assert client.session['tenant_id'] == tenant.id + + # login with incorrect password + response = client.post("/login", json={ + "email": "test@test.com", + "password": "wrong_password", + "remember_me": True + }) + assert response.status_code == 401 + + # login with non-existent account + response = client.post("/login", json={ + "email": "non_existent_account@test.com", + "password": "Abc123456", + "remember_me": True + }) + assert response.status_code == 401 + + +def test_logout_api_get(client, db_session): + # create a tenant, account, and tenant account join + tenant = Tenant(name="Test Tenant", status="normal") + account = Account(email="test@test.com", name="Test User") + db.session.add_all([tenant, account]) + db.session.flush() + tenant_account_join = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, is_tenant_owner=True) + db.session.add(tenant_account_join) + db.session.commit() + + # login and check if session variable and current_user are set + with client.session_transaction() as session: + session['tenant_id'] = tenant.id + client.post("/login", json={ + "email": "test@test.com", + "password": "Abc123456", + "remember_me": True + }) + assert current_user == account + assert 'tenant_id' in client.session + assert client.session['tenant_id'] == tenant.id + + # logout and check if session variable and current_user are unset + response = client.get("/logout") + assert response.status_code == 200 + assert current_user.is_authenticated is False + assert 'tenant_id' not in client.session + + +def test_reset_password_api_get(client, db_session): + # create a tenant, account, and tenant account join + tenant = Tenant(name="Test Tenant", status="normal") + account = Account(email="test@test.com", name="Test User") + db.session.add_all([tenant, account]) + db.session.flush() + tenant_account_join = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, is_tenant_owner=True) + db.session.add(tenant_account_join) + db.session.commit() + + # reset password in cloud edition + app = client.application + app.config["CLOUD_EDITION"] = True + response = client.get("/reset_password") + assert response.status_code == 200 + assert response.json == {"result": "success"} + + # reset password in non-cloud edition + app.config["CLOUD_EDITION"] = False + response = client.get("/reset_password") + assert response.status_code == 200 + assert response.json == {"result": "success"} diff --git a/api/tests/test_controllers/test_setup.py b/api/tests/test_controllers/test_setup.py new file mode 100644 index 0000000000..96a9b0911e --- /dev/null +++ b/api/tests/test_controllers/test_setup.py @@ -0,0 +1,80 @@ +import os +import pytest +from models.model import Account, Tenant, TenantAccountJoin + + +def test_setup_api_get(test_client,db_session): + response = test_client.get("/setup") + assert response.status_code == 200 + assert response.json == {"step": "not_start"} + + # create a tenant and check again + tenant = Tenant(name="Test Tenant", status="normal") + db_session.add(tenant) + db_session.commit() + response = test_client.get("/setup") + assert response.status_code == 200 + assert response.json == {"step": "step2"} + + # create setup file and check again + response = test_client.get("/setup") + assert response.status_code == 200 + assert response.json == {"step": "finished"} + + +def test_setup_api_post(test_client): + response = test_client.post("/setup", json={ + "email": "test@test.com", + "name": "Test User", + "password": "Abc123456" + }) + assert response.status_code == 200 + assert response.json == {"result": "success", "next_step": "step2"} + + # check if the tenant, account, and tenant account join records were created + tenant = Tenant.query.first() + assert tenant.name == "Test User's LLM Factory" + assert tenant.status == "normal" + assert tenant.encrypt_public_key + + account = Account.query.first() + assert account.email == "test@test.com" + assert account.name == "Test User" + assert account.password_salt + assert account.password + assert TenantAccountJoin.query.filter_by(account_id=account.id, is_tenant_owner=True).count() == 1 + + # check if password is encrypted correctly + salt = account.password_salt.encode() + password_hashed = account.password.encode() + assert account.password == base64.b64encode(hash_password("Abc123456", salt)).decode() + + +def test_setup_step2_api_post(test_client,db_session): + # create a tenant, account, and setup file + tenant = Tenant(name="Test Tenant", status="normal") + account = Account(email="test@test.com", name="Test User") + db_session.add_all([tenant, account]) + db_session.commit() + + # try to set up with incorrect language + response = test_client.post("/setup/step2", json={ + "interface_language": "invalid_language", + "timezone": "Asia/Shanghai" + }) + assert response.status_code == 400 + + # set up successfully + response = test_client.post("/setup/step2", json={ + "interface_language": "en", + "timezone": "Asia/Shanghai" + }) + assert response.status_code == 200 + assert response.json == {"result": "success", "next_step": "finished"} + + # check if account was updated correctly + account = Account.query.first() + assert account.interface_language == "en" + assert account.timezone == "Asia/Shanghai" + assert account.interface_theme == "light" + assert account.last_login_ip == "127.0.0.1" diff --git a/api/tests/test_factory.py b/api/tests/test_factory.py new file mode 100644 index 0000000000..0d73168b43 --- /dev/null +++ b/api/tests/test_factory.py @@ -0,0 +1,22 @@ +# -*- coding:utf-8 -*- + +import pytest + +from app import create_app + +def test_create_app(): + + # Test Default(CE) Config + app = create_app() + + assert app.config['SECRET_KEY'] is not None + assert app.config['SQLALCHEMY_DATABASE_URI'] is not None + assert app.config['EDITION'] == "SELF_HOSTED" + + # Test TestConfig + from config import TestConfig + test_app = create_app(TestConfig()) + + assert test_app.config['SECRET_KEY'] is not None + assert test_app.config['SQLALCHEMY_DATABASE_URI'] is not None + assert test_app.config['TESTING'] is True \ No newline at end of file diff --git a/api/tests/test_helpers/__init__.py b/api/tests/test_helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/test_libs/__init__.py b/api/tests/test_libs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/test_models/__init__.py b/api/tests/test_models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/test_services/__init__.py b/api/tests/test_services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000000..ec0e930f17 --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,256 @@ +FROM nginx:1.22 + +# ensure local python is preferred over distribution python +ENV PATH /usr/local/bin:$PATH + +# http://bugs.python.org/issue19846 +# > At the moment, setting "LANG=C" on a Linux system *fundamentally breaks Python 3*, and that's not OK. +ENV LANG C.UTF-8 + +# runtime dependencies +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + netbase \ + tzdata \ + ; \ + rm -rf /var/lib/apt/lists/* + +ENV GPG_KEY A035C8C19219BA821ECEA86B64E628F8D684696D +ENV PYTHON_VERSION 3.10.10 + +RUN set -eux; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + dpkg-dev \ + gcc \ + gnupg dirmngr \ + libbluetooth-dev \ + libbz2-dev \ + libc6-dev \ + libdb-dev \ + libexpat1-dev \ + libffi-dev \ + libgdbm-dev \ + liblzma-dev \ + libncursesw5-dev \ + libreadline-dev \ + libsqlite3-dev \ + libssl-dev \ + make \ + tk-dev \ + uuid-dev \ + wget \ + xz-utils \ + zlib1g-dev \ + ; \ + \ + wget -O python.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"; \ + wget -O python.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"; \ + GNUPGHOME="$(mktemp -d)"; export GNUPGHOME; \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$GPG_KEY"; \ + gpg --batch --verify python.tar.xz.asc python.tar.xz; \ + command -v gpgconf > /dev/null && gpgconf --kill all || :; \ + rm -rf "$GNUPGHOME" python.tar.xz.asc; \ + mkdir -p /usr/src/python; \ + tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz; \ + rm python.tar.xz; \ + \ + cd /usr/src/python; \ + gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ + ./configure \ + --build="$gnuArch" \ + --enable-loadable-sqlite-extensions \ + --enable-optimizations \ + --enable-option-checking=fatal \ + --enable-shared \ + --with-lto \ + --with-system-expat \ + --without-ensurepip \ + ; \ + nproc="$(nproc)"; \ + EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)"; \ + LDFLAGS="$(dpkg-buildflags --get LDFLAGS)"; \ + LDFLAGS="${LDFLAGS:--Wl},--strip-all"; \ + make -j "$nproc" \ + "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ + "LDFLAGS=${LDFLAGS:-}" \ + "PROFILE_TASK=${PROFILE_TASK:-}" \ + ; \ +# https://github.com/docker-library/python/issues/784 +# prevent accidental usage of a system installed libpython of the same version + rm python; \ + make -j "$nproc" \ + "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \ + "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" \ + "PROFILE_TASK=${PROFILE_TASK:-}" \ + python \ + ; \ + make install; \ + \ + cd /; \ + rm -rf /usr/src/python; \ + \ + find /usr/local -depth \ + \( \ + \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \ + -o \( -type f -a \( -name '*.pyc' -o -name '*.pyo' -o -name 'libpython*.a' \) \) \ + \) -exec rm -rf '{}' + \ + ; \ + \ + ldconfig; \ + \ + apt-mark auto '.*' > /dev/null; \ + apt-mark manual $savedAptMark; \ + find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + ; \ + rm -rf /var/lib/apt/lists/*; \ + \ + python3 --version + +# make some useful symlinks that are expected to exist ("/usr/local/bin/python" and friends) +RUN set -eux; \ + for src in idle3 pydoc3 python3 python3-config; do \ + dst="$(echo "$src" | tr -d 3)"; \ + [ -s "/usr/local/bin/$src" ]; \ + [ ! -e "/usr/local/bin/$dst" ]; \ + ln -svT "$src" "/usr/local/bin/$dst"; \ + done + +# if this is called "PIP_VERSION", pip explodes with "ValueError: invalid truth value ''" +ENV PYTHON_PIP_VERSION 22.3.1 +# https://github.com/docker-library/python/issues/365 +ENV PYTHON_SETUPTOOLS_VERSION 65.5.1 +# https://github.com/pypa/get-pip +ENV PYTHON_GET_PIP_URL https://github.com/pypa/get-pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.py +ENV PYTHON_GET_PIP_SHA256 394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637 + +RUN set -eux; \ + \ + savedAptMark="$(apt-mark showmanual)"; \ + apt-get update; \ + apt-get install -y --no-install-recommends wget; \ + \ + wget -O get-pip.py "$PYTHON_GET_PIP_URL"; \ + echo "$PYTHON_GET_PIP_SHA256 *get-pip.py" | sha256sum -c -; \ + \ + apt-mark auto '.*' > /dev/null; \ + [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; \ + rm -rf /var/lib/apt/lists/*; \ + \ + export PYTHONDONTWRITEBYTECODE=1; \ + \ + python get-pip.py \ + --disable-pip-version-check \ + --no-cache-dir \ + --no-compile \ + "pip==$PYTHON_PIP_VERSION" \ + "setuptools==$PYTHON_SETUPTOOLS_VERSION" \ + ; \ + rm -f get-pip.py; \ + \ + pip --version + +RUN groupadd --gid 1000 node \ + && useradd --uid 1000 --gid node --shell /bin/bash --create-home node + +ENV NODE_VERSION 18.15.0 + +RUN ARCH= && dpkgArch="$(dpkg --print-architecture)" \ + && case "${dpkgArch##*-}" in \ + amd64) ARCH='x64';; \ + ppc64el) ARCH='ppc64le';; \ + s390x) ARCH='s390x';; \ + arm64) ARCH='arm64';; \ + armhf) ARCH='armv7l';; \ + i386) ARCH='x86';; \ + *) echo "unsupported architecture"; exit 1 ;; \ + esac \ + && set -ex \ + # libatomic1 for arm + && apt-get update && apt-get install -y ca-certificates curl wget gnupg dirmngr xz-utils libatomic1 --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && for key in \ + 4ED778F539E3634C779C87C6D7062848A1AB005C \ + 141F07595B7B3FFE74309A937405533BE57C7D57 \ + 74F12602B6F1C4E913FAA37AD3A89613643B6201 \ + DD792F5973C6DE52C432CBDAC77ABFA00DDBF2B7 \ + 61FC681DFB92A079F1685E77973F295594EC4689 \ + 8FCCA13FEF1D0C2E91008E09770F7A9A5AE15600 \ + C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \ + 890C08DB8579162FEE0DF9DB8BEAB4DFCF555EF4 \ + C82FA3AE1CBEDC6BE46B9360C43CEC45C17AB93C \ + 108F52B48DB57BB0CC439B2997B01419BD92F80A \ + ; do \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \ + done \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$ARCH.tar.xz" \ + && curl -fsSLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \ + && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \ + && grep " node-v$NODE_VERSION-linux-$ARCH.tar.xz\$" SHASUMS256.txt | sha256sum -c - \ + && tar -xJf "node-v$NODE_VERSION-linux-$ARCH.tar.xz" -C /usr/local --strip-components=1 --no-same-owner \ + && rm "node-v$NODE_VERSION-linux-$ARCH.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \ + && apt-mark auto '.*' > /dev/null \ + && find /usr/local -type f -executable -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + && ln -s /usr/local/bin/node /usr/local/bin/nodejs \ + # smoke tests + && node --version \ + && npm --version + +ENV YARN_VERSION 1.22.19 + +RUN set -ex \ + && savedAptMark="$(apt-mark showmanual)" \ + && apt-get update && apt-get install -y ca-certificates curl wget gnupg dirmngr --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* \ + && for key in \ + 6A010C5166006599AA17F08146C2130DFD2497F5 \ + ; do \ + gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$key" || \ + gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" ; \ + done \ + && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \ + && curl -fsSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc" \ + && gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \ + && mkdir -p /opt \ + && tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/ \ + && ln -s /opt/yarn-v$YARN_VERSION/bin/yarn /usr/local/bin/yarn \ + && ln -s /opt/yarn-v$YARN_VERSION/bin/yarnpkg /usr/local/bin/yarnpkg \ + && rm yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \ + && apt-mark auto '.*' > /dev/null \ + && { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark > /dev/null; } \ + && find /usr/local -type f -executable -exec ldd '{}' ';' \ + | awk '/=>/ { print $(NF-1) }' \ + | sort -u \ + | xargs -r dpkg-query --search \ + | cut -d: -f1 \ + | sort -u \ + | xargs -r apt-mark manual \ + # smoke test + && yarn --version + + + +RUN apt-get update && \ + apt-get install -y bash curl wget vim gcc g++ python3-dev libc-dev libffi-dev + +RUN pip3 install gunicorn +RUN npm install pm2 -g + +ENTRYPOINT ["/usr/local/bin/pm2-runtime", "start"] \ No newline at end of file diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml new file mode 100644 index 0000000000..09372ca2a3 --- /dev/null +++ b/docker/docker-compose.middleware.yaml @@ -0,0 +1,53 @@ +version: '3.1' +services: + # The postgres database. + db: + image: postgres:15-alpine + restart: always + environment: + # The password for the default postgres user. + POSTGRES_PASSWORD: difyai123456 + # The name of the default postgres database. + POSTGRES_DB: dify + # postgres data directory + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - ./volumes/db/data:/var/lib/postgresql/data + - ./volumes/db/scripts:/docker-entrypoint-initdb.d/ + ports: + - "5432:5432" + + # The redis cache. + redis: + image: redis:6-alpine + restart: always + volumes: + # Mount the redis data directory to the container. + - ./volumes/redis/data:/data + # Set the redis password when startup redis server. + command: redis-server --requirepass difyai123456 + ports: + - "6379:6379" + + # The Weaviate vector store. + weaviate: + image: semitechnologies/weaviate:1.18.4 + restart: always + volumes: + # Mount the Weaviate data directory to the container. + - ./volumes/weaviate:/var/lib/weaviate + environment: + # The Weaviate configurations + # You can refer to the [Weaviate](https://weaviate.io/developers/weaviate/config-refs/env-vars) documentation for more information. + QUERY_DEFAULTS_LIMIT: 25 + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false' + PERSISTENCE_DATA_PATH: '/var/lib/weaviate' + DEFAULT_VECTORIZER_MODULE: 'none' + CLUSTER_HOSTNAME: 'node1' + AUTHENTICATION_APIKEY_ENABLED: 'true' + AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih' + AUTHENTICATION_APIKEY_USERS: 'hello@dify.ai' + AUTHORIZATION_ADMINLIST_ENABLED: 'true' + AUTHORIZATION_ADMINLIST_USERS: 'hello@dify.ai' + ports: + - "8080:8080" \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000000..7d1542fbca --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,213 @@ +version: '3.1' +services: + # API service + api: + image: langgenius/dify-api:latest + restart: always + environment: + # Startup mode, 'api' starts the API server. + MODE: api + # The log level for the application. Supported values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` + LOG_LEVEL: INFO + # A secret key that is used for securely signing the session cookie and encrypting sensitive information on the database. You can generate a strong key using `openssl rand -base64 42`. + SECRET_KEY: sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U + # The base URL of console application, refers to the Console base URL of WEB service. + CONSOLE_URL: http://localhost + # The URL for Service API endpoints,refers to the base URL of the current API service. + API_URL: http://localhost + # The URL for Web APP, refers to the Web App base URL of WEB service. + APP_URL: http://localhost + # When enabled, migrations will be executed prior to application startup and the application will start after the migrations have completed. + MIGRATION_ENABLED: 'true' + # The configurations of postgres database connection. + # It is consistent with the configuration in the 'db' service below. + DB_USERNAME: postgres + DB_PASSWORD: difyai123456 + DB_HOST: db + DB_PORT: 5432 + DB_DATABASE: dify + # The configurations of redis connection. + # It is consistent with the configuration in the 'redis' service below. + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: difyai123456 + # use redis db 0 for redis cache + REDIS_DB: 0 + # The configurations of session, Supported values are `sqlalchemy`. `redis` + SESSION_TYPE: redis + SESSION_REDIS_HOST: redis + SESSION_REDIS_PORT: 6379 + SESSION_REDIS_PASSWORD: difyai123456 + # use redis db 2 for session store + SESSION_REDIS_DB: 2 + # The configurations of celery broker. + # Use redis as the broker, and redis db 1 for celery broker. + CELERY_BROKER_URL: redis://:difyai123456@redis:6379/1 + # Specifies the allowed origins for cross-origin requests to the Web API + WEB_API_CORS_ALLOW_ORIGINS: http://localhost,* + # Specifies the allowed origins for cross-origin requests to the console API + CONSOLE_CORS_ALLOW_ORIGINS: http://localhost,* + # CSRF Cookie settings + # Controls whether a cookie is sent with cross-site requests, + # providing some protection against cross-site request forgery attacks + COOKIE_HTTPONLY: 'true' + COOKIE_SAMESITE: 'None' + COOKIE_SECURE: 'true' + # The type of storage to use for storing user files. Supported values are `local` and `s3`, Default: `local` + STORAGE_TYPE: local + # The path to the local storage directory, the directory relative the root path of API service codes or absolute path. Default: `storage` or `/home/john/storage`. + # only available when STORAGE_TYPE is `local`. + STORAGE_LOCAL_PATH: storage + # The S3 storage configurations, only available when STORAGE_TYPE is `s3`. + S3_ENDPOINT: 'https://xxx.r2.cloudflarestorage.com' + S3_BUCKET_NAME: 'difyai' + S3_ACCESS_KEY: 'ak-difyai' + S3_SECRET_KEY: 'sk-difyai' + S3_REGION: 'us-east-1' + # The type of vector store to use. Supported values are `weaviate`, `qdrant`. + VECTOR_STORE: weaviate + # The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`. + WEAVIATE_ENDPOINT: http://weaviate:8080 + # The Weaviate API key. + WEAVIATE_API_KEY: WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih + # The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`. + QDRANT_URL: 'https://your-qdrant-cluster-url.qdrant.tech/' + # The Qdrant API key. + QDRANT_API_KEY: 'ak-difyai' + # The DSN for Sentry error reporting. If not set, Sentry error reporting will be disabled. + SENTRY_DSN: '' + # The sample rate for Sentry events. Default: `1.0` + SENTRY_TRACES_SAMPLE_RATE: 1.0 + # The sample rate for Sentry profiles. Default: `1.0` + SENTRY_PROFILES_SAMPLE_RATE: 1.0 + depends_on: + - db + - redis + - weaviate + volumes: + # Mount the storage directory to the container, for storing user files. + - ./volumes/app/storage:/app/storage + + # worker service + # The Celery worker for processing the queue. + worker: + image: langgenius/dify-api:latest + restart: always + environment: + # Startup mode, 'worker' starts the Celery worker for processing the queue. + MODE: worker + + # --- All the configurations below are the same as those in the 'api' service. --- + + # The log level for the application. Supported values are `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` + LOG_LEVEL: INFO + # A secret key that is used for securely signing the session cookie and encrypting sensitive information on the database. You can generate a strong key using `openssl rand -base64 42`. + # same as the API service + SECRET_KEY: sk-9f73s3ljTXVcMT3Blb3ljTqtsKiGHXVcMT3BlbkFJLK7U + # The base URL of console application, refers to the Console base URL of WEB service. + CONSOLE_URL: http://localhost + # The URL for Service API endpoints,refers to the base URL of the current API service. + API_URL: http://localhost + # The URL for Web APP, refers to the Web App base URL of WEB service. + APP_URL: http://localhost + # The configurations of postgres database connection. + # It is consistent with the configuration in the 'db' service below. + DB_USERNAME: postgres + DB_PASSWORD: difyai123456 + DB_HOST: db + DB_PORT: 5432 + DB_DATABASE: dify + # The configurations of redis cache connection. + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: difyai123456 + REDIS_DB: 0 + # The configurations of celery broker. + CELERY_BROKER_URL: redis://:difyai123456@redis:6379/1 + # The type of storage to use for storing user files. Supported values are `local` and `s3`, Default: `local` + STORAGE_TYPE: local + STORAGE_LOCAL_PATH: storage + # The Vector store configurations. + VECTOR_STORE: weaviate + WEAVIATE_ENDPOINT: http://weaviate:8080 + WEAVIATE_API_KEY: WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih + depends_on: + - db + - redis + - weaviate + volumes: + # Mount the storage directory to the container, for storing user files. + - ./volumes/app/storage:/app/storage + + # Frontend web application. + web: + image: langgenius/dify-web:latest + restart: always + environment: + EDITION: SELF_HOSTED + # The base URL of console application, refers to the Console base URL of WEB service. + CONSOLE_URL: http://localhost + # The URL for Web APP, refers to the Web App base URL of WEB service. + APP_URL: http://localhost + + # The postgres database. + db: + image: postgres:15-alpine + restart: always + environment: + # The password for the default postgres user. + POSTGRES_PASSWORD: difyai123456 + # The name of the default postgres database. + POSTGRES_DB: dify + # postgres data directory + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - ./volumes/db/data:/var/lib/postgresql/data + - ./volumes/db/scripts:/docker-entrypoint-initdb.d/ + ports: + - "5432:5432" + + # The redis cache. + redis: + image: redis:6-alpine + restart: always + volumes: + # Mount the redis data directory to the container. + - ./volumes/redis/data:/data + # Set the redis password when startup redis server. + command: redis-server --requirepass difyai123456 + + # The Weaviate vector store. + weaviate: + image: semitechnologies/weaviate:1.18.4 + restart: always + volumes: + # Mount the Weaviate data directory to the container. + - ./volumes/weaviate:/var/lib/weaviate + environment: + # The Weaviate configurations + # You can refer to the [Weaviate](https://weaviate.io/developers/weaviate/config-refs/env-vars) documentation for more information. + QUERY_DEFAULTS_LIMIT: 25 + AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false' + PERSISTENCE_DATA_PATH: '/var/lib/weaviate' + DEFAULT_VECTORIZER_MODULE: 'none' + CLUSTER_HOSTNAME: 'node1' + AUTHENTICATION_APIKEY_ENABLED: 'true' + AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih' + AUTHENTICATION_APIKEY_USERS: 'hello@dify.ai' + AUTHORIZATION_ADMINLIST_ENABLED: 'true' + AUTHORIZATION_ADMINLIST_USERS: 'hello@dify.ai' + + # The nginx reverse proxy. + # used for reverse proxying the API service and Web service. + nginx: + image: nginx:latest + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/proxy.conf:/etc/nginx/proxy.conf + - ./nginx/conf.d:/etc/nginx/conf.d + depends_on: + - api + - web + ports: + - "80:80" \ No newline at end of file diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf new file mode 100644 index 0000000000..3b153f40b0 --- /dev/null +++ b/docker/nginx/conf.d/default.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name localhost; + + location /console/api { + proxy_pass http://api:5001; + include proxy.conf; + } + + location /api { + proxy_pass http://api:5001; + include proxy.conf; + } + + location /v1 { + proxy_pass http://api:5001; + include proxy.conf; + } + + location / { + proxy_pass http://web:3000; + include proxy.conf; + } +} \ No newline at end of file diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000000..d2b52963e8 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,32 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; + client_max_body_size 15M; + + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/docker/nginx/proxy.conf b/docker/nginx/proxy.conf new file mode 100644 index 0000000000..254f625961 --- /dev/null +++ b/docker/nginx/proxy.conf @@ -0,0 +1,8 @@ +proxy_set_header Host $host; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_http_version 1.1; +proxy_set_header Connection ""; +proxy_buffering off; +proxy_read_timeout 3600s; +proxy_send_timeout 3600s; \ No newline at end of file diff --git a/docker/volumes/db/scripts/init_extension.sh b/docker/volumes/db/scripts/init_extension.sh new file mode 100644 index 0000000000..abad1e5182 --- /dev/null +++ b/docker/volumes/db/scripts/init_extension.sh @@ -0,0 +1 @@ +psql -U postgres -d dify -c 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp";' \ No newline at end of file diff --git a/images/describe-cn.jpg b/images/describe-cn.jpg new file mode 100644 index 0000000000000000000000000000000000000000..10bdc286579020d725a33c0ace99cee9f1d100e7 GIT binary patch literal 1230401 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Ν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

G?GpjdCAxP`Zr1 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

R+<7+5!3 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

d@@V;wQsCeH{U2$u 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 z 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

x7cXNgBJ-T~pm*qN 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

  • F858O3{M%87p{0Pe`(zf>U{<*mR33KCuQ*|{i`J7ug#kNNtD)*u`j z_J1<8=f#~prZ?vwWhKBs$_c`K_@c#q3ZLo(=4E#0dzG`o$PT$9q^D}nfKNoU?jUcLtCoXnJWyNl4$nKq1_JH4@X}2VqD0gxuqOyv&g)WeZF)xo}Nkpk{z=vyJ z#G>d7#`9v;sIKj^dF)W-iy+D-4ydshOO9}`r`;t)Rlil$ydJ{mY+;3k$s-} z4J8bbuxS_;WV|rAUbUQeUbLx|^rbrK_^Q*cUgUI)Wt zf*TJ(GZ6j>rIKRXKZ*ygtV9!hulxHSI$XiQywD-I*q0-^4ZJs=jPJ^YUG&;G&TB@6 zLhl^aj0FXsI!y$`f^39Li7Sf|y%miG`S>LT@$+Aztt4OBkB0PwpfRPA$Tz`B_*K`F z;p5k`8)#OWkC!oF#l8e$!DgC}`8R|c`K`}NgjFaQ*uEo=8%a-2ju16rW(FNeEkb?F zLH5X?!H8d+AyiP8!SsIJM6|EnY8zy^Pv>mXok31ubUkIEZ<Ia617<16xhP4WpeP zDf-pmVV;QAk+{CmdaNQ5Q_0Tai2Jq!v5vc!6elT0{EuYi_gaANx2-J?-9Qwi zS?JTl!ec1bGU*3}f>_HfzIuLeM&g^pBMJDr2ncQYx=b9OGAE|d9ge;hy#U?_sU`_V z=7saH(7&!-c#LnLg)t){ck5`O?@pzXC0uQY8oPn?8pwpXeRFkbLoAG7V{oi#0=Kf^ z0xo@49FRPqOKdZ0>2dW3U+`cZCpHP&v|mzYKwc&Qft*bAtX^dJ^crKQY*Jhlh??+uautd^fH3IO$117@NT`XEty{;OFw;$MVb zBnL0~$5!%laFnnSKTfQC@A;M=1RgUdb_nt0BH>U^v|E~Q+9$;!tC=1|KDZm^-AIi= zurQvtDkElwD{D^3QiJ}xG;L%^;xA7JM`=&3igbs2%Du?A=E0=d(t&L9OiLd+^j^^n zGu914=5xtFrv?{=$D!iymqkD;<88|*jqRVnAgXA$7m!5zz1>#HIAru3p;Y;eTSmWC zheCW~P&7Mdgo~nKbp{`uaN9?$E31ox?inTC6$WSi~bWn1o>JlOkVxE_>qgDp209E{=mW(3cAaW|eb zhL}QuA;?p}$Q>DMl95~?{AXs+?4SMoLZJD>e)%4dl5+GEow)IuTS3#Oxf7I|WYF;} zwv{GUo*q}NJ3^TAgA7Rxf`jyJ(RN}mcHU^-Cj;-fL9`_XqhmaUOdzI=>xfxx!n3f! z{JJvUYD;)_kgcG#1q1Kh@xj%uH?v4=I)Uz*TSULi6b>6Tt&=KE5?X-pT9a|R%Virw zkN)0z6dNkgh`cGcuKPP9-()~?i{O<$mr9fn%^%`0cS+O3U>`bZtvbte=l8Rhi%6X_DAC!0Pj5w{8h+3V#tEVT42oh3^!cCoBZ?U^( zG#xIkgW+34b}(UM65oeOGrgV#El3r15ipS0#F-ww=60)Yz7<$O6a5eaI^}!!vW3R7NlAMG5<7j9Qhrt7h zP+VqV+RP|)jI1dpGCX#T3ZNKG4$>@gFLLf17F~Uhfal;^6L<`=sXIY%L$Fy$;?u9j z-7}hHJ=dXUn9BjPrRkkL%wJPtPtDsUC}7y1p`>Yv zi463Qlwho~+KiK#W&RM~K=9WT8>}8FZ)ag7)Wt1FSjqBN?j4;DCiWB|Fel&H!F}^I zmXH>Dn2WGY*i#u%R)h&ld1UvBf7RY(h>xL3z%U~E4LGmGNI-+Z!KDH^)g(f*gYM~! z{D+_*7&p=r?=V?`f;b=$VdN)5BAX`s-+=J1_TOz{;HO;~%_gX+prxOg(YV=o8O$%D zLSC#AZOW4bfj>-4w93SZkr|>(q`cZQyrFJ{9EokHb9yk4sRYF?Op+l4Bv0&Yi1NFQ z9ALTt998AJ({qOlkHVV%8~nvbm5D%Nwv%$XOk9}{Kb8-38gZGxj-Lf3+!snrEq+k8 zK-*9d2?JTb$I(D+8u~CuiBzG_K>qLzgE=oqmXOq7#z+>K2=Xza=jn@1gf$1{!s6ca zZ4H$jEjPhuq5M$F0s4s8BXy`h%^;or# zC>#=!hJyvqG46}YWL`NUfwSE#vw<6dzNtE&5zcBTG-?w-HGSb3r1hbjHKR#5Gk$Z; zEeSeY+xlpW*%hBoC8!;3#|I;&2fBbSJmv(u5MTy$R^RuRmssAtrVvw?wSYbFN7op} zZTcLOLZtxZUhS+@-Ghg09Nk7#ylDFq5Om(m8i0a|uk`HC%(NT1>$C=D$55YV1DOr< zalZ_)`QB~4Yec4VxecLU9rTAE+lE)oo;s|7f)&V6<+!gvXtocgDjsd`C?|blNF>dT zp_%S6Lg1^O%oxSJhJl%oXOxKNCNP6VFt#txaQx_&TXar zW?uNKTxdnMQfK5h^TKFLU}Y6N-&9YE&pgxq6s! z_&L;z#ki8QPFx5i2WewDsZ0$EQ#DUf)#*-8mUS6XP_vRtdfn*1gFwxoo<4`qrc>#u zZ?xmH<|OAfy~p6@(G{SGEkh`fZKD~nMZf39dLk|FC%h4}aotQvjOY$E({Q^4GPC?L z_l3%We*EH)+_qkWh5Y^BwNoec9?@W6z%VYruUAmTceAN%GfJ#A!PKAl*bbTz;@h*X z8sl@o3{dh(Ht^T0Fi0JVYSO2LCia~IBlPGm7M4L)#VPvbtqjUxm|I!sV}|@_!)WL_ zH1d1&40J6(0T-q(0wR++EEl##Pv^~}J9K!*2(46&2&kF6vw?1!mN&!(0um$YDM5ph zC&UbMz*NaklKIF%_Kazap3{Om0q(*OI zc`#Im(vVX#DZ^RgEBSeDunM-pUnGXXB7UYh@-!#dK5|;&4y{h#hwuEC|?9Ui6*(w*q}c1_5y=2TxDOVdHy1Ju-mZ9f|@J za)_|>mp&2TVDu1SyaZ>wkJaNP!ZeIX^Q%tW zHiXo{g7_ZdJ2m~T1YI(Zxa`YBdEtmF1Btsvc!H5Xe-H4X69OwT+{hgs)yA0?tl#j0 z`*^H7A7N3IwUAVl94*s8$-(=CqgH}GG&u5eBvS}6E>o6vsnqeLVO2_Za3;Tx@*8EL zdI>+oYI)*4dCOGqqVL-AUWA_|<7Zl@!jtSDjTj@*T;~Ev=7!bUMr&YWW3s5cmSu

    s*=l%X5JXV*K!HvM{{OW{445tw1Q$34a?5U`}2=bTuQ}07B@HAwW1H zjPQaAV>+Q71t~0AUkA;doZy($HltuC3>t#e&m!waH$|+QU=b6IX^s(BSAw0P5M2HO z*AXLkiK4KDH1E$c;bu0oPvw?+-q+Jegs>Q|bcm)A|DKBxA&oMlNcH*ZpddyfmNG2% z5s2hhMfZ1*T{2UZkv0l6A1DrDL9!78>irJ4uJ!me>i@|rWiIVkvCrAPW^{!nh@^0s z_sHN5FYcDob|fD?MhFt#jlD(VuN3{{|b$s zIQ(o@l@Pcb5YZhq7^-!?k6TFTp*HF~7+Lcsw2?kUpc}JgcEy*~TYGV1!kO1S4oi&Kd#F_|Q67zdVc#YZ;JqbuX!Ez-87=JBjf-n2wotj#(RA5Mue4;xQ>3ThYQ@pRs&hV5MazJT$pLAEddnX zX3;!r!#_TVLqI7Jnam*VQA?r&0xif#-9em)8G6MOft*Pu`*%HHcM5QqJ_`4WLWHN> z7@6y5h{y`pPSQ81nO-x%q6K86IXnidWEEYr6 zCaOr%)dfZ3kBtl-{#erSEg{IEADcOgm6&=;vf0GUh+No9XF0+!UEGno0^CQF&pIs@ zdC7R~Q{b}mN>+x0kB0LN+;m#9jzJHizxH?TVwS=b%Y4PFo5t8kvxrtTWD%R_$UK+U zxz6$PYMPJmFB>!HJ{JSZV9uC8!YZG&D$rE%>&c@$MNM`@nhQazla>|Kro3cc(}+n6 z;!0XD8V1sZFr|Am8IfmHi)`}F0J;T8kwg=zykKH^h?$1x@!hK-@dE5_B(u)`?C>1i z$GX8r(umwMQ~o3T);X2`1-iH!;Y+F!S|JeNLr%Ac7g3X>hiOan#3108_Z|eu4;A$=a{P((D|*|E+ww~>#s6C=TNHuP^M2{*+Bgs#W< zwM`WOdZ<`zLfmuWa7neIe$mjKrQjJR;~34>QSt%8v|yklm_{t7W(}7>1n7u=*0iwj zq3X3vn|j|vMv?a25`-Z+Dob#R@FY@!F*VVboFV*|u$(3g5}rL`h%nR2k?o3x*M}M* z!5F<329uTMAGn7+J3&YpSC8Iq1bB-1K87U$g~iq$WYVsfIa55eVB(nYr$`xY{0SkT z#{(2Tm~cbpBJvlQEug2Vj`{KzFA`d0bmGYlVrE~#>dMnWd(@tQ6E8Yl0FW0{)2uwqk@l#dt@?l;#4f_X3? zCl@=lCW;Mq4Se&!0d(&uv7$k)7Y%-bm&Y%_1x_$NOc46mJJt6}>1nkQNCIi^G z>)N2LuptS>VcZk_#&q%@ZSsOE38Aw|jg~)gc|$P}J$ZJ_lge?SxO9=vm^5HSvWBOh z_-4h^PEP-uWC@cA?7Z__2Btxm-(xU6H zs)a}frg*^bxvA4{h<(xOhzR4Y80d*Y(va7}fY?qc8b!|=$#BdoG`KyWH&}FnduXn& z3vPru2~SsJRldKfcgYkeIX{>#KM~sZ8s#L~b67A8$kj36VctI`6S%%~2+<{mFxE&Y zRCtD4A!}X}@sP%RUNl1#5<|YOk=jn%K2Zb{ggkBp^&DXux=$m>4|=&MB?v7!VF5zM z(L;Xyxodd|_xK)?noFH%r60$F}QKSju#~R%S zPic=qWk@Ir;`q85!W>#5aNSU=FWo?FGDrL(1?U*DM8$R)!|fJbslsZur4})dBMTvj ztm&*pYopD!>7zP2RGOUja2d|;?OZd8OB%Fxp#11%Ef}%6{KaFo0{jIR#;jmi${~7i zCU@S;68JXJ%V31MF=YnnFfRni(;vg+A*QG>pSQ7)uOytD+&g(FKTg<2*4MuOB<4m3 z7Dtn!-itMEkW)cCBHovi2J7%fg#EUob3Zk!$$a-oTSCR=zEMI^cyZyKmKai)ukFla zH|LK^KR7hVAs{f26NB4vc{+$lch)5pi7wrZ?9!6|A_BIn=he3&hB9OV;Zdy15q6K~ zY&DT-AW;bpzV}}iLxEbiyunuc!xjdIcgTt3+uUPkr3Y*^%;Ms1x0Li0Ow##S`hG#~ z++68H413XLqqn)OPgh4)GboBpZ46h*5`|c>RWf>{YWpBP1siKFju{5oK5fF8NT&Mr zWi;sW+)OALwi3N$srp!iesfPHM|l0U1k$k~lyib__FGyG0dIrSbI1@xb1*~q7H1Ri zGSENk*NcbMGX1EJ@6Rw>B>w*I7MXVZRsYeayFcU0kdhK41djx(lUmH2*M-!Z z8JQSEnitDtpxf~8@K2p^M8mZ$J186?j03rFCe|br86(8D{NlgdVAqcJc9qy5y^)=e zxChI;9q50DUTsmlHg(%@Fed}s7z&H<-#sJ{5kNo!EKeAIWRw`9cieC_C{D8gx+la4YMxV=zW?)1|!L&7xKxoK<2p5s0t! zu@1`PT;N=+7gPiH#))PTy*3mkWa(JJNT86eag6~z8%O+Ec^dd#7|d?a zj77hDn%D~?W~KMFq0}1e=FfL+isE6iizUblFAM^SCwDoZ&|-seG7j+=07L_D6F}$C z7>*7iSzB<}#|!ewh#japZ;6eV8iJOZ@f32kt4o*h}XkU zaJq1pnRKH|f$a+rb1oXj{WQa~{!tOSSOIX_XEQJORl}oXR#7{c#TOP8oe|H^etrUh z=R-C|lUV0wPh?S2kxmGtH_|BQyLdu1j0GJ7dHbgWHjb7XSG9f|nGoyOkH@S%@r1Ux zbhIJik}2so_hUCGgWs?K>|;PWhaa5f;fhZ?%RyfF&TpY+DK#I~$lMq`JB}%Xt!BfU7{&KnSp4IN*zd&tr@*VkWM*h3hf} z7TnX%E~xodEBb~0$P)sH039jN+%r>Vr2fNMifIRd$rRqaQ-X$xDFDWGVTDMR&%C`r zU1WhTJ-!HxvqpNBFdPX+$`kS%7zXxe0l{nF#WL5yr2w5j*w~)m=Gu|@!+R zVA46+(u0qqfo@f`zJqs&umCd`=2|NaFB%#n?D3szm55c)Z8lN1WDU8{Z*lz@q$Qn7 z${U>^)Pa>K?VnK%VEiANq-RD?JA&e*$8_W8*5(WwX~Ooqa1-gg_Az0jmpQ`*lE%Mv?mT>hn$HfM<@#V8;b3V z+d((VcML}3Smg?t63I9;`7lT*?vWTA?)?Pb)n5+W9W&J0Mnp44 z0S&5Rg!K$y(8$9PLE#ZX+YvtSO<44g@a9U72uhe;Y~%+p#<94d9eu`hrW0$oeBmli zkf8j2P5K+CIv>gl;>8qefUW`HVCilU7~jd6dk9!x3Gr6E_*SH^Eu{qAGg=7&OU8D8pF2Yd z(V$`t!q8v@j|IBpQ!<;N7WYM^$0|%NmscS{VW z{|bL19KmxB8WHAXp4YPAMmKACN=D`_uZ7#R-k4F2BP@?l3WK4@q%7SDG$L4mq7b|J zbTC+IgYDoyqBBnD@1&cxD#)A(s|_PSm5x3Z%!NaXTeRcndY5}3QNmK3`fFk^&eI#1 z$*XG!s#=5oJu@aya$1B3JR*}jBo5!p!-{$7LCyEnG80+PghM$+I4VX*Y*H{*P6u@) zx5n$0pb2_$p0CrEp)1Fc^NaJu_}WKn5MK-Rv1Dd~deB!QBCA{vGtbj^M39*rX!H;F+S9DCblZKhm@?(KQlL=y8j|zI?&!(aB z(9e^DMllGDZWomo|057ef}b9F>T4!cwmDCEs!h(*r_({nAS62&+hO1@WWi&@+bG(i^fy|PK7C|FlZJ|J1YNTjeGdzc1?eeR zZ@_LGtrjTc0AY}h+Jcd}QS{P-G50-nn0R_bl!TohYi0qDC5&^)m8a{fFj=s zc%!n7UL%95kPkZ8|sA*5k8yIijIcZzHqr0RMk!n{G2QkKD49;tF{th?FB#zO~eQz#sZ{upp(bu zSOl6hNX$f`u8-76b2WKTLsi2?N#a^k@PyD^2yRKim90N}?i7u9I&_%**%wzE#YcC9 zFcDd#2zf%EHYg5pI)Xb%MMGSO^q6XGtqIbEXRLAqE8c(_=hlrv1&TZ)%sT$%fQt?L zXVr;z(qLt|PCwcs+~y1yLL&870_1woifN2Uh!?To? zc_;P)8TOCDi#IfPNKwIn;tA<8G4tXn8;;o;)0T8&Y>x~eNH}3tCQuqF60XD8MJ!6s zXLSc@-Ybf!Vk2NaV!sN;(p-XBTC*chDh8A zHB2ON2n`Z&6NZFUBk?bmf`gFXDL|cd2irhIl7a+AO+@&yKB60a5F(NA_yeTDiW9|Wez7}xilE`ySOO9yA!WJ|4)brOO_nZ5G?G#MKjzi zFP}9G?hMt3J~6TxKupw}XeElRX=RI0;`xYn9vKygCudNk+hm(I^{>PyF|V=?6zYIzVNJQj=kCvtVYH z&l7|+bSG@F29&8#(Ir7wtFzpg@Sz=kS||ziSId}XLc3j9g8jM}v4eFB0m)XiPtJxK4zy;PG3AC?HAzHk&A zS5Qni?IuS9EHjY?C4!AOIWTlDuqap(4;-?8l)<3#&I6tZkkSI^=!A*162w%(}w!nfGko9+P0g#Xa(OVCJ*@ z1c6hAr_w7OA!yQBS6!1_>sV4+FD?V~%BPe)d#(IpK1;u2CP zJS}PxH$9Q;N=h-<~@Ps$PtskzU})~{OrEwfed z6Z#z<1Ovlw_Z0AwDZKvH=H*lVqbT8V-gQAgP8`P6tYDoNOos8|NgIlIX)&dZ_R#Z6 zgBym7c%ngqJl!`s3PfZ?943|t^E3s>)7_)#puzJ|lO6*6qHV8e2LuRkZL|mh&bmSq z?IFCh3t%tk&~OrDc#%Y%fqza6`&N%WbQc>I!~r4};;)@Tuty3_44UVY@AAfC5O#++ zQ~<$BrNE49l!q|)ce+hXGYB2?pb(XS*_AfmrL{faZWgCn(885$KY*_v%dPR6Bb;LL zj$X#gsP2SE3472;8e~XXZP_`2(w_o4BOJcEW>zt?Elc0v0p_axnNB>JQWp#J-KJ7> zFpTY~#i=o7+>x5e^O70Z4L#;T95c_|p+VB%hWJeoo;apF0})I=u0X`JJl>L(ob0=i z!-SbUgdF$r#O}UM-guc3J*eZMLu~uT$7GX(bt2x>d;AI0FGqN-|E|ARj*h)J#0RmV z#d%sDBFN<-VnTeS2cyW(tzzLoFpG@)>dv_9NV_gCAGULXCU71LH1lY!YiO|$nNX&W z3Eylf?d-Cd3=7JF?3nb-AD#sWfJln`d>V|`;7-j-ZtQbS$Z{OXXEsp~fJ+QbE}RoV zW@ABVZw>{2@lP|7&QCrPByCzuj;f78i+VUjNV?|~1D?9dt@O!$ist@!ghOojD`@HH zLy#=!950o5ZHxeBks(CPM$&Y7(`8!WFyokzkq-?UcvnrGP?4|QqaMYk3=VOl=amLG z;5R|I37q%~?-dIZjz}E|b``wO3HJYl$q?r7^08r$6rKfy1S8M4Q-^KA$v=sLW53x{ z94bnASZ!gx^r1do1e-zx0P|)rclgT;?|@(aa9b17nx(R6(42BRSnp5x_@sM;T8hvh z><$EJBm1|zVv2OOGqA~vvEgUzlb26CnHG5Lz?!>GW(k9P2Y`YdyFZTwH8?83k=`*w z`3htsU`RNUAc62nf2#k54kfRcA))VVD3uKvT|pCyT=>RHgK^<>rk<@Z$wdgTBTh#d zOw@$vSaMJo1-hh`M90x;oy>EEeTjHSqHzgH&kMfS>8wU#3RHf(=mZ-)Pw0uhfZV$_ zeH;{?r!qZtCxT~Xz6K{`2M|MsXHJ?h@E9fCi32+Z!SAOGyyOb!0|Zg@#yTD(LkRKF zaY%4Fn?tA28cx+g@DOGEc$nx$QONKH45a_oF=Atc3{mna)}^Bs;Rx?hN@9?BB0lgC zZ=Z?pe>C&}p_q3Dv~*x#BO>gCAx`p&H$I>d7^ES%3zT>AEZUr7q~!;%&zSD`PN>Tp z#j}DpMz2riW2iVl%+ilkA&!X(HXBQBc19i2qD}x%PrHJl)g_dyKaa9_EK!LQ8I3Um zID@{i*B=FxNKjjZ zuG63wXh_np25y*w9cCm=f@6|yRtKawIJWIMpF#%R%?OTYG;=PnFaceN8M!^C zDB0UcSK5%DmGm8?>lvybn}2?rXH3IELsAfAAi!+V?EGaHiHZa9jHm?FNn7=YSP5cz z=ic}Mlqh!3AE@i|bD-mEFZyl{OD5ht-$q3Rn|?b9)p*r>wA#KriYU=3N~kE_F6v;A z%_bX)hBQqmav`4Q?J`s*r2Q&tx>s|9M}L@_GbD@1Ec1aMY&S<9q|bDqDM5jd8NAr# zNnQ-~iJ6Nq+_hR`kefrpa=TZQil7%tx{%!ir2kgA{$}tmVW><&DZ=bY&u})fgv;=O zgP@_d49YZ*$Lk@rgPyRVUK?yYPB9h<;)pLmthUyaE&C z4KYWXSoK=OL^$`cW%NI%2=#rP9n|$%8(t^Ayjs!K5R68fu3_v3bv?iFal7Y&pfyWH zFv9?8Yy}B3KDKz)T)L~3N$+`$*^GV-$-RUbmvMfAqZ8H@5{1}r$x~s63~_w3L4#Zd zLzky1Otyi7a|e$2j|ne4F2IpZrPLuzAi2UWnWxC-YB5pT~HWJI52Dw2d5J-m!&I9}qkbX^0FmAl{fP?p%A%qF9{Zp%5s(<1o-kK>leKafDc3B<4%e&;n8tF5`y-lL34o{jY3dzBroWd zGBvn!v*Yq{IZv35`$Xpz>ac2I-zREE?XI1kDBA-eRlaPL9z?icZxuC3hjEH1mod!@ zB1UEkd!W!(Sb;%6&ClsENtQ%02JV=$d>q0Xx8Sf%X%8=!+xQ~+6wa!gk$KSx9*yny zZ^-G9M7eO5r71iP*tsAR9ka3m&p>oR0aq(G^gpN@{hAkuxZj_8fuOJ3I^P`1pTOKW z%955QybciyE9B|#e-pPBBa}EnDPk3?(c{%m8F$WF#?9b|ee+ zssKjJ8Og?Z4zCp2ezhpf0%oQ7T#$iq|I*fno24yA@2pwT<*`wt-|YRTo2V4ttZwTX zEEA`TdB|+jaib|bmXkc4p*jqR2FO(0xSaV3YqKEC8*NH(j|P^z{aA(X@c20Q7#fHPH8f#Gd3%Vaa+m{ zZV6H>y+%^LY?pLkS+8X%($&fB#0f3not`*Sh1brI4jQ}(BnZ=G4rFHf&kBSeV2Fr9 z=UnZO9No{6#KQ}P^B&a5Xkd1dR(MvrLZ}!AT``T$f!yE}MkQ$vFnrF$Zh#VE_Itv6 zW(r$bkV|7u4dv1G{!vqe#lwsQb%Dk;1{#zJ`&eXNnyc<5qBPu^C|Bq#rS_8KzJg#c zmH7(&6gA8y(mm60UpHw^ok@CRIK;;^1r^v$WgxZ~OUQ58h7dJLa$5Qi9AhFZ2$yw` z^!AM)W&jJ!^Ey&!uJMgv!ZL|P#LV&*RMvq3_p|J7((&xKYY=8eXf9pgu3Xp_>D0CN z`9LD$yWmuddAboRd=V>cbG7MJ*KL`4=TVY=jCbmAke82a_;J3E=?ugXvDl7A;1kGz zAaD2Nrcf%*)nskL6&N&g$qOMe*K8lLDs6$XmM_Q&--X}B5QE4iUb%tgEO9!Wk}PO&fN4oyytY$ zXo$Qt$k-cW40lP}R#=>qgvlu`HN^6YQ5HeBxvV~MPn=`;jL@c^S<{f)Av$e}i&>E{ zV_$9*MS>4Qf$|}h>UY*GHVZ1jsOAQ-$d=Ek?FXTXe4EN5cS+2>VZzp` z=Um|{mUv2diFon<|BRhL4v_z6bLq@a5_q%oRb@!pz|Ur$5LFqyA*xuA-0~R#E;PP2 zyjv!5Rk&p)$}gqG-FaXgLjR13N7hpS>;i;epkR4Sr-2$AIx1769@2vfPmIuqr8z@5 zZ={CejCXVDr+{IF{V|vxHtYdG3QxG#6M{>$v?nm)o96 zP9;?gFw&O#7C(K2QL!^0N;*?8KPK&RHmffL@w_bFne>2Cw`aE7~(;61Zo(R9#kL zg@5 zj*AuC##+{A5J&?^C?xPY@OfSYO3uz4;tAuyVH8W&f|)q7dH2ec^)ZmgW4Wz6mh;vb zbbt|cp4ORgXW^tXnFWmw6;f;UNi3=nTtbAn~5{-GDfIzWtteW7OgR^>05wDxTg znq37wVk`c2AM;pLvcvkUa`;gzkoAmStY!0;e{*&PVNdn(ZNipQZ20Wm!l`$iULB~6 z5}-}U6DOYmuE;WCAZ=I{{>yNl0O38zwZE!Dgr)*z!|@na8lvb%B%#jVz$+aRn1)t6s%2b>$YdlI2G9aKWd*^1+4!cbz3Jn39WT>I@ zwN9vgi>`S^%*6E6Af)ghr3n`r$7yaTt(?eAO25Tcd7>lY@1!}=E*ekXeBj2uC&yo7 zBxW(+O7wIcP&z>%{N~ZWuOm$AeR!}Mr$OPf8BZrAF~VLvm|PV00|GJV$M+6g;%_?Q zXEL(rG-PBks~F;i@?xSvjc)!Vaag#kWR-h=FQ&PBm341=!zz8JbysNWPEcjydI0dm z2nh*{t@cE{JtfG&!O-AA+I33`f^X*=7MApAke+oXrs0>iZ6A4tHiyThp%KzzF6*6;C;AQe+9Kpx z*6_UibHa-Bl%8PkXB1RyQC5>8IDCktoePVtoK3mU<#*aYDTv234&Rr6}suOUE~AYmf%6KeYjK13E;EA{&Nv z9jst)Pn@e0=AO`%AAH_ABO>5+5(Z>l15(L-P(G^VgL6E#y7cV5*m=ZJJ*a?d&_wLph@^bl$jst#N z{Yj#b3?esyzUF2g$`~%<5`=l;jXi6UxVwR)e7iw3dL{#RK%+GeOMAUmG-?CEy{`Cb zWB+KV6dpvvYsJ5tK=})BisWO~VUio_>V=_tfX0~RU=GQvspox`|r&ZCC?N5 z=0c?ZiuahLLE%&NOXy_EjwA;K!@}S$kb(V@9K?vf`YVoc-bXz^Sx*CTBI6kFBgKf> z$*eRdVoXQ~82CJYfaC~I8pQSABb^QAi?QCJUyGfF3x85%j42VUQf#E?L^OW7DApc? z{Y1Z$D)WkeN;b_yI<6V5tZ!@K0}O|fOzkm3LLc{`Kaa00ZwSVj&4UY(07ENN%%3cu zJh*09jlWe#G6?pQCV6YEX zW?<};ndLzj5D2>y^nW`*D8PM%gLcLVas+A0%Cl(YRYw&i^_!j3N1MZ`sL~hEmG<%V zv4LKnnOI-JthU|hldd{ih^`j9{g!%Sd^msMVBFr zNFFd`h)C7>XxC6AM##^jhB%nX+|b_&PE<)j`e%a(*P?dYDBTy0^MMj#q-6prUr(B_ z?(w7ZB2Z7BO`%*3!yTi|1A>Ck-)V`vjv&YoCd_Pfvw!&rhgF^&h>?cMFj;L3n6WaI z2Fc`c8m}2UTdV-1=E(^L3&|A@@5uHM*AFIFaPjj>jG9dme%*wY-Y@#&hwAcs0v;MY z0uSCrtT$-9#-f}STv#DX@U#*I_@#qo@LJow9SW(kEM3**B~5IJ6Yp+-^r2gm-O#mV_SHv``4}rmpxM>25b`k6*expgu{xpHCh!AyuAcs42CeLU#hR$`2j5+h`usu1 zsi1(;O2*&Yy8vq_+j%7^sX_2AA&Gz#+g_NFBu(~s#=eYSe+mi?Vx+zLl26RjX!e7S z_=v|x6FzUP>d1DJ)RO3 z!GNq=PQh0;XZBrVHR5f;*E5It5v%8EHcRLoFoyU8@74Fgb&{bDdTmPP|5TTa)UHpt z{WZz#1Ka4wYZwfUS(~f6o?&^lsCxcT5-i?jxTn13mQRipqMwvFSP^t|7pF~X(}gDa z)$&XV66H+B8F|o|$>_Wq+dJa;QR1_(d@qk_pZh~kgbd$RW-w>@6AFaMAjS}ita=YT zNDqyzp*$-Qe$wA8gzH!KK%rP^tGvrd(_^74PEy0(l2{$wEovn~39!vY^joJ*8;^;nz(_`dZa0J*ui}w-@$wOv8mFQ7WoPqk11aXbNzcH2%VgHb`7j z5eQ5d5o=R~c+v>00?7Hz)RnNp{X8`;)pzgM@AUZT6Sk~kp{(8v`FtZgm<(SNf_YDa z>ll*bOA0WN`0wHCT8BO{#X2fZTxfVQ%Yb_|F7d}3pp4<z?Hyq%a^#HWdGe zUwDYzx#yJW=)67@90+gn6BukYiMpPS7qZFEt?I5+)xlH`{}j1S=8_e_&02t^ zMg*8IZ~Yw-9+Ij6DI!G{6bm}$lXIFjp*` zg{(5`Cp&}~Hw=3acV9S;f({ui?oddQW@I+Y*GqpFTT44@1)!amc?nvK4Q+6UAE|RO z$YSOe(`yvk`c>pSoz>s}^qFibAC&n;vxbDaJzw&4*~fz>Z~1MX!U7dLxUux>Ak|s) z`2BbpbZB`Ns1OwuJx9212uIn$@E|n!u1>-E!FVH|W|r_HFF3fvW>OGcU^J)4>n2bh z^mT?3qRg5^2($>C1LR&}NRRudf_|+!7(=)(Qs%`Mt;Y<1a`8ZAwCnQWLSv}V>q941 zp26mwGnz0f!+%s3#F(9-B|M89VwV{)ScJl`=jC>Lug7Sq`6ud4^YWO**wE;NMj23p z@mwrJ^}*c{W6_D75NyY7F}q!K)>?qWgfoo=a@DA%C)#}$o!~9$A=OWpO{0TAKruZ) zSd~B<0St-x4iL(9PYd0DFF`2O**kp1sj3TGYP?IWO%HClmJ@4%z@$n>eD#(DcO6ZU z3Jeqc5K(aLT;RBHIYjWWYXFY>f-Yah`%S^XNxRUdp~wjgu6?UfP(G&s*%sO)VQ>%N zE0x9w&Yb`?f{v>`*sZ2J?3aj$-C3H-^f-2KFd{uh%=l+=645j;^Sx!XLz>uUnF&7E z7zMxCn?_t{Cz>H#r3rd)a6FjqS)2aXf2~a#L|DOjO3NdACfLk>w}e86n6Myr^ErLe ze!)LW&kyddC+}DC>n>8LaJxh^rTw*9o~N(XXxK~#@}g+bsTmq%h``~&bkG<$hsegv z@Yt>&O&C=_H%-k_Y5iFO5F6eFU*_wVHG27mWvP#A1OhJZ9Jwjmw7+ez`qlM z$nc3kDQlwQ67Gg*%t(M=&xpkztiZ=)44ass^vj63#FB73KbVQ!G0I>vuq%UxAuNQ- zd~+07Z5cp_HX><4W|zDg&60@&wTKe#JWsf)gn3T|(z*(3)q8eY{crAkr!BoL^wr?# z>FZziGpmjH=o=v)CT%%qt!{cnLFA=ig~`T?WNTMo2_yu;ih$<O-8ww_bOrnSxjLFZl&79!lMeVAY(cGy)P(Gfri)`M5Uj`{5S-_HS z%wGxx7leh0sAUf;Ef1HD-Iz#19p79J3iTcq=y^Q<$QSsCm#YY}7Z{1)qt#8XDAqF^ zEqWoTKa&n5VgNf4?)g(`ai-#j$Ki=-!%CZ8dePBA1)(AX&m@B!g*toZ7{0h!s_T~% z*8_VzX@_~CJ*2@_RgV5EGlYuMhlR452x(D4v5^pZUeS(70Te$jr%YnO95QYi zaE0Z&U!cbdduMRQRT#X6PO31}cWTx-fq#kfI^zmcEKTUPPMX-JwZNz>!qF{Nh|=?a z*GC%VWvn^2d`6GV+3eM=pU-0~9T<4iK2Q9iP=c@uz74YxjOO4Boo!kIeLD>pIc%6N zY#2F8HiQC&XJpKCwniqfGm3^xUGoUiy(6`B1vk2tk{{GbfOx7z-aJ)YU1SzFj|zle zfZlhSphpwVzDJv)*^Nkfzt}g5hC+93W<6dC<7|wxTuaX^BxsJ12Z*9rKG2=0+t`T_ zQyI?nC0E*381dahY7q*Jl-HW{-=o2ULFx$pA?Ow%L-?@F_tPMqH3V0^p6#TG&J*he zC5M<~-;#=9Iq#F<@gh;gFfUAcHj6?VC{8d1kT|1;dXCpcKX6 zu$?!KZa_dH&v!BUQ73A)=tZYt=0|gmbsf@KX`Wp*I0ZyhM+s1waeRk33Y%xaj9VQQ z6Tnv2^fQ#%EE!nh04wP?tklGw`fCT4IyM64Yfr$*m{w!H;eGtQSq23RtieQJ#IA{0 zaLPjFHYK<67c0Cv08~uquzVh#OcWYl@D+V_lV%{g7|2r$GXscPdW#r9j6@rIjwJah zURbvRhV1}lKF@3)MJL=$nwgDjVFeO@-3k^$gwLob#&E7lO&?lOVnriLT{w1@@rnyhD{{jlzACTqg=rA28#9)Qdv9vN*|_EaMtg-3f|g za7Z^o`~3a!@Y@~`W`yZ95!-cpi{7#9@>`U|mEqRM=l7>8(lFA?8Q&?qgr*_)x#3r^jYeQM1;aEhMMc;l1y(@(vV2 zexbrxm;v;~Q8hZQ+KZB{MriqoSgi^%(6gW*T-e^@g^CxLhiu}45$uU}Nek19WJ>=H z;6yTKDC>T5o+$U^He@-{PV6(r(GUbcE=>U2HqWidr}ADAQSLlmnkX)^g_BNjWx=S0 z!0iY{vb22jgo$Xw7ycu1*}*6YPZ#YUImUF~Ys?Aa!FpjawtZ4<9(xjgaSr#QC!t8$ ziPNb!!4^}cJn!1`9qE!gocz3U6h_p&sCn}o1nv|e-Liwvf`YZo9PwF@9zAS`6>$6+ zMEIL@!9d>Y&`1vQlm{_H-4Q}?kqcuiHlP19t@>bKFAwrUc22N<1U~E=F3d|tGqtmFoy-MVPmlqsYm21Y5Z#aI=-Vy&?AQ%j)*F^p5Cw5y zVSp!4ycdS-A@{wM8Pg|1h;v0^T^RUe{Icn^K%@Ax@e}v zXZ1#y%^X>Y@USZ`jt$CH=Xfj_`zB)raG^MX>+bgY;Ig;6qQ>9mhT8J?}`N^m0_1!oQu-RwEEwTWtR zF2IeZhB(^vjv*(w71ba`Ey^zA9GkRdHHy!vHcekNY0M@UDm;>V`So#m;YC zJfj0BI3fIp1`Tq*Xzcf7#8U4`&z+oTG;z)HgG!^kq1-H6iny)*;6hXs8Peyai%$<4 z7Ytw54+AMXc<+gUx)b2O>KPLnYbIs}8%4vHU8;LV|8y_Nwv>FL*4U#)rIKVKsVlZf^cAX zy#)(v#X*l$aNMn<BKC~>mUsNB>_pj6z|$z$ zeWwhCyasK3=UCz=I|5yL2b0=Xv0*OPRef+CufLowkL!^xq{}@qvgn)e66zQDQ=Pk0 zmYr;8c|_r~stL+EvMv^EeA{<7jE4U*FA2V|Sq65Bp7|tB2y*RAJi$g>CvevAB$hdU zLVD$BLNZ}Y2>L}B23n^c>wIm-V2+;)Mt7$SGT&nT-YD9z+HeH~;YS=^Km+{Q zPT8F@CBY{R^3qF#8z8%X&I#rL{bs?-HKX{JCFDpUbMK!8)(=dd=S%}XYEYC4+wks2 zQLd87%XZVqLY)b|sti~WP~<{g`kNO_2MAV1+jXEJG*Wo2K_APS(1vi6q9NzqHKPT= zEJJjBzpT(17QPQGo)_r}e2O|v8jhW8&y-yRvufz(>SCO2ljp>1&`^*v4Yj661j*jP z;vRZEzcNbj1KP;tp#V^!t*u5J%s4zX!N5rW%q_Uekv&QFxaoDnTE{n>|HwKH#}>TiV0POqG=z?H=oe$6x_#Q0WH z1)qsohrO!vBQe3r+Y3sNo_aw+CL1w5D0KdJc8>yKJ1+R+`T-qii1@_ z>$Keynm)){fZTH6U}wZqU%||)H25mk>=IcxawZI$5>E~gbT}$RZ3>S?&nI0JH>pAh zYEq+q#U%(Ot(LxvkTx@eIL3^9%4{fktU0*+VTXRz78r0cu_1i7Wvj=h)qvj+$*v#tEz<7u3?fSr&+C_L zj#2xv-V(zVG1o$UjzWl%AFhZXm?7Vz&iSzvI1O=x6 zQ&2FzF16wmF}rC_$muUY60Wu#WGuU-k3~>4SGWUuOdq=e;Y%xaoX42YiW9bF_((F2 zN3&={7+hYo6W$)ikD(#H-N72#0hWa%NS+!Rq1R2!Nxbj<9yexp-rYw)6JYg9U_m3=S{4F zQ{BmX_S&MB^a6X-l}{hu%Z;NUMRtES8%S=D1mHbvAuk>Fzs@is-u|1NW*BWI?e<>J z`^@Uah@=^j$Tsj#Ye_FVLnApOCJi57JfFv3{9C5#Nb!;H3l9DX%gHCsn@QT3#nghUOW z6$ot~8A72B`8&6cY83Ji^$2i!q_n}*Mp@B?MM1sT(NzB6;tm7A-R05=Ge;)s4P?t644JVGROdvD{c3B^M~*9YCY}f>S`M*pzUe|bj%Qi+jAR;1c;Bf%$QZPv3Sqb} zFnE^oTu?08zkAPivF&?bOyle1g}iE3oEJujjyIN%D(muvjekuvCTI77`EET~out&`Zr3Omo70 ztM4~VEjmU94Ph{LkjCpy(-W;vXvugUZR2LHJk4dN*MzeoG5L$AC?VV3+tUtxm+(8FX2FQS(F$95M{5^NSAJ>kGQW<}l zKluL6+PSpsc~xQf4aUP~JmUeKR!}<-5=bDWHUu0r69*be2hl+lS`@@qX)6s%N{dqk zXZ{TFfQKp?&(>2tBK0qbICAE?@9Vw2eX{d5`PB71&)#dVwf27FIhSkiz25P}J4T*R zE8%1x$e8kj*34nF!`pRzTChb4_gp2#5ZG%*&4SY9gdsOqe5$pfdp}nWL=8jYW)ywH zh+Vd+Kucr$OGH_#A)Cvs?#TCzB?KryoUsPJOhzGkPdK^nRN;8!V21%UEHtG&INoIa zBvC4M##+J1M_5Ku1cG70wVH*1EwlUe4$>@?WFWG+4-dUrKQ}O7D6ShxH(<5@$t zX+r}X+?QmalyOjO9!4zoB|1eb5Mnh`pJ(k0R_n{DqV=XxZ4k`>4PnsRL>nXzj#{F1 z*#co0@c$w!@4a zaN#^cLamS!f`VFS5#hoDA(iMcUi{m;A3XcGy$-`#QY<%i=)_>Il|f5-kSFA)P9HY6 z88~Ga9+dvFJt-zBC?F&`=x+W$lvbaI1c5%r_~Lz)S`5X(0^uv7k^7qm_t<#Us7Ey7 ze&T&039gob4O4eQj(P3H=7FrJ0Hhq6E~WQzMKDFd9qg+m3IkGwm4cuU5siGDn30{J zW4{URjuf2{1gJprgERy`^!YEo@BJS+bN<@pO^Q)!rGxViz3=H~625E|wMw^0^N}E6 zsPAL|Z>;4$$2Y@f#O&(NDzv}@*JO;QpupApIOWm(Y;1HVDT`B)6^jIctAImE~a1Gzh6m!|`E%*l12 z*km;TLUKUm4hjS@yTg+pE!M;xy}Ugu$b^DnepP@;04t1s{l3f4tsRI#va4cb>7yE| zF-cp#^v@EzGJ&+z4%B53r{3|ct$bGA5ru+cIl*U&WIE}(85Fkx_t_mMmtMJm~K=Ox{5wSSg>35ojmDmz09X}7zNDD`x93a9D z4yOG=d#tpu7q}(A0@d||A$^xx=ih!@3S6JXKvLY+s0XRRv1w6NklfM2E; zJ5!-B7t0*p)fAe8`vwQQ!?S{3C0W2a*6Q~4Vy(PFC|4Zx<)QO(TRd=QvlJU;)t5b{ z5=z+X%}TC2!7(HYaJVG*DkZwKBhX%}5Y}xapAm7Q8nOk#!?h)jmoZ$g8SP*Rs+6*L zI^GX!Bmf^KI$Jt{Ax+bL_|>M#H0%&vwtG&;66jVSHd=`pk4YH2rg07ul+s!ljs!FT z!H}M8NiFhQTsSH-5yvb_uS}qs&_(GDhRXg)cfyad^UC{)_jELny7UT#C8wK46GqkU zQOZrQ9*jrZB%#%FKa_mxkxT!0;K8#OWCQ%@t!h3)b*(@+4?UP?Dl7n&4!Kga`$wf+ zd-`whK6}jp$tWsJQ1~)s+{J?}J=jY|AAI0}iz8=8E{v4>I9|wx(N{5Q1$w~G1HWYa zdXr3o5{{7<3;$J-lYnrY9!3mBg<`*E4%sT|Irt2V(z@AmsW9(`kB$JI%(N$=3k~Mu z1%z^wj9co^yHDOwon|?rkp)LbpZ~)j{_v+i{pk;XnqFl1==t-nWD?8t9vUXR|9d0I zL+=wwUSqu;PD~ZT6&RzI=05m->5M=v8uv}3FcxwEPBq)$2};dm*xEVA5>ryn6KmANarrXU+TX zpI>;)i{J@M+H!f^Bd1TF2I^=zBKa6o#|I&@#BTY=A7CSM2=J0h>|y|wp^~s94v!!~ zqZ$v{!rlp*s`9vVwh|_j5GMs)F8?`CU%&6M%Y?w5xsN7zI2Keb{^{?3|6^Zz>Z9U9 zOSq7EKxjypCLL7Un`ce}cVz3(+4}`4?k?XA2UhM=?7U0~9E#WiX`}`TAZ3_p6XvvB z|Dw*oc;9E=GxE-nyCq-w%2%FL6gS8e1gFtz;GInX=@{Td{u)c50szOH%mY+8xCi+W zr(ekO`L+Ar`Of(h-F4Sp_uO;OefQn>SnK|^cEhd0hPx8ML%xn_m}$VEjGg45*EQXi#^{$d z(bq+)RWF^L0PXy-Jbk6^MzpUHEnXhpZJGNvO-S01niFolWfT&dj?;wt!iVx3A4he- zQd@$N+M*z8M(`U(hIyIMydVLL=!wkw322I&F?~rg_c$pn=x3L*G9o+b)Q>h}yQxh- zMyCmn^s%j!U8LelGvdtgGgR2x5{0tT9+rNzH4}cnJ z@Y_c^s;NZ0Y&0c#Km}`=ig9q8?hs{!pz30ojuj$R?aG8*PuU86ONJ2Y)BE2F3j~K& zDcDI8S4ONCS%aIN+rnS$p0Ms4H5f(RRvs)LcAkSabM4WBB|_>GvWB5f4g4lI12B>f zprcfw4eENcjIR}qn&0D7t4=dn<+w#BB--!^2C7m#SIZ3h!F(JcbV^V>sA;3%m3Y_b zOq6#Tp`&LM@J2KkTk6bT{NYv}xwa1?#Di8RWEIX1wgw>>2o>^nUnoOZ&-y4b{_VNv ze)X&8e)Y(uzdi8awa1ywB*7pwNV$o5$25%@@8R!k)_1@BzGq)}297(~3|#i#tfmdgvZObEo{C zF!p(@p}g~;n^^Ao3R3y3k0Un02BsM;y`Bc*2d4oQ243#u0B5pQbad+NJD156dG2~g z`KozAck}Tls=R{}eSVoI!x~nmA|{uXGKXKVH`mC-)rn7ZxsYMgm)Z2IK_?3E81^4O zO=F_2<5t(-*Dgf0$fB>gyv^ys4%aLqxW+;u!gb4P<+kRP4oUTOoIb|%%M?P_xD8#d z8AA+->adHx85*QLB`qcCM>t3*RS~1J!8G|p(5$?73RwyW*1pN(bpX4S5sQ8iLQrWF zL04oXu2jf<#5+z7o;^3bX)~fqg%XdX2)TN~s=Aby5Bh^yR8rac|eB0cRP+Osuc`FOxvjVRv|jv*r?)NWt0C)MZrX2Ew1Uc6C-dI zbPLXr#Y>fVRK+*{7<>#jlHenbZ&oF1IFE)spvX)O@pZv+t6URttXoDYKJQH5bcR6b zN(0&plyNf989|>N1lPPl)bn6Qd9;8=hRV%W(U}C)`H^<%CLla#`b!KwUuKdZOf%i9 zBSk;~V2%y)GE~R+$aBcA+&L>HxR5|Au? z?iAJ74enqe3`pGx|Mv*-{5Ocwfkr)2fCj!1t{at*7(`_B*crv?phJHmzb5#sGlCD1 zMj)sgTOEQxV304WtmeBT?iM9u=+qoqi_m)z6IjTwkauNrgCaq-dpU35%u&P9S(RB2 z!h-(Lu8ftuYa4{@UG&hu1`bzt$rKc{@Mp6k_qt#ul9DfySJolL-XFD;*9j5Nb|Re|yGM zf7&SMMlx%)8sSHagCkdE%DF@0_(s6IdFYe1Q@po7Hb2Mm93ixpl;ntvifU~_clZA0 zGoSg))vH&Zy7%5MeDtFqz5jl@oGw*d7XUh2XyWRRknewAjpS<6img>aGV;mqfB$EC zD%Z!0Q$p^syHcApjK~RKP>RHaXOts>m#XwneruJd#xI^rZ9v7UamO?Xkl0G~8WClW z7l7kd2Kt=$*>d*K^Gb5or8etf9#0i{MdG3G;J}SWMBo(e5FQnH) zS-sHnmSNq{7qmf_$YE2{b;Y-!*DZiBxtndYl|X-p@l<@7f;-VMMQ7Z$Zij22CUMbU zl7LLLZ%IZX=-zN3T6^7ys7Y!z*C&uSMe6LvB8{x(n7eDMS%eBsu6H>5LSO?nQ~H zN$P>J6Srl%D(&lE0?-0aM!d6kgNf4&u1PI6%^FZFb zAwl(SFE~N?9IxbWdw6_(P7Z>DAHOIr1PPt|TYxYeNc;G^tva+M7~t!`?*f)}9M(KJD zXo~>0oCF+L2pbQ=Buv1YLGJy3B0oriP*C`g(}_p+XTPb%mZy^+3=+P7f)ivFf~*ZY zY;kfPofY)|bUym^7c6IvM#d3ZyL|pwGP@?D(dd<&4hc>drtF=c5k_@dHPC#rDZ+@e zq5Ex>CPC`rbx9dmBtY6cqr;5LWaHxyaN&Sh)%%$_rfPdeU$e zHQ{&rC`93qThB0N88U`4^MRKo%D~;dzqv^qEvMF_)*zV2^jvv*85~3a!Y`EFdK{2H z+Ly)+4n|Io14-Y_ql~L{xTDiw#MrV(^3Jd7i=BGnW97iglMWlyGUO4yOD~t~X}Dfg z3u*}C_4XM4%K0(Kr}DAvPaC0}JZ*{$ayhCD5TaYc_9P}KoY&}@T;eGqf75Vxzr)!( z1x^eXdkQY6SxK*D-+DA_4TDs)jv7!5PMThWZAJUyV&$=wj*Dz=T1{jZdzWp>5&Fbm zDZy3mQWX-Lp+KURvwf{8*dIc{Nw?S<#y=2jNMREzrXt?D1GK(QXuzRsLK9SyP>Mgw6+LpF>0>>%h!ju2f?+krJfTP0T8N8lvreKS zw%5tHQJNd-6X%HpXH24R=U$ZPa;8+TcX4|EM_0Wj7!qfwu}350uk@-qr zEeGB{V5Pv-4)QFlcdT_wN?DY)*NARtG5+#vj5X8Q9xayI~Z8GAZvJ0Gt)i(!13i8aF~l@Pz{9_pQ{)R&vKIRWfAWdef;5HedjDy zC{kI%K#1JXcky-DXa*Au28(W4{I3r1(W+#zan^|E1&F{LCuA0oB1)uIr?c|Z=n;$m&I zf;<$hbVW1>5bBkjVIJ|Ql`AH}0HL1qb++b(v9l(0SS-rrXP$lb*%y=|{Q9+CJSyeP zH(yk~?&qiTeBBMtD0}(oPt{C!=B6`=w2YyzrY*7X<$a$LgnCMZA0Wi&l>L6PaEcnP~)!lBK*Y_l77z7CK6Fv6Y9nSz{pAZhtTlpNUNL12^6{ao5 z1Rk9f6y^w^|5``>?+G0sT;(hG9UX=HQaR$L1fZ-8_!Jplk~l z2(W2+v+I0E0G0EOPV8~1XHE)^55gfAVH{)6Xetxl&99IoJkgAc7QZ#=u}Y|mS!>uV z(Y#m(^jaWq35m|V|1|e4|6IKD)QO_mNar)VYXPFuVvetojc<8=hP{H;(-X*mQTq;9 zKNyFObg+^r@r&FFOVc4M@zs>JY9TTqHTUDibwF0S3|3|)ZO#w6qdmnzK@x0v1%T#2 zfD0^4$wHdV-Jx#z(CbKO2oV(>DfI;o`y=hLp1LkrXJ#C^GX+w2Vmjpkfy8-pffRW` zO3$1$0M3YahB8i^!X{#tkqmxK3#x?+Y19_t5>YA3ttbi2Va7*Nu*M@Z;hz22DVp^( z@bofat0q@bTgambVFt1X^YX681BqBCxS5VZ^cx^-W%(QxH03EZDCsq%@-+o0M-qn9 z>9f~8i#TC0Lbk!Op*RVs$ML^40Vr$F7{;HMAq|~+wOb%OzP$8Kt;l*D@b*y~>?`+W z4L^CYqdCV%j~Sy74IrDgtQcF41}ZQqJ3?h))a)bay4qV4ABBk=KqQ{krqipbqp56P6Tp5a2yjPVlyRjnami#M0>f2)CCI16lqO~ z0^tQHu=01IzX#;3=`WS(QL+5pAUKcPX>6t>y>o(UEjHiR;wBcH{$-6tpi`Xe(pl!+^X1qy9U=U?*{k&^$Oo zBG7&65h`F58)ec%2GI`36a^U-0i~}}^dqR&NElGPMm)e(NJ~TI4EI!c1d$QqX%6U& zD*JvCgt<}1iV}qDHKCpK5tCqKknW3d3QZR|YQ7NGqS1vdUwpwd_`*D&ywWpx<34kM z0a4VBSQQpRfotL*j_yYrBVUj$g1vboSz#mAyE&0Jb z^B$1r`JR7X3M1#^SbEn~oH%a*LX#jo217=UqBvD~Z*qffB%e+m)2;j5TXWCPNDx-O zz}`MW$VV3V(0U>1T9N{I;wI0W?^DX}sVxW6jNCosBhq45COb!G?7@DlP6J4gEaCJN zHB@uLE!T`bxj@kM`F-X=C1JUlfBikj5P_J@yTOfODAhdgPsL!y8xvnf1Dnu)up70ZCB5_@Y25;3$^LNW?#oHCI zWG;ENrJq@Qr7;VTHN0TBghyX}nMG<3t+GTR{S8c+UA)X&7K99w_oEQuw4+*-C6tpu zjuWD`&_k)vIu@D*`kK?DYjl>gupr^g?UKtiqmrn^U>Sm={KQF;eOxhfV#Y@rGWMmT zr1wpR`Zp59XxZ)fKXRx-_d3*j2 zvpPsju6{u;6c*`(kwzi0wloF27Syx!cNsg#fvq8k6Y3Py5spnJh|ts2Vy9ucrVz!S z=6Dq+#BJ_07;#Q;jAF$Z$RhfiMq_RfHG7VtmdX>JL4Crp8s$`uA_>2-TY`EsToZ({ zS-G&_h%06&TdjtKYIK57rZ z2Qd1$AWmELA)2+x@2yf#$PvqEjAc+KMXk_JNLb0RNchI1VX)pJy15C|+R^x|6JX2} zF%pEF4+yex)CocsYLF*qBkDttVC76fkY73|#jxqme|@rp$`A?(zxl;YG5SLpLSRsj z8g1zG3>i3puyU1>eL>WTU?~_0FdZ5!1Re&#js%4?r*j&x zX*=`<2!CYe3eKq$nn_7LMR@Jg-7mV&5Z>DaVQCKNTX&2;rnnvcaNww1UPF#jo=8Hb zQ_y}3Cq)`udD@A;lXn#cmy2agYrOi9v3W?okW?|*Lzw}teb&z&Eh6IzK~Hi<(V6NdjD51y|2%nG_Kj>M!N>?d4su^U3 z32y;}pj79R01e<-0|0Gi$=K;iihHyA{y__rT|YsY8$8NFIAdEh!aItB*62F3XkG1A zx@#|GrN+``Wj+6g3GL9FMn|>~2$WiODN0YHR+~XNHfXpq4QjwH(mf*X^(Oc>&{qg- zKCm_kc0{9)98VU&juU$niQQc|kXShFn&Lf91A>MELkVVer_}7&5|G}%<)~9m_GjBD z6S#1uSB;LOwNmo48NFuWkUEQi;9%IY)glR zLFXOV^Krtl%-C5&3KA-IY-A20KbTGWP$6~e_tPQ3F$L+;wC2Q`AiPA7g&5Ri2v-;+ zKX^bci2nkCXfN}=(6iblN^+3?qA#IwYH(1{8NrL68XEkLi10#+(%*Ec%nhF*3=q!q z@D1eY_kw<$`PJ7`gwfoG|C%b)7-q!iRrv1#GJ`@wb=f!?P_&m2wYGV)D4heC-5sJy z2{!*n?+*_lbk#F92+}u2$Os|hXVMD^ZjTLOP8a^u3J$JA&lj~(6c)@KqwhZ;mo@oJ zlY_L+;yftl3zamYm6Jywd;uI35K5jQp9V}F1VVUlnlPa{QK1urfkD60&NNfkYhd(Q z#T9q*g42L;BbNsUdxj9Xf8s)yIUL9(qM3|OtKe{ejYdaz6SQm@C0;WwpSB=xZ-!8x zZILf*fbdJURW9dlmlmf_hVV90D69wwjZ#Mtl?xUEZuf3+@Haog-i@3smjSCuJ=#6C zNf27Dm)<-LoK*jg?~@^{d^&+%4sDekE_Lw-6I&y~2uA(*l?R(Ee5dK}1h>#r$X@eD z_2d{0h{iv1gRbW;-#ju)nSMP)_#X2V%K&L)FlU`(0U+@lSPYwW-Ukv6y6F5yLXc_p9MU1@zHzPLS8$5OARY* z{5*w9*3PQvH2Ko5Ft#meh<%ieb2L0^8y?(R*IR|KLfl$RqVG1{$M|sZ!DV5hH$Rxs z_P$Z`pc+i-^b%M}#Z4jN7yJS2;=ty=^`=$sL_W9U9nJos@bE<=`bs@>`xboB}e048J^J!er?*R0KFP_ z_}wyxMZ%ZvJ)$}Or7|U=s*9(Vu38sL$WM+nzCoz;!9hB{<^_w6B-%=a0l3zI@A_)- z!(c4yp{Ov(hTqH>tr zfofxa+ z%TepV;pSi=dSh)aHS(1P3xpNpoEjj|P5KhxBoEaynssmm2T^Q20}Mih8~L8Tja<>Z zhsuMZLA}(T*vbq4ZqHC1G4KV8soKOmLxb}okM`2g7Xw1Ji@Kf9#|WpVi3|?Xo@wT< zr3P~?r~!>Wzic^WfW!4g?vw!tkuSi34)8(209O)`61j7gDyxl8Ub{iYB}1rqxb6Pm z{@4iEauf91(Y%Y8_agTjUGwwcnH{%7!K!rZ9X&lOd~{?gGEx^YthWN;9srMem#LI} zZADG-_(`E4G#DT>!_E}F>G>=67ktMkg4}D;lMhKWC>aZP+Jp^Dn=+HH>y=ETeI)j2aK2KiJPE4i-9g9LYM2{hiCrnx04UZo571m>EK|R(76}Cw$LM zKlOyHqQJXGkf(5!8soU`KgMmskjIj;4f~C>oSX;q1@B}&P=n*C9T_`>=n8k$G=RQ7 z-j2~reVxQ2>5$}_fj5@29)FaD>+0yF(dxupDTA$Zy~v!BRtdgc9Hsoq018jJi+i4H@I6Ha1LM+kkWP?Zd)O=A)c_K^_puMKp4ufRAQDOrDklN$%8SeguSkhgFbD~ZMUvjkr( zTOeGC@L($MfE4#oZZ|I!x6ZG>o>CLCBnP*Xp)dTRv_KkA@xEBP+*{qkLG# zG((mvaLOw;>A{qtA2oc+!N5!)A?YdoQIN7Rhq6NDE&bpsI=ew-w}dC%-e-cL_F^vxW0 zhiNPnpP2tD5Y&i}8eRr5S6|EujhV_42qH^V za8PeVz-99hI^`$uG)yx^fmDoVN9aR)5OjA7CcGI4A4EkULRTX^AOInZL}N_4_DR#? z3jsp)U%%F>5oZq5AV_E)q~%eP&~LMZqn-hW1Y_2c!IR_)P?D0T^3xDviZ(p6uRCEu zt{U*Nsq~car96ok{&K=_{BU7TNLt3XWC**d_5RLt|P8sVKb*NQVlyqn~mU(P-PNr;LdvDPe^Cp zi5%*yT=r4|!cjQj=;pXUnDXn>Q?h+Mu=uejsqt4TJuL6>--yv~Blje`Cg?+5;i(tZ zB>JB9Hyce9n!3KXU$VHRy;C1xTM3Z*R)resYd+QNEU zl*_Q*dLXqmWE|D$R(ebuf0XcKVH8K<&;kphj%aKG^lnkQ8O=KlKsaUv%CkW`o{ep8H)4#Bbx=-O9WtgsWM`eVckBx1-dqwZ+O?I&ACc{Q{aD{xn`n|!38 z&z0&YdAJIpsOjgaIm$B5u&4h9{UAN5=)j;}g1Rr%CT8I`R_-CubtkxnFe!&7_MH4< z->AC4p+EA1*rkqi(nc`UN4Egs%7iaH#@(WXqOSq#sQW+5erOCLwO1j6lpL{LFpJ`= z8y7=a18Nz?OFT2l-Qk7=drJ&1g>2{$-D+-~0SmG6QRNUyRD6?@nnFgBMG^>z9#{5B zz7egfS8zD(bZT5`ew6X(b9F&6q!NK5KBC2VDN#=`3K&pr7|;`vCw%0GwMfBz^qX6N^_|FLh z=c9Z0ai`h4KQuSWNDT>MC5vb+@yyA8%$?!${V-e*GY!Bu4>ylyFDY9@HFSu~^oBxz zlHwdK>WD488N_0VZMjco+C^j~J=ohj(bsbHM|LY(tsVjegs&m`BkU8+HKX%Xpg~%I zQ1@;wJSZI0b4wZK0|-@9;v^(>^5~UcbbinYLYPog_{qx#B0UAdO45SXH7XK1LpUF= zM*D4}_nX0ZEJq$D0|@)sTTatJpY8W^rA+15Klw5HJ=qefXfzr0mibJDtYeo~W)hAi zL)f4F@d|<lr?=Y)|#J(ed075SqE} z3=x+FR0F0LA{N;6%eMV;Z|}eH7-`PEu#uiv_!Cj6m=CYGi~nC{lcN!ihvD3 zqs6&#WgwUJ3d72p{e$w>klLe^;;PockR=mQ#_M1?v4~e+`LMrH7R-K8zyAqX6r+F< zrOhi!Om+J3TM-RiPLHs}$Pj<;4y}PXwMjf|L+1#FEQ1vneI}fs61|TAlMXcGxxxh3 zjgDO$$W^~`kLdWJgB*~vhNnc9qqqkOp|UN^MQTAI(C>B<)=nUnmc*bDaAW~WIR%LR zJd1m2r1dW+?P-IE#W}aRbX23(q+k#Wg003C8do%=5Phgml$X0hHOc_?^M_2Jr-l+k zjWpuO*S(z7pd>}glH?2cA(+E|fTJ|G2xdA0ijNE;+cShk!ODMKuRd6H>{i(mjf|n(AusIwr1*^5ByUP8)8e*dEp(&LMTt>@f!_~Y({kq`N4{OLxop_ zfO^OgZgz{_a?9xXtwk`nu;2^Z(HZ{4(9Ua4nwqYBT{K9|3CVQxy$>=wTy{c8s1AVz z=?DJYjUps;L)nR0kOtEF>0W-V?{?zcAYJ7rKcUZ1mU(Gb+2f|uh21>rZ@pzSTTRIj z9+p2I*e?nX76@U%eO?q2x}(#BWkD3$)4LT2huHwa@*ua2QotTS)6chHysY+I-xMLV zoSyaOoxpiFbqnv6dqaeu1q<*`1U(g1al0EUAH4{sV(bC>^$2mp$3 zr0Cn_D3{aRy~MaA;r#LL2nY==$H@?S0?3zi zWm7&q-p5rgGkS8xRSpW~cwh@W1US14|3F!c7}iFmL#xP-sXaQ*C1X|`WwDUqB6yqo z^9;Bx99op*Ltp9$rfb6@TgqG17ug0{*WzCB5^HJ>uAJF9Tr7i-8k7QYwJ71xAASXJ z0V&hu3eT0tGQO$!ATGw6KnA5m#Ia{`ir_G`SWin+tjWe+ zh^J4gS`^t4ju4^%ba3FC%6I@E4q4REGqCC)HtUP zb196Tpdi}0AHJ9v2SGv;B|x~!K_Yza8wNtsgP@_uBj*H!JwwP-IVTL{1qWSxf^zfQ zEu%Mv6vwXT!E6@&`MjhGul3L%kB8kpI^s@WSP^#|Gj4VHR2Q7~@%Ks|7w?$}_sK$_ zaNmM~OqG`s6dJ4vzYggty?ZA1?!kz?g$JjPhI!y`J}7V+55$Qv1AsEhsR+H8CE7B& z4}8CIFy$sR00`$nZU(}KLW25e*fqKUVOnPhk)+DzJ1=LePK-!-Q~_(*2{M8xO%?XC z(Vw~p0+Sw8H$Vsp`ZYBO2#-O80ztmu->-jS9l_-0pyY;8XAITu!Egb>-@Y9f)Vl#f zq2IjIWteZl!379Y$HG|UM)o^|LKL8oGlaVU;cxbzfN*yc5b9|Scy^2$&02Q?cw383 zT-6l*teUgakPBf>|7z%`>nxI;W>5S&LyIDp1!!4xnY zMN7yeq27cwJyN{`^nU=tQ@i5=C4Yt0A5h)&2WH=WeRtxHf$$D9gxo+AsagOUvQ5`iU?Z64WU|#!Mp($l`72nZvS~P$2APCunCth$DMHg6mxro%NeYjgHO%dy9@rr_C^#x3wHi8hG)@@3 zuGPxcO`nn1zrJ)=x1UvtjG!W6qcA>J9m3M!?av5;P~AjYWmPwm7H8 z;1?L{GK8QJO4rL|!eAye9HKEI!-%p$YLFsxiE^uqo*dX^j?1{o+#Fw|@0mwC!A0;A zNIae>11RWo^+3u7y3;33#7A8Tb&8%>M2BaBrorK&g=3VB5)*Q#f&{5T>u3+WC=1eN z%jgP(LxIqsv;%Sp7Su~Kuhg?3Aa7NR5`&jMHJ#r1BIho!H*`~&e&iyYsEQNWEgE?m zoO8Y}tY=d?pCKMEChP%0y`UpU2b~|hcJbp6KmEvuzgzkIhadUS)6cy7!LyHx`Zy~X z5X?n1y@1s9DMEHgQ-3riKs27t=zt#blhBq7^=1F(ha^3g#>1RzAt-%(-u<_DJogp?putDaj%kGiQd zKv)`7cZdaPw=zIDslb>)LD~aQC=~5gGGd_=F!pt%2_*^PH+XP`L3%XyaGoMRxNm4rNb^!w3}d~q z=6?Y~Ut4pm`BYR?qtlyn!|039<{bjzsXGP29?){-KqzhB*|Ot_V7?U)Ztn@r^zH&> zkhEKu^v}lLXu~7kn24#Yx6f?)B4A^pR2uq{Wqs0yD*ztc;aRAhWKnrX+q8w+Q~lCGT5>u6eJ4@Hmauv6H|oTVBT7g z-oa~CiXvd4vp;##6t~X<&hP}6$7prUAetYBuf>}e z50Pt;QxKK}Sh#3v{1jtADctwvg*vSmyWo_mFwJAbwhe4A9)1l@=Nd39N zNN3A}j5goqQkj~LwR^OfcGw95)eu{P2*B4Zl^OCB0GppHAVO`J5>MMfI&hG#;K>Ig zb`c5jr8}vjMO;)DWQNT}QO(F4IMD{2MMushj!09WmOQR-d8o30Vmys0LO9LX#%WaK z`cRADPV+qWF+xM4%2rUn-i0ef$1Y_E1|kena^jE#1V=$UX$3-_7m-UG8q}#l_s$W{ z@bBPXQiKv2=0zTU5E^ujP{LKCx4KkD8A4w`3J*erFyW`zGFsFt?xFM|new3iKcqxi zL}$Xe_g;M0Bj5eqcYmjM`bB>C`42tw?z5>#|2BFa=N3Qo;oHiGMjn3VBWKQD`%hA& z#eh8M=!YJ1D;ZUf`#Y0I!9l=JN^npr@93~0a^)e@U@sY6(}Qjbgsnl?BZM?73ub(V z6NCtd`Sd@k&paC(R3j+#F+u{C3}H2rf$kX4BtPivAf14))djuB&p-$f(%u>c)6;BV zkmn~`9)t!D0bwsRm8t=7wgTap2>=M24b+2@#=}JxAUxU`MdwQez%d`U;soOb2~W|>|i8`9~|3B z+;+oZo_orJ2c@U_;6W0C@T9;+=r%3YCxo1eF&6$$TSkw2%jk(DK#pLe>vtaved>;Y zP-uCFK_4SwAg$O%rN|1HrPk5s+MXcJVvFrsLdo|ug)l{Kb)CNhgd6NqKoA~!6PHx{svJEDf)TRk8DCj z{fjo5#tM-rA-GQsQjOFLBL>3|q5FKc;Hn(HS4NXMU@0z;@gkV(lms0+myU2bf`NXJ zR@NF?1lRIR8WKylgCZcI+eK_j%S+d#E1(>u=VzqW4?9Jr(4>*zcJ7a zPe4#FJh_l!v1qcsG zGKO0gEDjb3sf|xBN1kt2)qbZoiZ)01P7Vg{fUr;q5Xw1j|8`+4Rq5{q2Gv_WnuOv(^LdOv zlM{rM3%H1B%jy7PE72n*#ELlCdA)llv7j5|Q&c0|q2T#!3w5s0!m+|bR|5nQp9l!g zTA1D-81`}z!s~li|L@;28Vq!sBun|Mb>tmIHt$vILJmWSb}0=i3R+fNidHsDw*|j- zW-&6KLN`9%eSDYt6z~#OFlzIwI4kva(w@X2lIX(Q8i?A_y7{wuL#uUw61~$4gcw4E zYPVZL$^F?rJqFR?45>NcSXrhIu7w8!e72Sck(Qxo+bl@1YrV!Dd-RVJ!P%IVaGuDSO&sK_g1w@A6 zoM>w2lVj>Kk3B;qT&o{CG z!u`hvPX<4*C%)B7N8jvRJqtP3)X5>cg4;+3e+hIb4TT>Zs;DxbRW^f$ z<&?4uV5%q1?53%kih{W10Q+Q@N*wEhki_Ty8dgH&^UuzEW+EK4gVjjvDc>hMTqg*W zn$m>hb)3URg7Yd$KFzYKPxJSW^JV;GEh>b^^5_5fZRH{v!X6;hd((Nti`grxp7zcQC@{!7qOnvinDL-|NX&dp z;ta97=Y9NxLxU1%a6a{WI{`{|M-gWs+Enz=h!Dm$?2=JtnDb^;be?(Vr%rpdvm8iWZe%@mR$9BHM&Aua3x;WYc?7kYbq5Sh)Q zwj1;?kswsJPdLVx^OFXUUCsJO6{a7N_bojS^cwVqh>p_`}b>5l`h9c070uJXrbe2i{;o zu3x`C4eH1=mzGQ*Uny}n3?}TUNB_Dl5r$S?bDYhh)S!0>LuU!8Z!fC%U4sKL)T@7z zGlXZJuaingrWinVkoVpP9({`N;IwB5PkqZMdol|!AZaCsw>HM>>q1?j^Rsx@??zC6 z&GYTUx2)CphON?-%5`11(78-1tR1cp$hX#sk4m!{6*Oxuo$)OJ%xqea&OM$+Eg)D1 zEJrrN!JTG5aF<>ZMg+=|3=|BuG~rkoS}oB9%dFD!*h3KRdemar^^)2%?9`HGxHBSC zfiM$9cM{Ruyc$u1{=8hA5R{#B#rpr?P@b4k^Cyr(r2ScIUMJK{@fu;wCZ9h-WD*yOg#3oBv_D=N5u#KfW&6(!34d1cMkhac=y z%Q7~P*f3K9ss12K2B#MQZbTVDYwQozdNZ^U@NYJ`D7$(^Y~_+i7Kn#z67 z5VBX4j_XJ7e;)}Fx->nTKy!G|0{Phr_??^1=tr;=X%^ChS3VaOga&yY2ZSj%;S%*q z7rH*-yB|-0Rji}@{L6Q){Ci2)0%4HQFvz>X!TH=rfI$+3WC+EBM@h%IEs2ejvxC`3 zstuzcAn8HB2w3Q5BX6UtG(A`;3F=dZP(RP_3Ito5;hJesAA;PM@ACc|MvH$=3RXdK zd=Mghi++kMAxQSnmeGCE{Gh+^;ICjowv8q`h@1q3$&PZojrtakDojrbam^6+Qkj|- zS%6UW5X_*QB@~>Ung`uX+xOC}LLEXxKuALFp-W|cNF741hGFNY$gtPQRKkQ1A+Hc| zr(YsvcxhlzK)A@)^>m~UdE?>Zq)-?zy#Dn^^WcS7=`I-t(kxuiNn$WJ$_NM9J}T91 zl7mN22!x`#GXkMglWymMSw8W|ZKEGMH4q-28VJvq-3np6=yIVQ#{Xy_BoA0D%ns2M zSIqjD32M9U4aJs)S3)D8E7Y?w({zOzY4>2|x{)-Lb%HdaT1|^_Ju_G}4^lHzXqPEl z9A#WHqfG@u)UsiccmhC=IYVf75OLfZ6U#eap-vK$KlJ7iGcS{hjf6su2r(=ar4)){ zjjm9bI)f&|BQ}O|!3-(F87NCQLeP0fiy-y7Ha*wONLQYb-37ZlWoY5FT`$wEwZKTAU%|~+4;#3yWhIe&>PrOg>f``wU*%o zfONFRKz-HVMb=MgikdZ+nd6AtzcOwLOej^zVZvtmphX#thHkJ{<3(>%ZY&U<83lov zM@H8OE1jx-`7UoPu6RvuFL(DH@~KieDr3 zRFGi0jQCsZLZk94tK#4%6ibrv>{rULS=6}aWT6YrivYPlRH{>Z&+u3X=v}3rkhzH+l=G`+sZ0io*6vs08-{f&XY;-FL+^hWN? z|4nXuBrI6D`f;|5f`a4-Z@Ow>&kv@#$z3tQJfIH>w5=5irP&D~;??XizUf`Rt2i?l z7~Btp)T#Hw-ce@^3xxZnFi5ZN-S@Yb&uoDZB!mddgCWAO;D}lYTSou#-e;#Mh1f3z z2}esgiX&Oei?@2AjNS?)m~WqegZ@?&tkO$m;J~~Z-tCPtP$9<%_W+@YaFY&{#pMUp ze3fP%1nv`z@@q7Q03p&0;eIg?A{-tZ+q42h<6!cGj|D<*iqRoL_KivvCayr3T;ldp znKVrpT0adCQg?zggrMk2H3-}FlF<=0Q+7f?SR51(D)|QxRwhG8<%lDZD2!|F3J|VD zSV8|Ooqlqrzu~90ohV-~5JG*uM#c&fV-tiIJ`M6M^0D*hKTE+0KL|wlG2lR#pdg%tfo!an&De!)zFv~^xC0j z*cs)xjj_{K*>jvQ#-b!<)COiG7=#09k=`MLT`Gm?Jsy=1&+Qh^f*!bry`!KV)@>0% zJF`;f^U-bUR}j10HiHpI9fg9VC_pk6(@_o<-ZFd$D*ut50CXTI@@ZjcFnZF11 z%y$yX41$EHWC$IyDN^qV@_+obrNQnMmGU?s9Qtd$2`31pz5B~&*)yv2;Cxo@ylMB3 z)odRv5Q+pX%Z^dM2u!FJARJMKFbP03gW-sWYLy`z6G_DP8V7rVFtRO0_;?=NwJQ)t z3WVY^ddYN37v35U)U%NX)RasjbFE5`vH+pa4x+5`778Cy32-{eCnFGF1#3NJ z#Mm#*QUyZo7$rk^u%02@mV1~Gj{(9kU|S%tQn_H7n*`xJ-`$IWn*TV`esGa{^FN~Fz+#E8tDC>r zDr<3XEkt-qARK5r%a&2S0W5R`wVV1JWb%N;lXGXZU411E9$K)uC$r1`nr<7{Yl48>P)^zUo#qu zkdH4zSD<$d`q6@nGYT2X#sosjVdyM`M*~bnz~=e-lnCj!Sw_7?U{}A*KE_>!M1cgS z<7nUk7t3&rkaF`05acOGuX$t9SjPtseannqs#K72oG&>-(U%1>Y!WbUP!X-eI?-wa z@5E&CQhvU|6bC#@Ua$~H^N{j5M;IW~qed2NARl721MeP{ACLJDl_6tn9^r3;xgoh& z=4Gx6k?UbvK0-|>P(yVgzH{|!k?X!2 zGlT`c1?%cLbBX$O!LV1Yi=J_@Y+YoT2%-QYkJygh2X&5bZU7yOeg{r6V704dUnfuZ>e{LQfGp_ zqFgk(yJpl8Ei_>bGQ`4$(Y=xbgtTM`UpXkKZ=QG9)&${2m`}IGY4^I2g!K{5P#f zjfv8&qB321cVIA`niDgGxnEQb8%BMG@YE!@(SJT*`0h7tYfoHmg3v9Q!qT9d5jDa< zK5ZE_5Y96|s6-&82?qpsL^$d^65Am4_DN)J;E%Qz-sS}#bEymvc+l#90#Xu`a9`!s z4iz2_U%9^*gfEEZVN(oN8M=tqV()S4mAIpQZC;*|ivTnsD z%$siSbuzDVVTn)l9N|Ny(~sFM>hC}=#|5Py=|vGFJ`oUl#WT(4Z^Zg-!ID(|X8W<% zE%bKk4B=@nl_BNQSL5SXog}YYe-p=9BB|43cNi?g( z4IxTNF0F!RU1QnGiFQ(j`G)PINM@vwb@Q#ucg-$d2VZ^kwixW5HXmHb`!woKfO~53 z3knTkQhM_Y$R^paA@-nO;SCGk8mHY>s%QwaJ7Pi9Hj<2pz>y83v)hyFML7{D)42HL zo{n^&!Zl-9P=Wk2LVgNYXlYM24kk+o69)KTv#r#MXN!(i@km5^Wk@&=mHDnuVSjHT zWt3?>K{y{xJ5Z5aVA#zHI7qLyw1~IhM_*=iRGk&ZMm)+9D!-@C-8od)FL}U`u@eV0 zYHA}iOTd`rblGVBBH8kS^Ri(Slf`<(ZUXyeK3&UJ;N1^|$jMtl*Fn_E$JDOHPEC8_vuyFSJ_RZSx^tVQ+U0Ct#xuyeLb%qca3fbTf+;WP5XJs` z$$A#yk4lS+)z2W_66#v>QppgC{6?JRo6&g8dgC-Y4hMQ3yG%(YDnQN`aiS0;gaJp$ z5_-%?+Eb6AL2yuCp9?$FU%dq%_FaA?@WIjSAZ&KpRweO*E)ST{n$}@k)`$ z6y%Av`<;0{6*p z=AGx&WC$aMzXb^+rNTY8jB?GW>o*w6MjicN1wwfhC?K??qX4r&OuvpGcZ24Z(dpPS z>a~`_^jb>c22rro;JBV0bhlS78QMPjQ)&>NEMJdY3J)IS?oqd~Szw3b>l6M+9{ki0 zyr}NN>qriMb4{>8eWpPEzBmR>yUB4VRy0&T(Uwsye8#d9`f+MrIlm$aW+LgK1kA_>h%5_f#;%g2t$}gs(ziQIWxJ+%?Dxs;jfF-ROixVl0^@YUFzqqJzO$|OQ!AI{?f8SWQV{%>0MhfWom0w-`Rg03)6nh)#W~Pk@6Om>K88Jn{BgX9kM88S1r@&1ZD;b4Vttq{{3L=V)Q2gdHm@&7wL^FeSsMBPr&0nK2CN}FWr4JpPDOQ+sYO0laa_zceO_cK|>9;M0jTsgd=bI$hRUb zM!30Ubefyx!KMf=lI{!U`33wyLF|BHj}}T(xb=_s^k8q5p*t}{I7pZZgi^$Z+A{j( zGj17W$EfAnzyHJaazLVvI1F>?3z z?fajH1hZZA3%3z@HaIAGuO09C_Ex@n-{#8k5kH#?Wu7kW>7y#6cbBpg{vr>ER&F0KCL%5#@dxlU&>E}I-bjzslZ>>tWSPtI!QH6i=5lDD_ z@}g|{oQm}+Ia;gH3khgH3Df1e`UAW5$x^oU`JoBAq_U764hTo>PzU5 z9GgVR7Y@bE&~^&WkJa~RPm@%kyaR){41(0Sw^1B8k@Fx@w;-0!!hpBtYO5hy%TPdk z+hicPs7Ag;2+jTIJxSQ2%A>qYRA?Z4N%MqF3vO!E7x&y}v`7Ows77fC4cIR_oq#U& z>M_`&fg=opadM#yDu)qGm@h~L3JGur7n=T5U?M!{2J#!b5`?I7*v?yFqI>M_3xrF0 zkP+IG$un35s@s|uG~`|Vfx=Q@TECQU(1$I4`>Z3ZK>$J+@he+CRIbp($EHu*?ptdj zzophaQ~24LN;JWw2m8ZX8Y3h1A=fht{OH?7#dJ#l*ahIz#voksh%3o`>pA+~6Xa zW(c1-&LP2K;)u7J%?z5){p+d{&Z?k38-{tjA8Gi`J2lZ?umyD(kA-Gor zgqIj_bWkoO58rDBr1^v1>2JyZ-sTCNB0SFg2TPLh{m(|;)`Xyi@)t-e(gT6;8Y0hB zBMb_*@Pr{R`M=$Fmm(9bMgKOr38PIuF^VL)Lm?1hZ=nWZ$`GC)0z$wL3RKIZm(*am za9@TnH7^cLuqOc_8N%E{!v(3Aw%I{v+j1Q7egB>#{N6h!daRBx3+q>5v*|=`nk4h1 zfG|f5FM)%Unvi0IGE;8~(~}`&D`ky7*@+>#wHQ6){Qdr(qf3F6PkZy{fp>%s5&B}8RjyxfwTTIwIP>7hp=_y5NZ#;C zfY7wL0HHus9H-}@EZ=K}FlNp)U{PxL>5t?36Ioc6vf@g5IGS9eUBcj-5Fo#au>Aiq zLztgmt4#0~+Sb|>WkD=!*Yx2^Pk}#9OBToAv5=B8OO0@!J=SYWkMt?<7W^zR{Zo3 zq3a1keM;s?%2M4K!C!@6doESy_-~11eY5k+_O`(TRiFYEXWui z4bcwMu23vB5^EN$i;q!POeuPcyD>do9foa2kL+OaO`{I;77_Q?p;*o29NFP{&KQ`q zAY_LwYikuc#sG+mSVj{DO5%HggiWIg%H1)|1gfX)r$k@_m=KkKx_TxNDG-+X)+=IG z!AIN@xTm;8EPEwnw{RmywKQ$(U|xT;nZ?kX!vkSt`7jgA9WG2zkSvPI!bHm_RUJVv zpQ8vTy@z6aWHRI2PNPQ>hy@8pWGIJDJbZU18K9d@Iy zMPvkh%_x?6CvrRv^cK^H^MZOb_5dE-$bESf4lQmsj81|uG&s#uaR~aReXEjx@ZLq4 zDg%TRAyhZwxI3GJgZea38z$72(Q_|+<_o8J#|8(--a3Q^Le(LBla3H>vV>PZZn&FQ zUGn+*AVj2FmwJu+?_c(eUe00Gx39^3YdulCGUpC@MsodYDP=tSPgr5qbXY%{sd`#$d!%?ZMHiuw-t z&uzLqUhG(a(81%o#JJatl3^sT_HSnh4TMMCGHM>A@hzh|cxdloiV^NF zJF!nL%@U_c5PrQth+H^w^+W!nx5NaPD$VkV*El&i&B}wvW;0hA-|O z4=vAzrFdj7#)qAG+_B znIQgg;b3GG_J3ycrnTZ(r7YM{MYGz}!%%ORjDnG9MqTuI^MZpdI^ix-(t`&Mx2d8sdA(^~p_$EnM z8(Nzh6-;;mvJR4jONQ{Q8Np)P{(E1r(7${oy>-Mam~By|FHUqu7|N9%2K+js*|(er zv<54}YXpQWlOBw>9LMj0dl001O3BwXo&aWGwwPLURk&s`p9RiXfKc&tO76jahCl(| zxLkRj(h)vasMKLHg+{|22hPZI%%TRx=|)#4z~i-AAbiKlgHOq2|1+NQ{h}qpkKB0C z+$aMJN^WGYXsGZa`9T5S-+O@20tT8EbJ6IH(BQmMN9Za7!WW*K1mS#d-SFU4pa%%Q zuNNeoI)oqG>=>N`gq#e7_VNLQ*wm3Cq(*)4(U;C>^2Zk*%oU^hPA)q^;R#+&5uOBu zKi~AAAPw^6_vYWT0fa3{Z@nnk?V?DxjIv=AB7CpVPbUbGrUun-)N9Znxj}uB_joJm z^WGL51IUzf!&q^|cG;|WRp~3wEh{9%*?RzW?(h3OY@gLe- zI4L-hN`!XV_iTU=0SHM1ii|^{#!Ai)qN;l&5FQy6$q;UsQ04%o>JaJ;5IQ&13}L`f z>zKaslLzk3zGYM=2%R20fe2HRFp|`u`-6FSw{q_Tfpd_ z5DYxBa!96-8ujPRWFLFI1gxvP#@5L7hrC!n<_l|9Ahc-t!_O>)Mjb-a|9NPRX!oD^ zG%LWX!M8oBzw^1A^RZ#e_DX^^S3mlH0>anKPc5mzT3YgAwK78hD>pWrCE-)yVjVpd zxB^@Q=+3ZmGkI030Bf~mP<6=;1{rIpTCl7oYP}Y_UaGOaRG{u$=jI*J9sp2!a3Q&Wkz_L|7+O(V>s==`qKgj#wuRCT66HMUPD zGYiNq9`xJ90!)V#`LHpHl=q5{4XHgL^esDaBJ{zErwFP&z?G(sz01H$4XlR0MnY+0 zsDN>1y?|QDaYKualwgL`AU?mDiI-epD^(CzxU83apG?z?-p!{Tv>JYkRP=ID4bh~Y zakr>{nfei+o_Y<1eACQ`i$>K7^2CU(TsYsv7$JT75Qo;05A>Tx!iZ+{_pCfP&=>Er zVf12ZPUxHGCLG8kxi8j(2>)h7nQ+XL9!%@Fr%ZyWI3ZQUr6KPL> zsSF(04Wr2CkDq6G@g_r9Eg}}ATZk|~NMU;RgIZ?I6q_g??)oo;291Iw2p`BZ&t2yR zTZQo2vn!WxeW!AJ)sZ+&T^k9$!X~Fa4O36Rkt| z-Y<>yvYIccn|?Gm$mQc5Q5t_jma0S8niD72ok(H&aA2U2wik`=mmdug!K6mQODFFb z9Uz=YUVoRibqG1?N5s+lKtVMELSG_k0pegWPZ*aP^&THBK)72oK-e7NSeN>&?u`OA^dc%m!$QZ43i4!+V zAy`ounDdk$9DTTM4Ac?S$wKmg62&k)f8=GUKtnBx>CA|;1eFVcORd4XVM2BE5$o|6 z+Ujj|!`T>0Rl?C0pcD?a!^2XYZVGg(m&Uye)E^8C&JU3!jL2S;Lo4bP72~7JsMfmF z0E22MFJTj# zh#N;oWi)<0!a6adRu2opc~X!bAgo**H<33!l3PZnkvuh4Sa3uK39l+E2oGAjDi$13 z`{A!u(t~~~ zoE{VtZfMX6!rUnX5Q+w=Hlepe1~8#*u0gm-6TUx1DUOpN++8#Jxss%cRb2kUcaU49 z%xj{+i1cf!eaNit`UC?3BTP?kMTwofy=lbTSdi`(-7i1UXJy(!zT06?A>pX3dwl8k zPEb%(3Idhkj69B67JL>gFYY8AwXE}(?(UZ(j`s~R$8wCAOr^i!UMK~GHTt4q0kl}Bsp0itn>`w zULc$V;i;||1qus-T%&O zY=>xO_zp+>B}B}4n)&OTq9abUhIC&1R_gVqT%gl?qCoDDl1Ep?%rkwK6gWjg;@qmj7cP4Q{a_}(ugjAkr-HB1U1+?b` zZ4fW=Fh-&q7-{@f3WO(9US&=DogBYEe9ME;V%t;PzV>pSI>?SlEWlP?g7hY&KAV#k z>>0x>9}&cn1O(O!w!FEZwA`vtheLyfWj7=eDK~+HMX{bWM<~W~QTfq?cE&l4ruBhB zI8UIa;OU(wRM0q5b8zM$gIeFKVcqdad~cn}!Wdx{L<;Gii`JO~&T2+v-5;@?w* z{Ihghn7&uaAVxv;6~ka_5K@P*xj^Bas7$5;UC$KWe*bVFuS*bq{`N#tyYOvdLA_L^ z|NL?03i9T??X9D8;;{0z!l30rm`^7ewI*3X^>k=S2Hs9RiOU8zd<8;$ZfGl|LA_`r zZf;ZfFJ}w+T|I5gRaQf(IqL^P(L2&{44h*_m%?wrTSPIJ3tgo2mkWgZML>9{XoyZt z1Ou)t?MT1+eS?MV7Ea9B1C?rW_Mj-%9;0XyqRtApEs?uo*%LK$!I4 zkzme;MP0jC_>PGuSxoeeI zO^CvW3SmDrgI*@VX_L((30QffCQPua+LJyk^BoyUjG23ZaGnbfxjsg%d^4cemE=sJ zCTY1>8GtUFzVyXuq3${yMP>T=_?iEUeD?NID9CmgZ-#Xe<%`E zdtgmbGm~-@(`$5asN|rQR0KqrVuT0)S1@A`4_(_hDkVp!2i-9!MtzLuy z(@KKwGMpoZ%Z)O@J~Ug^Rv0Y@3Tv%LZR}NjL@-Id)*jS1l&8<0x-`LF%_9!yIq;F` zvXXP)0FtTig?^g{)N5&>`r$#jut?jE=wg#QN`Y{>Sd>u>g|$=TA?SljyRn1?F*4fD z-ZCJv2`rF>*0y+72dIY)A&wY|OAu5(O<2g#k-2+@xp-!3cu(ZT0;9{8bBs7+f%srS z&q%5IuI(YFGv6tbYV@K#QiV?a0fTxa0=$WqS-PoE1UO^Nj*>+#;hzU4!nF$F-+!>m z4bBgayhGW+v8c1$mxI)xyBkUn>XoFl1ZC+bP500xqi;>oiScXGC<(%O&4YYbo(l_> z1hrRGpMX&EVe{bMjf3;t`Tpik8J!`tyu&33zkl`PJsc>_xxd06=|Bb%MaYDV_Q(rm zc%_{Pgpyq)!sojL1!o9PX27aFJy^L*d3rL0(4aov3i-ilsXiDY*)sY=eundT#4j~g52L@%`Z5*|T z0|7x&h^8(1;I4%Ade8!2`48ZuNska_6N1JR#}x(9YhzF_Yy<F<>|3Vy8ROpe2I3CZgYQh(h7na;|Wg`M}>Q3l)S^ca$2;})b zP&Fg2APCaQLfr|BGn$ru8lj*YBjuo;!BVC%C{Dfb&78NXNv{iKR=GNYaDY%BQrCU% z7=6khNRw@$zANTE7vHMkM&1ex>ZN<;J6dr9F#PaycPu#nwge`mw}5*0#eVM)48A~( zfN)-YWC)W>^g+U_Zvyzvshf{vAn89w8bg1aDAdrAGCSrb47leM;mJT~3Zw|((4gK? zo(~Y7@@5$;oF%N7G?mQbQkf(O*Aj&DEfpu!ssQ0GNoXL1L zFG)g86IrTxr*P0bm`i0es~JL?AFAR5X_|4X=SR0dNOth?TSi~MQ%*ub+DdCoRCMjB zI(CZ&2$%h$lRG;+GSc`q6;mE*K5k>#C$IU0g7ugTIYu~h)yrtd@siYn4MdLjwyqCA zqogWVAas(!==IsH=Tl>pgFIml^UaN_ug8Fe?-Qp7!c%7mSsFP+|GxoYvwSEZAU4MF z^Pw+wCn78Eb$J?w4O*yiy`i*v32l`ImK%BE+F;j$bt_=DZGrhZJM1#$L*81LvIWg# z18qkY2-4drl^o%8(n$rnKd?h^sLtkY3vKofQ-(N*Xl^k0$m~pB@1kK`GjoY#R>C)n zB-T79iZl1k$Oj6g>CNe3nUAoD`zHQq=y67x&t0Uli5y8V_NGxFtXaScwuKLCVY~v~ zV3|5Tlq3i@btn2O4b4Oj55k3$7DO`~qJwEo6Hb$?UIz>KIiLoD8`t0p-sj7+!@ArW zs!z|kbCj(2fDEB@TUvPnm*N=V;qR8*E0a7P@JFgphY6GElMdv`6)HrD-T^I>*Da$@ zUU;y8wpi9%V;UGnTT!$A8m&|XwFqbxLab=y-H?qm8YB)^Z`Q6 z)(J(*OmxTS&F3g3$fnWh1cbEnh|h$8Q+wir$`3NUlk^}|=q;n<4&QWjE*S02qCG|E zA@7d*YUw^w#-oxTT!kxTP6oo{1?S@wq1rte!q2~J1RA{jfH16a~K9?i7U$b%+r6HC-nYP$arQ$l*b?>Q5n@Av_N99W_bEz{Bn*P2P|c zqQCCAOenVQC57-<%Zq0z{fb_jSm3BD1PJ$2p)-UcuoVXZ!G#9}gKoA!2&z(bVstua zSi?uvAv^$#F9`$nTBbYL!R<5$?`%~-gBqcHOfrodY>c4eO99Zn%W3T(DqXq$^{}B*&cP85Z?^2oT zxlOh|-D2|lrOxwKOM+1wmtylLth6TWBIUv2B(??Ivc$=M66vKe-Ix4-Tqb>OrFs-BZ z7aGdPx@mLel=h7}cCgQ-=Jm>|h(oRzCHW^R^!6CVnUI4k-Lb}TnS_E)DJO}`8n6EMswN^`f_!0X=r<-R1!c$4< zjdt}c*hbw6lvvh^g{X_jT5m0-%PxS{U$Bo4BWoM zV5ouoSinF13e^mQuf3C1elN)IY{CUHP~kkv-AQ*@de@hjZd{^0ySzW- z!c%u*M7q354}yb~s0YY?aDo7X(;}5CvuRW{=_{^6c=b6SUpO$=jQ;ZP!-2>kq4cL( zfsm_3C%;H8uqY@pln&)tuD(Nq8Ohj7W%O~&D7bj;O>ez_E<3sXj4c_#-ZeV5sKoYl zb8{~bM&5nr;>{~>LhfIA&&?0sdDn;QeV1~Bg2Cs{7ug4d@5%*SlH+R+K8#tZL0$y8 z)(jy)2oDwr*(y5X77ipsC>0_kN%&rpf5O2RyF*dBP>>j)5h5Ppy8Slq8Ra}+oGwN9 z{s-^TEuCI4K-i)aw+O9V`|7Q`x0hQ*ElLUMBgKVIgG}nBJ$!~RW$8~4 zZj_N2^1!#Ss0J{bfv!|&6^-EjITS#jKc7X>qa)b+3-c5_31*Sg6sFHDqv5_yW)Pd3 zig9oOLXJc7pI@KL@BNdvb9>pfo5J|}WZAr4`(5FXT5_P)SKn8JuT>7LlXbun2RQI4 zqGZ>HJ`s^q%6}q6*%Tp^?81Rlr}7^d*Y#VM%l*vfejl&hxbJ(;F~=BlJ}^G6Vty7pdS1wy4B^}hLd$LIw|_nbat-|Bo%*+*r;gIuZ&@jn5=P40~U8UBCD z5Wb4ds{O}r{Qwsu2(B2{ZJ{F>Fs9>M<%}KTThK9Jc__#2?J8}Gu%;~lZawwZ0=&gj zD@M$VG?K0hWhD==M`zTmpkc32XyBtPeV7j$UM?C%x8z@_CIky{HMMlSzq|#+9tLC< zPD@pSGpv!;UWD-_XLEIK@Nfkj4$)DCLWDSEH=XNC2nU6R6Lt_eiu;BJ56T5<0VfFw zzmijph!7OpKw!Fs1l<(q>a{gmQi0;XPPNEGOxH@^9|ixAl3?{nQBGu-(i2iCE59K? z*%IEm6D7q2r7I1l%AjCwv``^+16d3Z3}#reK;f((^AkOePimtmQ~~K9Wp4LY$oF!NsQnp z=$2J+F>IAWP6Fb?!*w$+W>pO_W}m5S616O&2>j~v>so<}WoT|7{+WkI2vss-8gZXs zE}KHfHU=LaRA-}6Py;)h^rIHV1d|gS6*hzhQ*GWpE*L#8vEwCRVh@1P$;NoMD1MzVFezXFFa_=r=$Ug+A?Yq)H@otjIz6w@)5#6 zL|H$~r9+7ZTp@!WBlgMbz4s&y(u())e(HIW=W|eSdTI?mdiR5(LN1kYfLugXkKV}3?Mv&erC>Ur$AqE9)1B4<$eWRUW!4jbFhB@*NYlK72@8(33=N&KVv?eQG@m`0$Y7 zgX8;V4lZubZG|fk+DQ$GBOtp>T0@g9CBIi)dD8gi!~EYqL#U;-ep7V+M<7HXx;NAHgW4!o;6pir*@mlX|x+gTf-9+YwcEg z&aj~;=aw!Rpul(Ysf_{w%5s%A)#xRTkW3J2Q-aL~ZjTJcQ=)TI3M}uTXBn#*91@WJ#q1X^{4v*qj|zi8pSFjNVPq)qQ}6rWd#CIKrUv+hS5i~1@sbJo4`>j)E>mQwQripJKx5IGM6M3nTJ7R8Bc8)-3Wh26!MdS zOR7!W8U+xv1~OsmNxqCwmki;^{`tZ7+%XCeCM}l~UPsyU`Re_W^k~=MAdR{O2=x;X zN$Or02dBHU$ANl#sZ4;dNH`*eA<`p+r-0D^_$gbVXOxReN==*-NDz%Ka!6tNZW-l- zV(SnJ2j?pWy78Z&@)XBq2w#2Y_EgKbeS2PYw|5eS2y*AyDKW1O`Q*A~6d@I(X9oxM zRo?VKaqM&@H|N6gsU=}V>}8|7NH|lNd_ks=O`-0V-IJel;!w`x`(_@)N0Yb`1 zjQjIP9C;zpc2N|%*S3s)3KQOF%jlt@XMqslgqHXp(#-vz%d1LnCu#|{snp%N6IfAuP!?lkGJZ%=4Z;ibRmGF#(qh?;Ks_eI1S5nm(_BMj%3?T@rgJvFym|VPc)e!1~-$VX%lv zme5^dQH+q?DT+!diji4i8lbJ)MW+^_anK!*DBEUpC}z}1*^joilakz?yQKUAf+I9D z)p4Lr)a##k;)(lDoRL(c@21hO;W#7j1dOKHTkGG6|n|D_XfzyJ8 zX2Ju=2MRVtc{ROsYEfL1A)Hs;Ze|d5-eb|vaAXk@OHqjPTSkAAI)wU@OM~gEE^=ba zXkqNxD3=$_5T1uZe@Pcc2E_z~N0-VR0K%UH3ia}v)8uBIY#A*LTJBu;Ng8Gs}=h8{R6q$j&KKi#u_yGZDipEsGB z_fo+^Yu!&-p*sD|My8v551gamr9eM_tVeF)A@A zmtqbBgQFh!K?>7bMuWv#_W_l!(vV0UHR8`^QF>_zLmGZaU40Buvjqx~rtemBgoA!$ z1xX7|s}ITDqMMf{Ui6x3Pv|04BMb)ihS6ay6jFw8pfq}Es0K#ce9V3r&JtAnH)fQF#je5$<6~!J+-EArSOyY3T_WAOF~OEC>!tU4ig*N5=;R zgn#jV(0eD35Kj7#{h}#L@r=L0f-OxiO>N|RwDnJCFX6QG{yC_sqp17Y{nM}2}6;ks$` z0Xab-pk67>6*fb-*)RIYP+p5jNL8B=Vq?eXrbE+YYEMviLapS7yjHK3@8?7Q+`gmN zoA6w%DeQL(03>H9Pfq1+ir6og%6$9GmeE2XpX8&5hYUz}keW(^y#odyM8tm`l?V5k zP&Bx18uh_MHMwy#m%3QZ##GuJU7 z?~)yyH{vsdlpqWcLcRduPnskY2Yb6?j_=K2LYF#(cVwP#{x&JkJ`IE92ocTjnl#*E z`bka@R$h|~;A`71!tNApc|aN)XdBrMr6YEUVnCt~zPUF2yvUXa{qRcoA2tJq^N=T$4H%pzNH>d) zI!tP19yRDKN)b)3l}L5M>AeP%;)Cqydu=F*K{A9M$Tx9P;XxvEvy2ZO!h}r_(##wD z%MQ`eM1A_gc!Wr!O&ERn5Pv+37a-iDzJ2?Cjlxn~jYYLxvb4ldEG+%ClPS)2`g+9+ zvrC8|9kDcR&1m>z7qVpuyL$>y!?I~gS8$3i6bL>_Bf?|N9nBK*?5Y<389X-;vmg1r z6qHbcP>d(!Gu+uVX6Yupv`!>CSvaH7Cr(iYK!akc4>mU#CS-Kow$X3tiGC#aa(vJo z%_kmzuH<#=c2P6ol&psaXJhEF;ivur2y%oF9(2O6IEWYsU+}AaAoPnICkTN;wfAyf z&}}M9_)=g@)8cK~aDec2M;zEjJSgA`VL33pwQG+Mx>-ewlNhv6 zqTUcX>NL6D^y{!-<<*DGR8%jKaYu{N&7!?hW&y%LpxW%&Z0*5)833fOVY6lQ*AQWV z&_xb=hHzNWOb7)Eu%;85qAj zve_=$IsDZUft}cj!NG)f_2g^MbJ3@DK20A}$T3d$)N!>zClV*(xGcS|Cgp!s9!>yZ z5*u$i+A*pLhf92r$5KG?RdU_z4B^!c>JT1+R?+@n8NxR}dn@x@t%RpXO^j8}%K<7X z9(G=^sL<93)b&?bs{Ss3zAjifLbs}gz>K?8t<@|T5Qe3{O~>L)pevF_wLpbDR;Q;) zdllyup&TouHFix~`tui%E7yhDXzHUaKxUbeWsS#qMg(t%46?Y6{*ePT`-%0S#8E|a z912trOgOPuOt_Ld?fh%c#iNGF(XYrST41rZsMY!*>nKY1i^c+3o{FPe(~31U7uXKD z1u5pRVBzi|98Oc;Qj)$MQa-HUZCnNBBfS?0L^Bo&_1HAp9i)l}0>R0UAoEeUt1zVq z+qOCPV7-;&}Rgd z?K8>g`q9xSL~j=K(Lst15-uVf(O+W5bITl!jG7WikVN5Fz(hUoyLQ_srwH|}qP$?W zxirQZz~}Dm8GRq;2b(9n`V zTc$u>|2~8JfBy6L|NQ6gPU%5DH4!p;>y}Y+glEP3MgxSc_dtFS5|nB(0|=WF+-S)c zp1Dp&Jh(6+%j?IfOh}L6vb8VP&7-fDm+FDjCO4?w>c}+G7umUG)C(C%#beW6?gL@7 zgB_}~5}CjJI76tsqsvZ+1x18tflvVJ*#?8@79hM+vU7J#gKiLn2{0tQtKlcVvt$VI z(0;Lw-7952^qQMU32R@Ty5#)fMUEhnUwrd%7(BR^rI%3@$!!x05V9nv11lf8I4}YC zl{cIw_t@>IwC z=DJqAI$!Cc{)Nnr#|K6`s93#UmHM#gp+SOmlC+E?<-i3lN4CGuCQ-bJ3lCa=JdHY*GAgaOTmTsk_~ zC(LuOdd-C#4?a35`m;yj(~Y<~{nd<(k<%chvQ7Cxo(xNK^lg#$Fr$QlC7D5&lqgrS zJn{bc19GoS&cvXTDnz#Vy_cd11f^wEed(&BbXcpNtnvI(pP5g@r-Ae&3u)CD0ejKR zh=%eA5sQq%z%k~s`oLRTQ=zhiCA=V^$Lpq2^u(YLcJoVRfW>olC(2$KtWnk0IjzNz zfghq8ho^?-18ZSM5wrM)KjWkp)ofCb8Puj1`Xd<#?NLj@W|l`AAu9Zu8Qbe`nkLku z^8%35+(Z;EwDjX$qFg0Iw$M96r(yG`bUo?>hq5Wg;X=!qqWMBc4ZuLRQ70NSq&vB7 zl-wXFsKNDO9{k%Y2lcXNbh%^nSC7f5TSVC~>cP7n3ko_($a9Q@2ErHYgCGi{O2H5~ zBtuAc5aLV033nqlS+UE;y&a?T+;t%d@`5Hkao>ChhY00WFTu(C%v~U4*91hU#-q2S z?!=KivW39hE<^LqKlPGPb-(9*#60(sZ2na1UIw*TXa4BcCb;gQ$$;c!2nB=s);!@k z2^Lm{^dbVnoFTkrD!Y}Vg7+tGQWin9TZHSF!E8hpQi3Y%T8d%wSUWK zSnzC$a3w;1O)%o$hFXfI`ZW0J3WN#|KOR6qT7S`xP*Crw3?WPPAHV(}fbfj0P#F33 ztTbl`w_8R5z1AUQ*J!SiabqrY-HW9#{qP;spqOcP@04kRupHQ|0CIziF)U9harooSep+kOc_sI9xrs;F!t~=DJ0F%Tx{$ z9^}3Kzc@p9@UKqC8A6S2pON)g=I3solT$z_xk`%g%6id{mOm{W{zBT*y4iDqH&$wR z=PvwMGCK^>E&gRn!ncN{)I^ORaiIy6U~1)J<8iQP__iK|-M%q?b*g z(v$RaE8Q;lkRw*^l_4l=A`g=cRF_Ed4+Km_`ZRvzAR=K!L&Dl2|5hEs^4r9o40(GcJaI0(8*CpUP0*ZnqF@za8A}CF2@iFD?SyAI6Wpar4;Xk!jSh*1+ zGV61wTWtKs^tsaXW;|%b(-bm;TqmlHpV|bP(}1oW!F4f;0R-opV7QPCq!Uf9(fH`M zFldc79jN%%>uOHuJ0reQ=CMx+@dSJCT7huhxl%?lt(9lEd;Ej!6wL|3={7QuH$bQ= z6MHyFt8QgMy(r2MYKQ1F1;H7p>)Apsn`uqLSG?eTr+IL`4>})cBIKAL{k)+<8y*Is ziX2sPLeGBLiIH3@llCjWJh^A|;{v~I5d{XvfcETn9+#!3+{7aTdjY|S@Q~|0$qq7_ zu;dM+$f9=M0YVX>9w;y*_%G}k<=dnLqpF$b^*%c&8qASFE_1o>1Yw@7G?nBMl|Z^~ZL`P}Kjux1DKEh4%+K$qkkXMD|;QFGsBlwOX=toUvx2n&QzScx?@?Mzh-M_m>vidihL{P(60k<(HTnU!A2^5`>^&YtaLOl$aQulZ8^Q z=y1;GE^HTGOG|fj>r53o;1Ek6MzLj}etUE<>sd0l4JU>5-)$F83tkBUC9+-2h-{#a z49z$#1pBly3E}c-u?!5p286->! zB30%&{P#jeWxHY`&vN^yzQk{7@O;^cVq(<_0Ce+%ht-RH`=K~NJv0U_CWsnn6cA+= zF}?b;i8^BaG%p8S7B{mpkzFTNh^~N1W{~5E$Mx}L)q*&nzAg?u_K#LDzJFcBb^5f%cdxl%lx~j% zTX*8I$Jq%w?c=A4PTXaiD98v9njar0Nhlh0*W6%~d#*ru?RL@JEVDsDH1`6++#srt z)_nLZ*v~X5Pc9u*%qw2;*Ap;E_fBITjK{7}8MtGV>txi8!JjoLDKmJ@8A7}D5h;1k zH&31-e1eNbLxF)q>)A4zG-1n5nCe7)&i4fiFKtJlK?}B;)nI$Vcgk!sgOM{ds9wsR zkRddaL4)9+wEIdWYP;wuAOHYC|Gu>J%26(&dE4=}(T_j-lK-Z>;sE}w%STd&a30J3 zBnVTA&^@Lu{%YuNk?Z!0LW9v(B7{SsLMl$ElfjMywpai3REE$(JSdn`gl~WKIeGt5 zb^^52@69LWWQMS31^JdOqe#n6EI_CXA!i3k5$c=Bpc|drTq;v4ydyId?0nt^1y3Xh z4S*AwWiM!yAKb`LiAk0C!<$cM2zjsNz>x3j3*v?@TL)@3sqhd=NB$69yDx`&)bE(Aspsr)H0zoR0Lg^SV%7LlHgNo zSPNFkAgN8x3noGk}SPk%{<`W)$&VdYk7#NGKGXv1#za|K%jUn~-6^`Q#+_;ERh#AV~qD za8mH6-?^j^1^*3KxN{#W3nDU(vr-rFnaoJomo{}RFiX`4ZC#sar3dvzq*8LCd!sBW zHIajZklz?ak2^>xUojCKaNHmuW87#JbDbo|2}6DM*d+vn2|Y4B+Ds#ZIZ_W(BL1J3 zg*%BF50!1$|Smd57uRzuqF8y%`yHO(U1Tcu7VzO zVs&R^>lbY6N;E-Gs7%1-L7TvP*;M&&WN|9&ZTH89~*q zmjR<1N9g?EJRjT35WYuwKT#i~NBKduP6YygvNR$`21_PSqiVl`gl|FFI-J^k7ix zO}nYQ2l7K6J8>U+vP3nV9)+!Ow*_gjm&2O*bKXQlPzi(V%-=@#t@ zRGCwamE%7vgmvK#Rw;4eg$3!(17+tVyr=b|=dBmz2rWx(?Rz_D$AqoPKb*Z)RI=hE zGqVQwi|S)e{}6kGf_z0naiAWcaC#RYOtz3%(uh4T*kgZKf`waNWPqD5EsM}~!G_Ss z*4f=x%1iImzpeD1YKyDG4C-7bkQ}7#4GC5l)S^>{;Xzj=)U!}sOHiPtWmqA+9U5&U%-0>FN%`4f z(n|q)I;9fvNivghZPcESP)&mKf@?+kVjnGPo=TC>UEBr2|Av(2{Jp&BxPap63h+V1 zM&YPl4K-7gV$wQz7LHlNq$M0}J`? z?%*KlLA{H-?_=34N^{rwKHj4}qrdvZy}?2GeSDB3gdIJXJ7ty)qcGt-_g=7bhLD}2 zXdbK>*fin2ZVnamL4g6lG4}t+nOAuD3MK!3wC&RzyA zPnZQOp~167Cq#qOUw^3#K*s68=IU^(w`9jR;%Ef$)@Q)F0tr|dW5P+|JElb9%u{-!iDdj zd0r)#1DS3oG#SVR2>q*<1~aA}_AZqP2rdRX3+3o%y>}Z23xuQs-v|gwNE2#dm{?KlQ@H`UEdt>?h(4evb zCc^&|2rsq90-R0+zWEpvH}~ydPSZmA>(w%xw3GBS;X;J11U}(}t$*SJmZI$ir;OFb z+W)1EjSF3N&I2k+Qlf+*W&ZRjAS`n>qlj9uAz!4wbN&h%Y%6N!v>;7=EjdB2;XaSz zZc}BAESWW#S77Y4h5U#q($P06>sQaL5@z`_KIZ^icA_&3i`_9AW#lRpC?v!I2ws^A z(cng5BNXPY0JjvO(5v_oUNH|HvzpFxw{Rj$#E+Tz#HqNBX-*($%d{HE=k+-rD9jrd zH<2E=ab^llj3;LAb~%U{4Uf~NNPY0F&=$2cSdz0X!$x}08GPL+`mn=zG)tq+`nKsB znf|}tBW+WFA_>81dPs1F+tgoQES1nP{VdD zN4NmtyA<)bXfSm690zUd5Ps?&$w9Di=440c$fIGw$3FI#zuwj1L7Y;2cJLV*$SBjW zplQ%xxPd|B%q^qBzkzKOHKV79@NGFXxRv3#v2&@+$dVyca!}tq;XsUe!;8`kiIg~ct37p4Os;$ znq&wUAfzn)nG7M_Dn2yGMolUFa{ux3H^21=2FVb<^*0~6qx7I=908%1&|0}4#zV4? zM%@JnCroa&(iMBY>;xp3+~B%p^b`v=2sXtW6A@Wx=L#T%{gm?hB6$lTXe&f$0 zpS+V@pz1l6NQw}`TQYq%N%(`uK3+8Em0mmIYB=-_)AZgL!94j%+giJ1N`I&dFOM#c}#!f(u<-P>p5JQJYYM3E{&C-UMLQ&^u{cjh7*7= z2XiB65Z1+oagdKRA&EWqg2teK|GTW9m=I2ssvQ2M)B{7!n(+)rF($%-)4ufTD(OI) z4f&nQ`qj8zLZcBx_soV%M>@BwZS1AUrQ8H32xD-DaDD|Q>{T#P0mBZrQsRrcM6hV% zjeii}40^X{Zw@v7%?R8haC8Nzw~$0k(LzVO;2{*cDFFH{>@9bP)`nRICdY?z^9+Zhq(Z?TW zESFk(giWI;;=#4#M7eM>glrkzk$o7PXHby#o@tL=+E$YjB%5zQ})B`_<87+eIR5by@S#Ngp~r}BH}|}@13Q=4G@ay#EeY}4ph1@ zVaA#-zXHRBm$o67+%me{G5W)EOvnjB-qQsL|D3|~5FJ-)LuPtz-PaL9aPTw`asy10 zg&*f8nF8T`MG%#>-#)29a4S!x_^Q&Z!xfvqkE}6VLO@kQet-}O z5E@?vgjAgn4XQnimH$mUbGm)=jY7f70N;2gN{wPyARNaVRAo@|e)w({28vs)LOVt` z@~V`*05$jTl{udtyg3eX)n>OxUj7%hjIJ3%{)aCdS1cnbsn>pjp4weG*JqSB7b0}Q z!zBM+v;U7+5rH_OEis|n6>_Zi%_rc_zga2c3FJ-#q0Aw@CLvo_V$Bp1$|y!~kF{`M zMc;+MHZ!@%wvUcggJ2zd9uYekKiY*2F=9X3EJ|$gt^R#HMemmxtXX3h>`&Fho2+2D z&@VKo?B8?}@Q>^uKDDF~TVGzoB<%`_8RN%T)3#83m#U}ELS&heQG~UbE9g61Im_o# z6iNaz=aNzs;{yd%bYfm=(X(@OoUTu!n8SSHL(MZ0BH&p|KroKQ#)h~J_iYKUZd@X^ zPQsWKH}lqNgdkucu#GP(9U}=s9UkO`{h~_SN4>({>g8(yWXPQ|CO+6udBSW8)z~(T zcpN+inX=gwGrLI{!-pw*Jx|%MT<4yq22}dC^d-lP3xqHK4>pJHdpj(N)l_prfW}JU zh*1g7R&*dvkEE-jXW8!h>|`OA?SzNi=56kU!WP_X(P{{Hn=zQK=b= z6z!HHH_Pz52(%%ZfX!xcjEVt;Pyvjg(P8d5WNwo zW2BoO8X%*)C0hYPt^|J{@)j)pXJbh{NxFPgO^+>0}Z+n6TaOD_|=Cc_L?2^ zmpf)Q_lxdSrB59~Hfpk^)O8=u*C9j;3%aQc=BW?eRwA$bodrv%SIEq_wtGK+C-6?D4V7kJX7)sBtvf?U-cnuRJWfYnj8Quu3I zfGoV>*ex3oq-=cQj~L+{wph_wHhgLvEE8@EQ0NRHSjf>qKUAc*(X<$to4%OV^Mls< zI<8LCqg|ydG-iF`2xdOQI&A`=m-FZ_pv%y^WCV2x$0p|hqF_*?3DKTX1&JEDUUZg~ zXY6DfXQu2qM>q=?;tFO}D4x&FKZ^R-9mggF`ws70z-F<*<4w86&1>@0>C7J$>4|a3BqPNv#|Kp=^)mN*Ph1Le~u8 zbDr}qP7vM&@dSLIy?gi3M<4y{ca__luGhBsmzRB`79|2;L9^FYE9&K0GNpwjb%NjSIRU)NN>?*&q?N)0f9n4 zj87GvI1Pk8UO3I=#k46<|Ct>atmdZU4G;cymN^DO|E&iK(JSfp8uqHwEms z2||ExSao6v*%YLg-vIb*CkfvG1j;fd)&~jitUw585<&CDqY8wd{3I8Q?vh~T{E5Nk z2G1){P{8$H0K!vcC$4lv@D0a@=(J50@VFEWCWrCq&!1fQ(+`t&oHN`dLe{u=@R~DO z(ePfk;T_1)*~1_ws}2r8PVA}ue9}4x6c!L|+Wn(TV6_fL($maGXWV$ubpMnXW2n`JIV zcrG{t4$5)37;O~p991}l3l(x0r_3%XBXFE}Qgk&8&m)H4@C!Qc|?WohXm(CG^9_95Q?%f@9i^%;CNeg5ZHqhjfxoK zlLIv&AxK3k`~@D7cCeTQd5d&CMhFWkCIf4 z`{aUAfYv0cWuQ$DZb*a=D^F<0D4*C*dq_$3JqMQz1CIJqYC@4o5Y9j<1u14ke(`9N zX!nVR2azR1$d*x%P*AAQS1v%P!t%p|qe_(i+m;}F?6JrGDjO+5K5$SSnL~kLD^4sK zLOlb7XUk5k`N8A56RJb#@`I^8IK2ks4F!f=H)D|V4WmN7_nbIG=C_v!{;lcHm(bk!$unm-3Mz>`M)d&c~brp?=DXpgn z1rCQ&qF!yoAGv@3J$m_ejn5#N5UzEZUXM(6V$pO{W5RNX z@XiJY-RQCW^tX5X2Y`ZVKtV<;5AFdWD8^r<)_>Yl?~i=(m*45i({H0G#MKBjCA3=<0^!=aq1-D8)3&Mzj%v}`e2qX$r;vhR zbWXH=?$n5Q_OJ|>K71;W?XTSW7% ztHRwfflT(@n z>{Og!UagolbL_Y&Iu8VHTC#(j)<-B^>5AgY_~|nuP%I zz;sv*bIh6^Gm;IYZTz4wznqldaG;A&wAhv&i8qX@6#)j^Y#KO}GsY;(9WoJ({ANI3 zuYgW@zZRjO8Q@Y2%4KV4&Kb@WIX!~_X^xg#0nR8&avZ6dL0%2`1b>VnLY{;%u>gr{ zElj`W0~bgsLbeZV`LGcY%kG) zeO?r*lgN+_pG`A12T6g7A}O>;=FT0*8(~rMi4ur4IZyrM4MgPUG$7zd1$wER9ke z19bX^+91Id3%w=OGbT*P2Mmsy5)`>?H1+D$^>pCwEu;8@jZxnN04_iX4Cb9cpv{dl zgegtmYepY|2e}?bIna-fQfn2LpuiUzGz>AS6^IRRW0_7vuH^t)>nmziQTS2s0G~ zXVPeBtAWHOR$FUMz=IGWwf!;#3DwdpvbSZF@)Z`k+%GfVpcyP!c|>}1ytm4U03l|7 z$E#-upXtI9pBE649Yis(!E#nd7tIw2sY@^5Q&fQQf!4vlnhgXsz{I5&WbLHpj!6k;u4S4G&sk;9f2v3!r@IJE#E4W<- zqg>Ix(gm8*gBN#6uL}#n9{d|SWy^SwR!jedQ3GYT@tmkj|9RVqVmS@?So0JRhDCQI z8%6!-W##jwAXZWgdIfn4c8!TO{<^B|U|-)WthbXS?6Bj~9aP3BO~ws8724>G!jQy; z1v5|Fr`@5A_i9}-92hS2cWEM&V&39@QAukR29;WlkVb-1PQ$k)L!^=FP>Jshh>-?Q z&AeSWw0Ur;!6OAG1b&pAkjrHT$2Hoe+y`?YK^J57O~Acr)X7C(H+qz7fDlPau%+hR zp!4FRC3KaNWe*2}hU@`t08Ttzk*@fd+Vn0%C^#$2=>=uCl07`h7s5`HmFKc@hA^CZR>G{82!^bd_2D|e zj30*KWb?dEm01KBmJ@K<4*1Q(@3!EC7rXQUjWDN?f)E7Oc+%dMl~K)sR-#DSh}5*8Nm;8jBwt2e|_J; z-4+Occ@Gv;D+06_BBbnK@Af7^=&}>Z z-23ZY!1KjK3eg7$O?V3n&MW!i0N=dRI7j&2v+{xL|D?*`*|HNw$7vOO5(rv6bK8Li3g}!f*#z80Xn*o zGlcWD2w}x!_377*AWzQs1>qn-hQ3mB*l`34mkgm{?<P{GZh%E-Tzp;Z`99{A^dNFuyl455&|>E!O0IE#yZdc zpbp`Q4B;%?ZIcB;B_-AMypySLwAVja@oi#GWd0i1mHYe6;)Ub7Bsb6`t=3CkXweY4 zbFWOfwwrEy;LBa}ZeiXXAS9wU*f8ubH*?B+s3Q&iImzD8B|d7Q5i{S86eh!6WxL|o zcE-YrP2bgD7iQFY>&M2sEkhqF?E)E@{=$pa@i0V}IV-VPZjPBR+d@TzvSQK)6{b+V zR7Sl4G_5t54kd;Y6$l$p;A4q2ow?!3#*{uY6)kvY{8NhXa0&I@7_$?8P8ymG1&dB7 z9^`N}ds#<2%$P%m69^%aBCM=?H#6Qw)s+^l1tv1sHtnO=y{$q43ch$Lv(*%#OHQaO z&S4GDndBDks{tQvWK9!x${?IO^%S1ZW>6WWDF8N7A zhp`6;C29E(+{q@M0m5@>z-v^nkKtuo=-ZE9ohw=bW%O~CS#c`ZvYg$IsIi3vXDI)=3gLrEakRQSS>n^&1`U}y ztU%bh^aeuynEh2&ZD!BnVQ4VJw>m*+AY8;NJnR@2LFsMe-qVyF9kSr8H zB%cWMwf2ODBT*F_(J-J_i-6B#vYzz_I@IaGIK2Xzf8@r|x34=!xANo!CpSpPAwrtK z;J?lga>3|@+WF9xB?Jr~l?Ca(GEqunPLP3+iW2}KCGNQ<=4yuULd)NJJnFVlemC1T z1!v}iHv|TIhHyOc(H*Yr82y>a%`Sa6xo`BrBWz*b!zWfCOkR?VsA)9!%DnQ9!T0dW zf3phVzXOC^4eYE@={uDV{KPKEdFjgyoGmBmD)&$3BpXwpvJJ=US3u3)P)E!_8XpX}ZdjFrHCdVw z!^$SBnD}Z91;ow@vAd48C9}m}->DTq!WtInt7~P-N@qvw@`1bUug)hxEO_Qg1XM`^ zuta#7>hoj6F^z`U^#%ktRV7Mi`5J+nFQEvI*p|#AA49@B!w>Pb<2k1EJjVj(mFb{p z(cq;b?9Ej&M|;>Y%3Y)re2`0A=%u;A)KbJlS}2~zAA|H6Iq*c1kS(H5ryomsu_L$n zExMv82L@ZC{>BCjRh~Yz2&2GmsBKGt1-0SfOSNNER}V;7>e9dyFZoi z>t8YYLTb#6>@k`CiP16J?%}u-qxjm?b8f; zFBj?U0AY}jNWT)Q3*P1!p<2K906w|qV>kDFGLkIxz#%Ue5vr=hn}NJuGpbL@03lTc zLBj8y6>kM4HTd{lf$$dqVX1K%zSoTJBoHPu=+-Zw=M3RLUnf&8d`%c{zUBw%7$Otn zx917tWikr3FlOu6BL%|m`qBHgPViTNx-pGmiL4+azPV1uaKsrxU~pkVPkAT?)CUpz zsfZ56gKGVn1$kEi2qC%cdyw@$NkrP$si8-yH0->jZU*;zf{F75C)WgFP7qc;bpQCw zvf$<%A#(nj(I;J>fqz>>TLL;KD6L#9J4QcmY_i-cEF#&RDXLn^PRw%mKgoSE^Nm;~ zpQuSaLpXns)*&1uOj!x))^pl#Lxb|O4i}5Q{y_>7-scL_3(tb|79f;>rTPswuQU$w zOB$U|4$|&R_lR=or;zZC{`;XVmze+iOJz>gA?#Mm;+X%Y8E(1+OPdwjoOFC6m(H{# zMOQgozVTerkQ0cSyk1muXf=8@JF&Zuhu+|?mXTZpkstqe#6`gUs19cE!NgByYT z)bMp#1w({|Lhch~C+VdP+%C$%Y(#7kkdBXT#BKBn^ENM`*fTZJK}z~9fTAn~4>SLU zwHpFMwv8s?H-M-xHjiFN%!Fe*D9AUD`kW#6ks2mFZG?+u%4YB!9TJT;Hit1|!*+at zU@Y#DtnRPtmlV6yW|JsAsx+0JP==5*guQTd-l*wXw?3fPag3JkEUgiHJ8p<^`p@r3 ztI(s6I&4NT{vosR>17>yfHAk)vvpK|2le~>@Ewi6Sk&@i+7U%u>y}X%=WI~F+8CiO zk{dhgWz1J9tFcbDYi5};VJ)y0o(*8~v$+T~4h`tkOU*=nh*@&z!OMsNb?%8^J?q9u zc?i|#F~1I1TC-E(FSt&6aClCMKvH~8>FFF}E{%~%i09p;qQ3D%dgGPG=|aWMKz3KI zHyLW2>|hYEFi6``fv`f=iCwvE#?z%TdMq+s1;Q~I{8Ee%-S2QASIlUu=hFrVH$~|O z2%iDzji_0H5d8bV2mbo5cfI=V-S9T1 z?RCc}$_KP%lp+&4Lr5}kBVX00*D?gj|JLzH**!xDIfe?^F-pmaMeg^OQ7N=xvZ+I; zXOkr)Lx>+JEB>W4{Sl{FP%lvmup;lV)1(wD+r|PAA{3l>o6M^~fL2R|g2ZW~PVksx-Ghfwte=_g%|0qI8 z6~a;7Rwx~>)-0$#yGc2<`1TbD%Y)$GT%2O~OLmaKJR257*~<`ecV`l0kC{+>s#|TA z>qg(gziHz=0EEsDA_u6-WcB1*HuAaS#iRHCfW4wWO3jJUxH%M>qqSxrgb0%%lqi;q zWq?6s$j`v2SD&|wBA*8mNiLoO!q262JvgaHnZyUl4zgwRe|LuPA_Vw}Ctmppb0AcB zaTJmL56%#-K&Zb_E|_-nV2HXrjI))xa|G}5KA=Z_&;Q!>u9}YDu?{cfYYkiNQppw zE=|v_(y?ldgY$%T5Ookvngsnv@I9rGf?f%5tq2hdeG`N@R z&&3o5`}dz4y*iX@MN^L+eN&T=sAAtL^{##(QY@Eaek&yu2r=j|p1$HCRwwyrgLWEZRZVhaNYN%pv-!y^h$s0=0$3-xM^+r29*i6% zsEP7#i^5bUcaD-3RJ2OkXYZ+baL_Rf=#d6) zZNqwZB|){w9uNxf?y+MOBK-KN3?cOiK|zTqki&!|52r)ke5C^6YYl_*sh?*(Lr8*< zJ4SoUsQxP@xt;u=XwVr#m@hAB`II2UCOmvOH_8b13WOEV?*jvZTqEj&^kczMDH#?B z5qNN~4q*e-p?m}!T;z*FKr)1Sq=9Gv;U9gc3>@grz3LE>|7(V?SG*MJXmHFM*E?Tx zEqU7(2L}k>dc6hSM|Lo03r%CZuld$qhOkJuZW$#-NP`6Zc7Tu{yzH^Y?EIX<^dMkx zkWU@xHwZYeHw``9XBy0Km6Wx}8NzJZ%*N4MS80%MON1XeqVyD@-c|O2P`fZwBcH1D z{3Bn8`QE>jAAB@cg(pLZIY3DM&TY;E-hcF#PkreP@BZY6OoM#gGnxxd3C?q+4&lh> z?pU5wBPk8i-XbwM?af!hg2<;>7E(L`gm--MkZcc8mRq19rdOT;!G#CUWC#zGB)Js- zyVSJObJG87AiTIdl_4DKofYPeIWw3vT{B|T$j|(y43`>;^K36Zu)=zcsjFr;oWbZ)MpG-ho-Gs7@vgK8RIWl!4=4=vq0yu&y$6oO zOD9?+Nhr+|gNH+dNDeKP1}gxdKegzx>A|sZq_lIlj3WEDhwhh#s2+~PX1!F0J>xo2 zJ1%jlZTJ=s>r1i@55{yEgIpx&6kIE2mJ9z)!|kE08?>_(TMJ$gH6lAj4ihG?MsQ-8 zl7o+0U-s(?5-@x3qy2gQ8$v7J;li6Y1_GE?8?Afd)KqZk!e zAcP9fWC(?N9~BEaOE~hbk$X}qua&KGduY&-jG?ITX_Fs(#`B-{<|}3CU&~%lwbQQ| z!m--tsuPu;vkO!}7z^J$n}vSVo(Kavdp~M#m*Ij@$?cOz2;4&UR72aGKWy?WQ|Q z69>YFkt5wA;=i^);_fwMo59*|2S;PhDX)Ay7m*$G{u*tdUtRgEA+8Q9N zEJp~F9+b{mLO+YN0>y|(sDA)w?4E3TP>3C13=#W| zoAI$R_C8TC3;@aHyCC?xp~!0n(;hhx6q^ha;M(vc3jcc`Je47I4sb!KJNbvOAjj+= zWu4qGx^r>pBX(upv@i7}n#QZie^q`&ScTPZf<{ftH6gblG`Gz&cKKESumo|t19s=u z)CJYf09!`8;nmX8@oa33Sn4>eQqPKVYgvhr8kWmo*7?k#X?*76<1PF(Ze3O*BG$u= zCPEFY+7=JSbW!v;5(J!=6d`gU{G-%Fxo`w#ETmmY4DtbW*eeA`0`WBy*fDLE?L=vv zV<{5iiEUud2|#_rb<$je@HkG9AJ_jLr3(z%7iz1`int-?$QZ`TF+xryc57(5EgcUjLavno1=%d>A${W2SfbKME3uvXYm9idAhiUk*= zCjPmIv>33VYk*xS=rR=qCk`b~mVyWKS5x=7P=*c^3=5*6(jryrlXA@rVL`D-wl^ZS z>egMjqyxQZ7~!N#D9#LnhdoA^Im|C3ZWuHc;TOh8BJ)Cv;F%rt@_>k0hSXmHR3mp* znOL62%@r`QDM}!zCO47(+u~}2+66kT^LnKJs3m9)l)|Rbu^ac`L0vhcW<&%ivy|}* z@f5%c%?#295hbIoKnMpK1X1?ow$Sd=x?}XL{PG*WoJR64Q=mC;8l?)S8y@tQ(ePl; z5E=!ildKs+i|nGlGNJ!_H>o}{gro>lc0xY;r}LA}K&V!S7Ik|l=L6rjqzCD~_d$W6 zkkE-j10h$*AX0(BaN&CjgUJ3ZqZMY{{^BoA%b&lX)F4L(QMek^kkZOOV2}?W^lVl$ z)4Dx;vP9@clKz`PtllW21-7@0?n$vwm&$;APZR}njPOv|0Yd(XF4UC$sxd$#tez)Y zhcL7OIEt&dH)6zp0TgdhfZihg5=NB6O!(gd;jFa+p%z~=bgxu4jz^6*j!w^6rAu$M zBn?PnZj~p%wxk3J_G+KsTwjx`V!T|Xa8`y&T95{t$S%3VLnbX%CxActESH4PDIh#_ zh84#23jyMaG-lCzA!&W6>?S=gL@y**TkUk!>sj>z($icU%gq1 zzTq@Aq8wFvkpIF_dPZvjgCf7-JO>@es4{~RGos%y>2A`A(}G6@l4z7nyVQR`tiyzH z>sgTwcLiVCr{kk(o2BTxUa=4gtXQv<-T=d%8ie|+>5a1P1f{Ri!IDPgeo;N@ylGS# zWEa+RtBxd~RC0zR+&oi;%u)k{K|*yj)SP%;-f~{Fjv2+p-nN-dw*a9DZYKx0ns?*c zM%gl5ttx(SmRm-tM8P4#efhO$P*5l!^x;A3POya(uA9mc3lKgJc^c0N zAS6ZjLl>nl5k3Yymz{tIlOaqELN^}KbLvnA>P;v?XeNC5T>!8G>kSY-cZ4fO)t=fY zO6SKw7)j=D2M8O6yzV_*H*<;z?{aW3g(sTGvq~MpD`n{ggt#B`UZRg+A*16cS+3bb z^Wy9NAPf{0>H!7m1_{qd@0QsYNU;4wZd1vb!e$CjW(YSx$h(MvP%s!Gl$^^Ds_8AF zbgv5smJ5?9Opb6Y!-D!CLop#lsPBZhT%Vwc!-M3<^ufaG2|}2#Vh;QUkJ55^f4ap*0h2&qHU73kzT5MMG3CPzpgApFWi zBpwB#3=k$8coqt8l3?t1(Hdn`0Nf3 zUgl(9_mIv^;Ik$U>ow)L0-+}r%mk_hJ378Qr|z}E$uFB7wR&fS6XMrc1L&)~w>?joVWDn&MJRd`iMW>&f(*6` zVnxYh1XFH`5&tRmXQoTR32z29;3;t6FW@Mvj}-QQK*W`YS?A(Ff{`7g`nCe^p_B}( zS9KJ&Eh@%7JlN<=T2Zq<+sP)v3d8*UXWS-0h|ekvm(jQwE<%{SR#&fm3mN8c;B;j= zkAis`$q(rX02L#59;}>51Q|tyqd6OqrVNOdQH>=+M-l}O`Mc#ip$K41L49UK;xb+=S74xZsiynl;!{dA~dLh zJf58mqoF^*&!gZVJz;lO8q}wD9|$MI_m|;4ju4&|6&g&2Fr_FofCsf@bOAyi668Bo zhYV0rc2Gz-**_6sfUr2|RvE&{404~0 zGlVkf%eGOo;P2;&sar;ovt=h7^5*w_;lse-=|hB{eo+K66tJ1{^y*TXh?9dUOz(6c zfq_WtYEWGbg5rSF>6#hY2zs43uvtRVg46KqiXf17SJ*mA$M{e4K1^ywYQMzSR3^g4DYS>aU zq6Fb01wp+X(5ZR9{@hxXrK~7jMXyxhRUjPCg@efu&XfCF?k_b6fA>iTUbtLg3)4q> zG0hROlCWnqM97Ow|8D;I_19_BBlNW?J8_-{$q-uJxbUEl5$0-{*DpYL^8gByU=*Df z5DgasyERjY>}(muud{;2y?K=v1Ox#;5gJ=Y?Q7+-39&LRJz4}xIqI2CzM1L~ESVaD zGv{!+?1Wce(tNKxh5}tNTU_RprEL(_qX)|0eRhxIG}BXA`oT>NwF*@x*e}Wr`Sb1& z;mtBubAxUHH7GCSxya{|0g4a`*vZUAs0++yZJGr0ul8Cn5WS|_Iq(-ocouSjH)kBJqlY~1QsCR`xtn1d%>TZBIQQy44-#mDH zV+_xj9{al0)MjI#@sLF{OMtXK?uOttYh+lEd&qiQF5^V2n_r}kLb+6$@qm?R?x?f=6qn5 z({B+j#le869xGY?-xoohw~VIvAc;TI-^kcSfrEX5KFUeVXgb#{M2PVPrHaF&XkA3} znEu>De(KS20*O1fjDC&hUjpH4&t?dLK{o?@@A9$15%(%S@%*`C^ciA8mz~&d88s2k zN4ryY;(6i0^->v0ixK*k(WC=;^wQ882DwniNcfz)Yl4vbV?HscagB5bHrR z5b~&raC{JDN0b;;w@3l7YON2;VM*0V8(DzRF%OsHgC|cARy;aWc4By-SeidWGK9TP z6m|3_2sl?PWQB$sDF!j@4lKm~mMNdPpDy$5yyLa`q(#y`Iy zNWotU3YH1qlB)F6Cqq~wWWLDLS-E7_K2#hm6>^U7OZtabtXD*&afUEOC(bh=KsYzp zd~5~62b84P0YZ|YJwLcWUzE+R(&Kzjk?)C=ohT|ngY!XvTpM%YcXUg(XO2ml-gwo@ zCmXDC1P0+mBbPe)bQ_I76QR0OWhV}B=KTjtittkN_?vKHjPwPoJ-SJ5apl3}*qHH3 z?)!Y4Yti;lnDc&MXc~xcy=fF8z67agFEgolv>qWu9{F$A&EfUn*L289AnX=T9%*~X z2j#W+1fJze(Nr4TqsQ$CBr0vLX}#(-YyhtO$hgi*;#RJdy=!~e@O7Pr=T|cBjWSzg z#G0udagQkOSTnFUVQQj{fPU&kg}Fus?$cNUpipCZ*+AOuqRo1)!^i9L)=QB^h9sAi!u^mX*5{4IWuUMbKwaZA2F1=GGX_IGE$G$ zkaSgwgT7aWffOF#FDU3J5`i)kE{hx()DEs?5kN3NI1j152%~NZ^%#RaX*vhxY#qWH zNbg+_56fe9cCdipvJ!2TtvwmQZqV$;)7^x^f`o=DnjPJ>MEivHGDxaBfBdJt^`iL8xAnv&GcI zQO7wrs4%UT`$R_x`GpPB6VNTAYtf1Q4B-&nr~i%oAS|eTqf=JmT{DS76!Yo*_}vlY z)!Pi=cRqTza{E+<5bhg0`$c=H3^z61cE*r1g!=Be5R97M^rXRXpj6A#%9F)u; zOtxkSZGM=N^m^5+t#HF=1r(&)0m7Ublvsfb4_7jTsWwp@>_euIf)jw@o*YG1&>%R( zkLHTeld|Fa^p&&e1ARN&G zsqwC0_KZ?y0(E+b@GbK@EGQ8iqP#&QsCT||EI8i);bZuG#GNvg@qhMT0b#JUmx`7R zJ+HQE9S#s8WhMAw+M|+o&hnYoOm9c2GoZ*=vJ&8&flAS%$fG|6H$^~&up1;Pr*-); zk$6~vQ1kV(uU}GxDe!P~-<*lnvQ^IncG0aoq2*AK&vJ=qq~`UeQhtX-;$=2~=2nP9}**i1guR1_u6SlQh;+M9;<@1|j4GK4`YO_7!1QnoO9QIGMO zCakS3J!BT%&&a85|CdP^K{d z-_oUrGZ?3 z2j}5tndAgX{3W#~9V*nvu*U=$B3Rcn;bLy+b4_rN-UtJdd{U-|0E2uIPl6YjYjAY< zV{7g*bPE`IyXR7vo}+=&Y8=1Q92!WbHyL(bfw0NDl?hjMEjqDg0C_Sl4pztC!+*5v z^63d=K$lt#BP;-N1;>brvYF2qkn}P-!B;#*qfZ-Wq70(eC@G&!pwcNWXrz+ItjG*b zCNf14qA(dW!aU7&WQffmI7Fxc;!mI>Z^1_3?^*o z1B3!WHS-IdmyAVUb(ed=eceCA&O;fi{UGN!8$13g^gsXcFdy4Fk7h23cY10_ z)^Iw7aM6qaXrdVlMHI~8LUL$hW;Gyi7MK|K)Q_0f#svsH`EXpx=#JQeZ|3Fi$0+Cs zxyApBJfS}v|7r>X`6KkuUZ{ssl}DWgh^`X;Hf-2EOX}TVQavrbQXA#^xT~WyRbnV=Yl}8vZabmPIDx zAk7#qr5OgD2_!d2XH=9`3Iz#ICF_c8oAQE*Eb9?K^d=6!jj_Ue#BO5fy0*hacH9+u zDb$1XJRaVYZGw&p;Q|@T6>6HeaiV6438l6@iyZ}$;lYuUrvw@Arvw{^lmu}wqCskX z4#S4nfiy#SgQVbe)A#>5H}%1Pz4`@{E7q%Y{c>Iq-s_m2sHpUnScPwV&>(!skfVk8 z$pP{Zhv1q)F$-C?sr4HfM(i2G# z8VP%!4Ee$NN`xoHsYkApVMAxIY#eVX4z3x(CqLv4d4Gw}ZORa)9R0j{c7!dX2D~fx zY%UcilncC-3tEhEw`7wmawOTN2%+taC~o+J0wFLcT|oFrg@(0)rPBVXF#TB}Lzv1E z7t6ge-~8Fn?#L^ATY!*X?lD4SLBXUu<0=ql$7q2tz^H~R;@$eMfCewBP`HKo>?Fmf zJ|w9axhGIQsd`0*Fd#uWMhFw0$Pj{#Cn^Ny@2gp_jKqli+}y?UzbC=oL0Tav7q!qv z!%@ZMdYD#uqmzctQu{!7Xbe3IR8nbhfljn^i!FT}W}xH(m&-$&DeIgS6gzDpyTVkH zYK@EgWbyeb)er_N-#N1VYG$z#o?Z!F+SZ*wx{wCM7Fbh;P#sVR1!5BWF_87r9cbuG zVfgII^)D6%_0l2s^3gG!{<1v}Z-9>>DuYh45KJK&iH5>J!A)|goNciH*c|&U3^6@PjFF{)m8m<`W;nO#KNFPmae0-grMTCIMeKL@u z6tf`E*W4bp>aF1__2Fe0tF`Kk>)OnI#!ERT=mUfBU?n-iR2}5f8AXGBnrgvjDr7ld zQ89VK9wSr(0)_{rqgBWiMkOXXo;@JkXTz5@=hp_$USWx?1*I#{Mb>kI^)zKyPnb$x zUVjO++Eo?CN+<}sIEhN-_*NMtLZlbKa~_d-MM!Q5O-hnkG8+64EOyCCgrB_QcAip> zYY33{LW3PpXJ7;7wC~f2JsF5g6??_XsMsOJ8O!cyDp1m2Qhe2k1scVMQ^Gg+ zwodz8S4NzI?Xd1kI$i|5vOg#d((UdiEs1rJt!EkRtBd{(%ROYN! z&qy5gFq8|hqsu)pR=bvjU=%dwIlk2h8ESxx26GXC-Tn=f_0CXhF^(+9Xg!Xl(YAFS zKE~K+GN(}3s4g$C8-DXyKvCb(Tu0pr0MNGa$nAXKaXzhl5I(Zyz)_>K{gnKmo61@; ze(Qtph^cH@j{#j5%K#NyqRWO2JW-r}K4d~4(;Gc9Iy@*_O4T!}baUzI+A|sOAU_`x z?9i>p=05I?!7h5li-nXSo*<>n&l?_;wETpUf|2eR?a{$F`b8WR+gh0ZWh)3S7GfBC z2o)`{vBXsZDj}{@f_(+UhDRw{hSU<(OFqMp1+H4GWR3cgs{>!}AoA=^E<7>2@vxjxhp9Qt#uLY*PRq-moW z3S}NqOEOR&Y&erJcq~a>!rj93d-4rmDswvzqosSKEuPI9s^{S_djCe=Of1>q!o9^n^s->h!AZKIKUj~8We!I!dE!7I zm&Od*ana1!M0*p*euKkkW=J{Nbjk-1>NAd&>na$l*I#WtJ}6!zq84a!gPeLBgo9Wu zK#q|6Mg3a1SSI{uK?w*#g|(oit!?p~0Yo;5PGjN=0ZIuLI)yO3ObW(7f~BOG^kC?b zb4rqwr_Z*~RAtcnQbxB78|QJIEtQ*%8De_(CI>h+r>g@ z(=LY$A-AL;d)yB+W2lZ8IzSKm=E4~-(3%vuol)$ckv~J{Ycdm92L`M%L?ilO|KhV58*+QgJr^# zw~WgAum=bA(Ntt{9r_|+WIsa)0_L?YW?Rk>@`VV82fJldC|D?*cSi)C0w^<>6tyPZ zTU!cji#++v`@chb|15x(n`PGHgJ&~@{&B8>Nn!c>UnvH*4B=bs6vML3^cKE~9KkEq zkw6*P`vIjUXw|@jx8yx`Hzt!gLkJFDBSQ#&YOOE*Y?V85oB~3`OuA*hSr7+?%4ST4 za07%-IUKWTbhM|C(^EcAWJb-wR-H)VL4IE-GiJ7SURQ3iwR8nSe&L|sdazt81ETm( z20JKOyflTns`X6`M{tlx{JB~~9C8}$0hAb0ymuP#KZ3p{muGI1LLS(m` z>g{X-%8MPADfugrUI`bBYIQN9GK27+Mdt>ycNBy5N?i-~)r!7yB5ViD3HtZOmF@dAWrrJ%O!>wu=}Zr=^2 zG%+y<=q+oudm%j`2`gzaIVgr-?!cehR<9?~W{<;q_OK%uklt~ST|x=u{!r9W(i26O zQWhb{QLiV;@)`KFI}|#!XfU($iVp zo*}&Vu93g8V-$Ip{I($4Izl2MdHJ%TACY zIxNA| zfuN8DN?Aoh4$4UlG$kFV192cF6n5C4C{-v<75ppq&A_r@iQ>WrTaIf{f{HN5uZ?0AZOCm5C^HIf%%|s(M^@f@?-Axn*=$N`!u{kmd;i z!W+gRf73Q=b^-_`Z?_^umKM9RK*ENLPlC=W6MtGH3eM+7sWMXEu#$-7j9|!6!W_ z>B(e*sHt!CedwL{moF_D!Z%+rqbHaCra{+;x1HU>@;-jZUyS+h$u+MS zylZ#x7hz&)5YHy2!428HZw5+C0m2$#zh%uUQi7YZ45$m|VctW?T2RyM_aIv5 zzQANV`tlyrGfgeF*VLbdse51`rMo=T^z#;$`vMbyL1!;q6Gyy!E?rX@r zstnQ7bsi&D=m-yBcf%|Q{>^Wgf#M=zP_U;6 z*TaGtw}(5@FlhBbFqxN&4n>i(2ILm(Pgg(BXTtmzIK0D zaO;48QrygFK&h=-S_{Li7ag^hDy7FYnWu)gM9G#;>5c&cIqa9xfNC-=1KRfZHHRgo zvK9+NmVrHHsgaT4m>n=<=u-wqTt`HGl#T%4yi1h3M#tug_449(ISDXNU}$>GJko+% zh}Fig8iw#*+ULr^C_PF*5g?4bPpJ9Ww+w{H>tFw(7rp-Wr8Wt0j5Gk8rcZZdA2?Lk{v8w zKjO(pP6{fzzb8ElK7EM;-%}n02xr0fiw8aJFBdfEMqNQL&3%Dz2#m@P{0;hs^`2Qb zjJ}nu2>Ai-HiE3`Mi|NZZNU!U5O!ZoAU*e9t?q?8=g!N5_^Tue|V z{^#NY0^uy6=_hX)1wy-W>o2sNU^mt2y_qcH=O67&qtATl*+~pOM1O9?B0o0`3R)OX zZuEDYTB+$f2eDG2&{@w@$<$0jqC%$~FY00+ zWfq9H#Gs=UKNEh*dwHQm*I0Ud4gA+odS=8`&2G;l>qna;q3?~cqEFhnyNv1w$4YYy zo3-N&#w~fl5}X~l^rDQ^R)2r0_A{hWLxH{}2Bed6Zah5bqET5iF!*u!Se6;eIqz`e zLC`1?)I#T}Lg-hc4U_FdAXMkZ$`V<{aaHtrh?-yG%W`gF>rt9!3-ydRA?VKl2N_OQ zaT=-+qW7$z`wVP|r9DK5?A$imq#(IIm`_djle&!q%^)cJ8=Z+W%G8&QpQJ=yxUMoz zHDy65Xx53Tel-vl?#f;To5;d?JH9=0Ft3K_CB-`X^RYB8gomwhHqu}&)TN@lpzJbt z2F;vkbd+KT9Tg7?!jt-#Bi0K5<_|$3jvtH>VVkLD;n(`t5fh&pW&5NU=;qZ}c|lzW z$_+F2&zMh>v6u;Eq49cNu{G%DL6?cVU=1h^y99(X3JAXynb_A~2ErHpW7;c`(B%i+ zjwnNjI8!(^=>fvaG-wc1H!VOo`$IPyM!98FO!(N&Eu$~xmQfl(;k&wVnr1_!9YDtWe+>9PA~dVL>|T5cW1@wFbh_-wqE>t9O;J!h?Kn zX749izfny(twUIu4B=njwka-nG(O6r**G*G?^ZnV-n*YA$vvX({-#{u*8d*9ulT?F zK&0T(SoZ1=h877$bx0vM*@y0Q4cX03?U+$S&AiDO`7!1cPOd=z|(N^ zdyt@Ml0@O<8)e>b83<>}TmSUO$7j||+t#SRPJ$31fp;!Ic&fG_V>~zSkAb~o^g2LDGVsugWl+!0NEbdJ5DL~$m7PeT z`C-n>*|KL2hY%NXv?55gA9?cT=RfV6Mjtxde3o{KzH!ohZ-&W&V&OpdXb2%EfBMtg zbWPXbk{JxA*tu7MrmK|U_!!Y&fpiTjC(p$Rxl>Hb-!2%PGLgQCFNFvZSMrUft3li;N^Mk+{Eqx4# zED|dSW=wpHi-~cD4AF@xIpQRchG6X#J@_ga*pJfmxkm@fU207b zgipF`QN31v6pDiY$uDBD4(JifgdnIo`6yDD9j^jGTdtWQgad)N(UsI2tBle|nUOMr zqbN`4DI7F(7%4*v(nE$mXUOrvd4WD!_La&c1tg?(ukI~9H7Fp|?+*}GUau7{0ikz{ zUd4mIbI}RCBPvY)B52S>Cj^AcZqXm|rrF;z`aG@~z0(XKMJD+7n(Gi!Z(e|SWJN5x;P_%-ho{oFU6dE~>7 zKmFDN(sUfUSailp(~%+kF9jf+-{KFPzBi3_+vv^ltUyRX2~8~-vIe67;jTc1*8xKD z7UEKb+!X2?124$t!Osw?u>FZkWl~yVIfwUT6CmTAkm-#t`5PK}{>+ne4)fUo!h?@G zzGJ)7^4t&?qH}}CuJbpZ`lpg2h6z2VFH<=Kd3T+6cfG&{r5Qe8va(xs@KD0jgVwnE zsXK9{5S0^uv6ej zE|Ni>ujWSee*;?&3yvlr9Oiqq9!eBKi_+Su-DC{6-nB*3GjL}l^f^MnPF;)Acdux3 zhNJ`SXm*hW$P=diH75zBkPg&a)~nWWAQ9b00CE=ypL^YVWL8u4{bXRR7)haRfH56m z_gLU@v(iAfF)I!euMRGji(Omn#D2?kW_7&2q5F+#eG z8)Y`~_zTU08z7uuEhYt9WJwQRy=9btfM&Z<^iD6K^aS#?BUwZTiD1~E3CDPq;Ijt|?5SFrcJI=R@ex|bVZ)`(vFf)q-d6)f|WC0`RCv$3G~ zL${yLCkaQ0j06dLf)MO$X+k_8AV8Q76x(j^Y-SL1ArKoO0YPFV1kv+gjg4VnKI}+L z3A}(o*N&hv;plSe|8G+&a9(ZsRvJ(&IQ|rypcehag&VbAcZ>=Lnf#)t}RZ72E~C>Wc&y31CP95S}v<>a~Hw z^wY{Zs0a==`zMtr9WW_n8sf-H(2Rh2Z8p${)LK#mB)OBOK$uM4m9kreuz9z}b+E;` zga-PGfD1&@Y>~38Sd+UFV2jhog%i$8TuKa@)snh1qT$VN1R{fc%#lF~fz%gNJ2qx& zEw`TJH8m*(3Kgw^;<3*7nmEY&N^cGIu2BeZT9Sif)WJcAa%GH}51dO0d#V@$kanxX zJMP{nqX9!Y3<{H1Vjzqd2w(IVIM@u~>n{NzcZ>!HZ$F0oqeQ5)gYUu``OJ%`ORrZv z=q;l;MrbWCNS+YcD@^Yk;Vm+RTpTm~!-l+RZpoCI6Komfz@VA%W&Xy(_;n+$NUx7$ zgx)&(mgWcbmI&XXOJ!ynw~Ts*p>Wze%@9I_+%nqDqEO)nOMeRt-oFIl2kz!3nRyX# z&fPRdxBy{`(u)TLgo;htPtk=)8hcpkNQV*T3-8%MqYhgDvWup(@mR;bO~nZVA&j=6 zu@Iu0K9W-z!cyLQ-}9dLygP3-^z`?Yr^AU_l{TbFDTe0yV9Yq1JPg!{l$eilKAK($ z;VgJ`8*U_)<9naD#Dh=0@X!sT0HNlv#w_sEY5CKo4B?-)?$6Eh)NJy6N{bD;>912K zHgoGLg4s9vr*HUA0|Lnso&m!C`vF4Ig8-o*Ft>*SnrDD#2=GWkMKAyOmEqF>=)BG? zqei|rYNCWS4>t>g`jia(^jI$g;Z6TR$p?l@LxulchVZ&T$OVFK^S|J7nF}`wJ|GZM zv;I_JdXR7_-5_UkYlqFbs%Mr8@CW&xB_Q0EWHV!BkR};7G$jaEdDOEvbNJ9Pe|DW0 z%*!X4l^x5sP&^S zKuN<0FVi|kU_(5O$l7tL;Y}q9JmS58U+54M_lZ)IK3B@5D1{Z!PLq-5`}oR<3^q5A zVnU&*({`MV&wN_mc362Z4$`@z1Xsk6Af!y8hTc3d7J1J`mz$%86QR2F_8c?EYquC- zcW=`4N|+jpZVc%w2~F3wjOjj;iEIGz4e|dfx-kzJ_;< z6*gH8UlNcGSB#@r%OXe~gM-Nsng+RLGzBMK z&@H3tCEPHoYejkV8u)HEIS36#KH}IBb)*SN6as{0LUr>N2$2He)mg$z>A?>{gSQ{0butwRq3I^l$`(`nNWrK=0*0C+E*zna$wOa6OZ+`M~IB2K!`wu$q?QUHgSy% zop?z*VHo4pVDFaEL_DF|6TIoBRsTyA)uSI>k9|mL)sMV4xONQ5u|8CI@4f$%eAq97 zTEi#!XY;^II`ZyMwm>*X1yKrwrZ@h{F&DZ&uuQr)3<>(ZkXZ)c?vGh%5Vk3gC&2BURF`}e*(fIwdBc9Q9kTqx98zta(h4^ z^cp9D&@||DUl!Y83PR*+;o zFQ`}sUgkCXXoYqW{wCb+kXDD-$!DID;NUX-&>`-Iud;tBC~vzpHq!JSk!ub6hSf-T zBf+*zC_}b2@0s3kJn;ME@n|7zYYrWsa5eoO43g+HREYUwG}e#WuK)% zp0Ho3Ezn0}i2(&YFH;(#x6p|(uIRX9y>X;Qz0@!`2Z91;}&sWk?| zhU%aut=PHCPQ2+0Jq#l+KW1d*=AI(Y@c8zAsXwi@N zVsHo1qG)r@hMBpPQn6+U+B2)AJTEcEoDoo7F@xU24Q|UWpUO8Y# z%!H1^)-mgKB5{0%K}}Y}L<6G1uazd~>A*sU^bYyQ&msmwc^>0VnO-UrAiOv^_z$C? z8o=)&?7bDaD+32Lc(HTKDGY_!r2|VEkig!h`hxK!pg^o`CNDY z7KaCAqYmNx8;2aBVbDDT?-o@vn)7`+KFA%Sce+=UK2Z2UF(3e_Tp`zzjy|*R!GNVf zwQL!!JU>9VH)N1ClO?Bs5Mp$|6M{Uwg1?j>L=Z-; zL-_k|(=h@FkGy36f>|v}-`az!N3U1k6d9!EAo2@DR#%~yMZM(bdS-wo(eSCBK7_6$ zU)v@K2hiZb&80H)fIe0v0yR>Q{MFeo8X#oPCq(A>{3N8ub*P%na27dJ^Xbo-2Sa~M zJaX(VE3H5{@CRa1wNRR)^=7ZBX3C~ORNxRGytHHVjrRAC#|jPtgm5M(CoaMsB)oZ7 zjui;CW(m;JGKAy2c{L1P8wjZZacn-~F#7SR*VO*tC1-N|lM21l+fO30M1ii(%;Q07iE(3JP|^|$ z43~YObQ^B$gg7m=Zg-NV@?bZQiut%qbTr8*V!)AU9IN4kMv8d>I)4SZfFCglBp7S; ztdyR%_{Xm$Ak#WE3mklzC?^FE!-wM^ZN9@nn?kvDG@62Zsm$0!fId+;Yshu*?Nltf z#laFGO-<#xRd7b}yjo~S($lVMbcIaIfx#Q@(oEL_gcgr=a?sCM)m=Dnh5&a2&k}(Z zJv{e&j&U23DH>{+vdJW_`WfxdyjST#WC*TVD< zBrr`FieX)A<@sm@!LnMub@^JWXC@%%K_ITI#eh)>wZ{js5*7<@SL+%WvkdF=lK2xE zqL}z-*;*4kjREvoV09QVu!>KLgwg>-c?=T;e^oC4(1 z1hwoeh5UGPV30Z#>Sc&qF1~*n21A5@zdiC6=nuIq`KQ$|;V0ENMJTy_kpLt^*dl}j zgfHfuk4ItfAQ?i6P9RIsi2~vC79bo*ocAB*!OFWRB{3O7u^>FimeK4I&6U4w;y>ogfoNW0`<~)SRqG<1O^L)aAy#c&v`<90Ab{}0YdtWMS*mvl%&5(p32EXc$7`4 zpT3kJJRUA*2)W$`Z1oG(&E%e@6c8d8Kqxpnw)}e@V;L~XaGU}QjYM-F{_y91@W_Yn zFW)E(0)*eFZ&zAuqWenSs`D8_1Tm5(U|y`QIROv`=Tba>Ta56FIKQ+2;pM}N85$4% zKN&(_{J=${zD3Y1c+h)J3gAJ3@ZdbbdZ~=FTo<*j-@NQ!9iPR!S3_p+SQyHvx&q-v zS^MrfMTci--njr_st)Rrs*wz=K<8y5r^_Hdnk=J38_ayIz6XTz=-F}??=D*MRW>ur zkn2ns=djpCdTS(c@l@~VYH942^ceQ2;97Z}uONuOA-jpm2zmzz;CDN211~0A8pj8* zSiDu#U0>m$L6DbgM%CdwSC?3Q+il8yE?qEFMl1;8b!=wPcprH1p}WWUuRt3Sv zc8N?Vaki^RLAf9l&5O4L1=5AGL$nXcL^V4HUIU`0rf(7S|p*YnagAB z(Si~)>2#XFDrna&LO5*3T`*9gq0b|Z1_gD1Pxu9 zpc0)n6)65*9@ML5SH45{@MwCF$1c)-WC)=_GKXbAT_!Wybkbh#6yY5T5b{MtgX)lG z2$wCR+B5oPIIuvNvJ$r#Q;1L*LbIPLOhAV7D)e>95NgAya(ohL^G^Tq6q}$g629ei zI%P-+`w5@Nb4S0|4Wsm<2nh3^bA0tDImD*L)|BK)M;KxvZ1eD%7_)gDBijb%ZbqM#1PS9N!2oJec=9oi+(;ai3?!kf3%NxU_qEKW3_89oMAR3v&>9BMiFM#mYW4GV6*)gi5U(XQ! zCXNyL+zk-2Z`36xp7(+mJnu8N5v3%>f^eYQPu{NFS}v9OsV|k8bw1wXgU$!e{WHid zjJ8Wuc7hbWP7!WWg}zb7L^#cW9*`%)OFz;RgsD1#eBh<5g&+L*m%sA1F93rf!i@&P<+_*AQ0|on=au%?KhN{F;}-tV^KAx0oT#NnLJEYX z!d)KZ0|3=rC4Wov1o&0y8Nrj6$`FeG2^|+K64HtZrHcdwf>y!7W(Ny|lFcc?R1BP< zfv`eWFfb?#RKsf!l&HNbNhsVack&1Lcd0vwyjQq5+eJqTgrxPjEOp#QJP)wXr!AwF z0%0oWYqF@z;V_why{8DZf|r*oG)RZaE7K(&)VC|HK}czO3B=V#TSt+cAzU(qR5g@x zz*-`FW2)e*fqE9}`VZ~EG=xJIQ0_;b-4}@XCp}_~p5FUf+m%BGzs|8J7zhOl*Qy>WVIuB#RsZT%%2db6Y+ePV$ zfV3M)a5s@!TYwNGTxqZaVmfBH?(Kx(>M07Mty2i7x5o6Vt?9o^A`VwH-dMJ^0U6T= z+wAUrG|?FlLjcYYs^&>)?F#8?M14g;uoKd}bVKWZFI;H~+re^!b@QnVh25kIN%c)P zF4;wTqpQK}iKPPFXAh$cLB zAAtt}LT(w|m#2+}XFy=~ZCXDI3tsTwWjI`j@H7kZB~fS~G=uRQsY2Ln-LhqLfDe2< zBS$zSsJB3v+7)MKiTSkCgSt*exM|->Ak5zp2~_&K`K<02C9!p2C)oLXYD9!23kCNN z4ut0b%$gxQdUqzA^ya0sdr~Iqj%rU`#PTRX>z2{Op+I<0Pm(j&wd??)h3J<5vyL!k zfpcz@S>>}#JSRIS1w2s!SVx_BWTx9$X*V+ME?X;r^ zxOJAnYN55UGZ-|gjxM+HBAvb3V7shUT*MWPS4bF(6{cdN@0~Kq6Smx7N)hUn&cCkZ z)TX3Fo28JlDy%5}~(< z%C0@7?lKv*Ko8NzW+cF+X~Rf%v&P(bJxG87O3g8t_5b)n-75pME>lOeQGRVN|< z;akjnDohUzng;>G9OV@mrT_&uj7r3Hh;EZnKNfD4DH76=B9uSp4V_g~l5vzf-7w1M zXl;exY>wXm;jhyulL+^vO^<@0;sn~jxVGa02vd^YH7C@*Er{1xRO^-2r`MNYLIFb3 zf*|283|7^%+$2Lg58Vi4sOJgDueAx0lmc8cgoM8L?b1!p|^`t_K#hjPRfa* z&apip5NgSjTSgB-oMX-qRvyLc-##cyskjxQ&c`Fy0)&tbe|fiVGL(7+5LP4hP(x8` zMY!TW`srGNp04^LMMJmhcS3_nmF<9*zEHTt z%E1_=lj;u`uIhGPXV>JH1_$8Q31A*-V@Iv8$Xa6c-co6xU^0Me&QoAQiiwahmyC|v zmYm)L)7D#N5RJ=L^S~>skv1LWzW71ReeLU}0WjBw@2$0*Y=8 zU`rCOP{`CUVDBXr&cz=VWd-1n#9>huc#L1I^#3|PsGJ_1Z;a9TK$uTuFd#j?Q9mgb-OjfKnM(S z%V@WZI#^$>7##|H0bD4#bLkM_!y!SC@I={(0*1qvq; zBGiUau9HbI37sMI@PnHRWoVJNj8~DVUdRbV6{c@N`Wzty^m0m?I)sJ6isO{`D+{9N z#a<#rPSttvcLyMxmkeQV8BG+=1`NxW#E>kEPV{kehS1NhK!QBT9u(Fbc|rij4WrX1 z`4>h!ks-8v<$LedmeJIb*u81AX9)K)gcO)J4^@s5&JY#`yI*t^NHAPzg$&_-esE)b z$_&DWPjY_d!@GZKf>8hX2M0G9!U^V>V{L!o2?$oX5(amX+=xwJ#e+m)xeqm@S-(#wrKg`#?y{ zerE4H9ieMXl&-eVdr;y^n$KbTX{}eM21|AYur;`m3qmfSSS(wj>Dv;6qZ1iiUaLXr zJE6Nqi+k8&&O`zR0l`6Z@g5)DnW69;jf9{aw6LIkVAy);OEAx0q)gZIf{byXr6DB# z&cii=zw0ofr!Kvdgeo?nf0bpS27L~xCUNquYsAWb{g`HwfBRiNJAdMZF&<+=VW_xjL>d~ys7#s9R zLr(Al2JP2xJMJ2yEFU!@%y&Y+8L}BvPio6+ZF!YVlwaVeDdIt>QWK4S3S>K|R)g>m z_jJ#t$8Td=Nm)>e)cfP$$gLN@=q>MB<>6GFp!?(+G22TgNjOCaBQJmmA1e>~eV8+X z)9|fl2*rsHgMyV0rKp6#6{RMMAw-o*>jy<}3D9-&&O+ydeU+q0ib#vW2HIg!0_``uD#5 z#JE*-;ga-4DI7tW$>T#bDKzNn>93Tw;3dGbP?@fY` zKO!dxyJhsEB>rDO!Ffkf2L+ND^ml(2O#k><{{Zft8BO0=fsk{9c|EK+HE89uga-BX z3?UfU!WTK-cs3cr<(5&&c!0O#1r8Pn$E=*=JS=)PQ4f9hR}V3UI8Om#It94UZ7e9=-Mw_h?+K_wpOt3QlYiRRkruXl=o1YB*br< z<7iTGUY?bEbK7WF ztktO~OiZ_|IiE<51A;fKxq{3=DZeotl>hU_0&$-AmWmpoK_~b1&Kg;1Ts|>u(R$n3 z^c_~pi#69%l{SbeX}2!jF+Cl^jWYSa&GooOeIT%VLdAh@s8BC1B&6Wyo(~J=df>}l z9?a#TFkgWV)u?AbFU&|cz1R?(>@HF_OzWmmx=m^jB$U!>6D~oBP>O;83@^O`2w&U2 z=(ko9td#2Zp|O?_-Fmsq-s!f}z`n&!NKONX zD!4`L%bvzgoH5AdnNXh_ETj~rX~wng7&ZGD3#k>63iakh>AK~`RVUO5_cVnB;Ke~f z=F4Fjr#*UQ7vklaMLg_V3dC?hiBp6=QZl?B5YBh&MKAs*m&)wOZ+_>(6O$@*rNQ~= z-j^+-Y#ZeuAwU=!)Hgtg3=XbaMwKBH)O}%_A5>ECqwEPKL#Q`fMzcqB`uWs~2Kj`3 z19lSS244ppN^W(_C@n&caAqiaA_>-X%1+E%ARJYomrIl-03rFoHAC1PqGdmId=*eF zB-a}A#u$LGRVV&B;xmLqBuRLDNek4Eq|AH;?+y?a39pjnMj5t={*7L3zf`I>0OiIV#_GoOM`Br!3_{98ax{>hi3WRZ2w!@ta zVQKLCX9zELbcxeAIVhEq5&RFtN0%8B5ANyHI)Msjve#^o@!WPFf3LkAuEKg#|(F>8!r4Q2Ez&*qNi~=dN7f8rAJHX zGJNOk-vA?dK`fFe=R~0=EJ%m+{?H2k1qvB7jA>nkGA&bQ?~G1q*aThptO+ z4@}5CG9(4nvS+k{Cxkb$(q3(VVho+INRV#}g?d|%f;e`UmL<1}oEA2WCQ)dFv@`>V ziY=75N`jjq4D+oR*S=s#Ur$Z7bk1oh$OW8Y!HQ*XBEIejCC`U9V27HA&aa`3i(e8? zq^wqCs-Bhe2b$wTaab|o3-)bT5kWj;@mV?ydA%z}eRXJVj`77Zql`hsm&1TMX-Fq* zgCo<45dFD6{a8lxG*Hu^2k+6i#jFutYjKU~R!|TQly=_)Awc+|e}3l*85Eo}p;1sQ zNH;+E^2ra97o-|twv2MWsARg?G71ptnYT3P_V&zt*-Jli`3T|2!?*qKwOhi$k#rvb z1XGiqyJwOhs2b-iWuY7`_x z*c4#Zj_CnH&Jd=^gx?Su##6V9zU>#fTl9NRPaQ&7F!{lY4WqWWEeg_Z<@fWxx91h1 zP}Bp5`wWDY&5qGN%@1;jP+iHd5)&E1i_?HfhDz0Yd=E7UFUrKjg9SqV&hf<}D7*xM zQSYB2q#Gb~O4ON0XVmlq+`qY;9dxU2$r0*3CB06@T{DEaW&;94QG)PHTShMfAxA7F zQhKKh^2V8!%V{_csL|a~Mmx?7t^C;q2tUuyu9Dx{$q>3hy*3aYo#Hg(={r|LrIQ&8 z6c-=_9D8TIt4J(BNP=t45PDxK)v8xIdX(*ISru^nJif}^Al$PBgnMMvdTq-Qz*C!8 zyFHXq5_~mu`)lPP01ZKF4zD#U;@juFnNoYbU7o)1gn0)muf)BBOdABNei|a9b+B5H zJw^KxC4k{BW zYEud#U2}M~(&EHXq=Wb}h|^lvW`Is;7s1}(!_iv?C^0x4xHp<=QJl*%8f19GcgPqN zr<*zR9%mI)#f}|s4iPSQzf@Vqp}0NS93hP6wyqTc89J^xLIeesI`wmzC=@uF6M?df zh6T9W+&L4v!^g`I5Jb0RN=Y$=29@0d{CWz|EsW?spa|N@iI0FRpp`Vh@v;pYZh5bx zKw&$yg5&)P)0gwou1K@W?iz)(=~iz{3aKlTSO685ZAq{7&QAPl84x#tg+XF$N}UcsP5 z9U>r9#fc3JDhqh08A5*#3kLbzF1L)1-1+Gb%YxPZ#oD?2YMM=9{CrWU&uP~n!H}ey zfutqX327w~112?~I%R+X5*3|PG~yCMT8OEE|3pN@B@%Is`z46EseymMdY<2TJl?av zw@($l-uK;m?X}k4UzPjM+Iz3BzKdH%n>rK_y8fX5Um3Q8lNuyL_)=KTWT;FZNk3EK z8;yfKNLbP6-c7{`96d>rEu(sqC(Ieb6rI=yLP)RD@#1-ruapt}tvS5$wGQFDlOW8N z(Xbu~MetF?2NME-$K_xm+^A{Ot#t_ZVUTVwMTo3MfDi_Q0)u8)PM#rr@JYZt zgg(9VrRv1I$nF_J(sX*kP&RQ&ZyiFtNDB}qLFf*RsuS*ZV#*Eb#+~%{2bIZ&a3TV-T+3B!Z)sz%2B=n39FUmpoPrCb*r@(-p z`SNQ>azdY)5~2H5N2}f&XCAOHJ+RVTU(}lGNeZsQxPx`qDJo{%wCQoj0)BNZUQCS- z?iDhNlEG5rSxOH=mN?)`2ZkWyN|XC#92Nki*o2y*tOg~%+&SZkWlFZ7J_Hd8F@0`u zI_XO2&C52>r2V{o)X2yUqwF+=0m%j`P{c@03X6sUM|iJELiaL)gX5>`mQu#=2MD1- zwGVKsjJJ&Li1&)BQ3nQ=3*YaW^yCNAD+{PCqYxp)_xM=#gbG7~LO^E-2m0;>0rQ3n zpZe?op+;&foFU|PQP9xIK^j4wh>$Y#K1is~h3Br&LV$q@c(n;}g0&kA}B9SH_|S!g$p(jKQ+U^a|K1cVf(XTm&YN<{fZ^ekk< zaXN*|r5KOy3Iq89gZoIxtEa-IP&ZL};b^vz8VEyzBi2!*EK)`G6Q|CN$WXZ8$`kKS zt-zoCp=IgkeM3&&9d>cBJh+d6BnRE7NTCvGUUf7l*?5u=LG2d3bcS%O6sC|S9%O*Q&RZ)Gf`4+-wL-lf6xN6^igx14 z62e4!(SX!5y~nX~%geM9b=)w@a9j%zva@uYn(1fy1qjD_@=}@pkJ@KGz5<~<0nX{H zUJm;bG0$Cwu&I~k)T;Z>K)=U>Z%;5yZ|LYAkC5wm&?Ako2nT24DeK}{i>_dI^Qbi&W){BR3 z3E<;`Q9B3|;qjp=6X;wZr6$M-$}(S4cnaQ@m1hhhs*4)EO3Bf0EQ$3C;t`O@4f$=C zBd|9N8~28L|x6D-K5`!7_n^mv>mtxenFR6Kevn$qXsK{=U`xtj$#raNMbSQl}?VZKi|7d zH8pV;YvX@V8Ng$MC?H3saCmU=ZrU*rW4j|kYogVz{2_T#kgcn!JxJ!oc9o{cmD16*3IIf$Y zm9`Y7SAvlIAW&F^eWPD|9QlyGdB4gHzsd~eiYFJGP$LLbcF`5YiS1NfPd62!nhRA%zyfTrwoBx5)~^dazsS5Sj?HZPb^_ zkPB1~99GFtA$}R)paTdcz#wS2kAt)nrEmJLQbSmfAwYNyB7zCY5b}{B+>tv0!kiy; zADLmdj9zuAOs|v)3j&0tK?yLp;$RbnhQb8{tIn&393Ve?&}-Fa=vGk$)S_hUc=0wKZ*rY#EY0%3E5Y9it~;S#AxcLk{R4587i zP)LHXB0nf7s-vu8fsXDJ5aKH7rPD*B<)({3cai6v0K%~b1HH-5%5+DpY!K~XH(w9$ z;?w5}8EpZ=qq2w23~2H}-TA34qcX2&2yMM{?WQDv*{Mk>?Bui-Bx& zJ*VRzo&enp_jvH*pCb^&a_-bxlRMH_e!Y|CnM)}?eixElWqdUg(Ycp^i)Fmr3VO`0 zrP^WtI+_|+D8$sBccnzs8g`bbd*`?!Bv;sLPO;|BPP8~~%ch>xP1kyq zmYt^rf{0W)#9oF1v*Aa{dOhfsHkj*QRnIK5F13rR%wlv129yG>^SMf8f%5MWdwk4+Ewa;q`f)A6T~G2nBEo|j5thmDMjzofXKMj0)z-G==LqWUz8N%aeKcg%fOmwA@muzN-YgdCTI2tgVD{{5dlrm?9E94rt9Tn}UnIYKCHbD4~qD}Ych zKnNG*?|%lQ2|Ak1wHd^Lv{PXb|Say}X`8ILE<5Ps(EUvBVqW$O6|G4RAyc^#-U{NS~r}E5j*mF1q8}i zt#r5%fN+m*OOZABEd{n~K=cJ1-PGK`@Z(PP_7y2{9qR6+tCXJa+vmTihO8AE`_)6R z-p<|-YNaz?EtK1Y!F8L3O?#+xMNjP6C92D3>VOD^e&dmZ`9=hB(-Ja@7!hI&!+4OK7feDC;Kfy)KG9y-_+?g6@3Mor?P$p30gBVY)!NeKp-eB(* z9or-X4^j_GDQYRuQVT+a+mnLL=w+}nUZbn5u1?Q|qdS4Y!}6z4>DtBBd(&HFnMOH6 z?i9^gLGF~{@L(>4kp<0$@*jc+7mm6+D^3%jPXHR~)RPZP$-&T{G&ORer6`5su3smR zX-qKaJ#6V|3d@?BJ@$ve zK{u`^A;`;+iW7r#9&xHLw~1<7=MbWxkO~tDRiF`4EIFOt2?hORl>+y52Pq~7gb=Lu zQ`s;|r{`H=aNb=Y{OzSd5`)8nK;ipckUltwTpz%DS2v7OnBLn(-~6c`{NSHTAnK!` z8}C=kgGE6)e^0SLR9|Wi=KqvYV;r29qkkg8)FK>Z#0IhA2Z#Dr^s8Z4dXTU{=mep< z-3(!w57DFyD`K;3`veT7lis7bJITTDAViomgoN}Cnb4p;$1G{9E$kR2hNYbrRC6T| za(ZyD3SmdwC4-W}^cNYrYq?N{Ci+`Wp&;G+1caNy^u8hnA={>xd>7Uy7a-)9{I&`r z>hW@6`jK~A-jasC%C6k+@j;&Z7a({{3nPj3KKfCe6GUHtFc;By2on>ZHmJG&Zi2!8n%xr@yP$2T!3)=#wSgp zEcLW&hL9t$YliU7$D^+@Yw69UGNR4(2qAw+?Q45(fbdY3W9gufek1VtEIjz8CTD2C z!AP)-AKAH}^t_Y$`vty*=#n^I9P3un{eV@dG|`ZlmYm&E1ip@{)#%Gq>I!ApSg-6@ zchv<)@pd($G+y_eHlQmr-X1{g+dm*)^ehh*O7)n_V?u=hA4C{B#B76f$`Ur&=T@@W z3JR(*E&>vWnhAx!f}{0}yCNYYiNIA2W$4Y&=NpGP7&xtt2F}$o(O0dRLx6D{ z(;El**Qr-=vuy$;q2EWvY~^7Nb@ z?8Pz$y@Fr}QDSW)_;Ec{VfyDU>gukm26`B(QRw=XIe3^63w>=hVFd3Acnu8RF-1HU zEm%lkm~b=S5|Z?fNb^oh1`TFe+nMQj#}Z@}ms5vEQ+-w*0thcI6V0&>R$LA9Cpy4Q^Y1#~oewybp71XI9EPF(|W%SaGG72I9MR0yy~>bP94HueNgDfBcY2* zo86=w@*~?PI#i2}+--Ug9Gn**Oy-aFn7m&lz(`YTu<-8fRW=w{^R6uWM!$o-@-;Xx z5JtDRW%N5VygxtQ4iD-Xc`_^s<-vChNUd#w@ORD-rs$yO@sNsQ%V=Pb3?b(muT47> zU^a7S#1li7m<=5t*lZgW5Gs&Ao7eHn9iMlwY1pHJtwU&X1PBX-8=(r}l||_RLRL&F zO;1vBp+OK3m9|nQ+)WRT2nekq22Z1Be)lX~cs!9IRJhU2{UKM3HbV#~dW|q$g0(qS zgSV|fSYRv{768En{2NDj@Dvb&g7%gKWgM#n|9OrC4rXz%kaE-KhZP7Nc<#NWqxjm1 z6F_({ES@Smv0z-6-tJ(t+*#`OiALd_=L`rBP$+-@?Y*EOq%S>N4Sr*6Nh*n#mE=CR zSJJ*SY2o6+U3D0(!)kQPu(K-%;ml%Qs!Wv5>Snw+3vJQcU|vGoC8`|+)+qF~Kp@U8 zfdx-(9d|ut^#XVaEJCI}1x^MyoM%kfs9+(Chj;?a#}Pt_TDVZaMU-g~4x?>;u5`y^ zMFFKlKElg`G%<7{fweL0^i7~GyvhpBXj-loWq-L)(Ib2mE94d$?F&_5!m&zE0efFo z%Aem<2%N1*7X@{v3VpjeYh;0!cnOw5sW2)tl224RP>*C(B)84FM^&A z!JcmQZd?!{bO+p7F%^;^M7VMW9vmYZMn_QW9>KGu^lsYiRVw=^Xy)BuA}WH|XD4iHj0;t_8Q6$rXD7n1dp2>BEs zzk+qo=rk5ZY3zg|rwBb}b1CUT_z#s1Fm#975DZ*0+FNBl{JPi8tam_v$Rn?NB82k@2qnH$=13H+7D5jK1{kf&%@4`p5!%s@e^v6e4G@0pZr0@2031GQ3f}!; zq&q|{33enM^YG4tZ6`y>#WG(M5JH5Mn|QMOJ-tX%j_2rWh7f-XPOt#!y8_|hAVdhZ zWs@o4FeH=tV;^J$vuKY1Ap#I;HVcW1+)zW6MA%Zc*6t8SGZ)f{P%%3+=>qciD~2ww+;ri3myZQD=QEJRssh9lOrD70AVUp zgaem^;r!GoAWRHFMK~bQU9A-z4cr031U_$`^u<9FkBk@R2&X@inmdT~#<`ZA;A%l~ zT0z6(&KbhP3WO~qvB;xdvRmFnq-(80sD`$A&%^_rQ;dd;YKk_vGD;woX*n1%(nSnmigOb9Y=GpsB*m*JEh7-qs3q^=O~6og4?LER^TBtELVeEvxl<)U5{JWOl%HomX+o!$*;kX! zSl$U`vM>;!q+Z1<^umCa7NAFGWT2En2;N*Wn*E}xFt`HYSl&4bM5wM?Mt8G>y(dd+T;l+A8y@C?P~BB-8;yjhY;sF8M%m zf+LK$ScW~MpZ(BsdXNm^)nEt@k|7Kd((C|XInZdxC}UJCVzE}2cOQ^*-f)v4G!Y^L zgdxWXLqXPZ^b_aM#__9|`1r+-0D_f|XeO)DTnmapdjZ0Vfbbd|MBq%h)&wC)W(WQP zVPQ}>sPB_GL)Z)<%Q9Ia+M%YyPKHnc5Sa;VhQ=|*Zx9B~N2d&7QjN`J@?&gGMV$uK zv_rgra6^Rm6$*=G9d}flx?$Rl(|qN zF}yRfakRth7a(NW1^5^wcUc#U*RYo%te3 zGie1v{@3LKgoicf*W@2R-rUFh85R<|!ySmv@uf0JoD>ZiAveCfpVe#}|8ht&ra;&A zIa0YCiCm<`B#LMd_{Q8Qw3;K!u-HcnB+@NR8OqvA9L9gr+0jCB8+%JU#LC(QxI`2R z>s}zV_Dwx`e^?vPU+$kNVZ8bAGGgPfC_h+?O-({6&>yDe`F}Q0&Md7W(ZEUCkQ2uE@o{Q{A@g3zI0;B@rJcl#t(;ulXp-_3}JLiJZ zA;erds!?RL1qF8u&F5uQM?6KnSaGCXFtFn$CAnqv;sQRX@KrM)J*Y4FKqm^zf3`+5 zAEIHYXB&By0CCr?N`U|kd^5rgHKtnOrr;o~*d=&u6A7k5JxtguWtu76FyPD48p1Zy z6$l@qsyxy}U{%dRuL;3cU|iI8w%0&NwtX^(pq3l7cz|WI5}P(fbLuh{xg}T%9_Gud zZj%GW&5qF)AJn?iC}L>DO9~N_oQX!^x*3O|K9DHq2i}eDi#{Pjc6QGGP>RkwRS25O z;8TK{I^Ebk5(#RcPfM5;wOX@&R5xsC`ihKh0)uGpX?jraXICJ6-HRW2@r&P?BZSl; ze6|ds%F*|tQKMjQ8HEOWe6V#0!NFDD2?bi{zWBI`Pi#oA1E^4s-lyNC2`M{k5-vV@lwe|NQh` z7%r7bQF^y2Jn@!5WG@#*dhaMgSPK-c(qe|6ED1JAc+4aKNmqsz3hypw2ya7E8-#@6 z(lMKM=a$jjA#+=;geykbGD@;eFF$}rGH%Tfii40u6YSAjNe@~;!Z51|6W*f(3D)M19ope1 zWdZ%D)&RD7#QCYFIx{JMe*+;nSRia(EojC!%S8hO=gF#PowNdBN=m2?5LyuXwH|52 zkpiOUsrk|Zgg07zkT0NHwye6y4iF0YNFhRl`Zi|@rE}FN5XLo|P8Z6s0HL_iLDP(? z=Xf{`geS{R>~q{RDuh8&O?0Ldq#A1@;;>CLAZ*nU2YjAIhf}+ zsY+4uM4K7?MWq^9C4U-i8EyFq=>vrD8MkXT+#k@FEu&?^0m4+#pS~!F;V2#i2h~J8 z{^nx}1@vGwA?FOhLg|6UZ0FSP+tkv0_G?2af4;L*K&TZo6~LUl z{S6n&agLcN(uiaci)9{c%R!U_Lx%(>ABd8OHi=5tlY%w->5rkbmn!ztc|p^mP*CKj zIWtttjWc=$e6BX(@)U%!!hVDwv?dI359k%i3ZT5yOGTk^_DuLV1^1kag;1n#4{Mm%aO&V zLQ~~J0pld*fyDerS$cX`6r`N|n4Hd=R>?oT20(6`(S*?k78S0alY`x#@(?JHgzr>p z9t|BTWM~f!5f|;vAwiIk3uJtR@O2v?eDR33jEV)_3J6bT2!9(MRNE^?tIU?sITA~X z5HS)0gxo5#$kQ)N^?FC&d>R_8lme?cKZt1G==JN|^J`(Zs9T|*QIJCP>ec9TC+9S8 zJh5rimT&(_Z#l3kJeV_t`42Su{Vk&gz!G6p_tuf17=Q+{IhS_~!yQ$aUQa|!NQUs0 zrMccTDidIt#oKA&6U-UQ-H#OnD}Vjrd+#0H7- z97DT5c?t;KjQm{91_+bgQ`79ATFKrmqw>1jJ77x1hAXW!L8uz@nB+61dcmCxAz;N!HjGvt6L-3_;XExg z0o&zI@$m%+1A0Y4&sL{~b^*ioJ3t8V845=M24i;1RUx3=Ip>8&u&h8>%$d+>)vOsp z_FS$&I1_K6F7oAb{s@w6kX#NN-m;T`1qitqbOUzKCz(kFQGK2?MkObkGs}fE-vm7X1gb2sc((x+`UVXu6tUWxqT?k7Ym>JPWsSNgU zq0BsnLgfW*&<0YV4bCPhSL`cbu*QJ2-ezUsOg>Q?N|%rYl@71sH3>4borjlPAkmv+jkY4a`9^kW z4<@frC|SSxWZ`fiIY1}<)XS$&LwlV2v#QLY&KfExOM!tYOW$*YvPgAaFqUp1CCVR< zju66wZvUm=*ImvK`cjz@Z5Q=>)s|7=;BRxs=x<38`sGp?1Od`ohj4{Lm8MsqqTlfz zaPSloLV|93qEMouguQPR78DXvXu=yqMSr|X6v~AS>s&0eg+X35dlDS<_rXmSLXqJv z5U$sY@|Fpc5Oj}jffKC_DUu=7YaGn2GH;oMpt&I7(S|I9er#f}QcT1Xq`bY-AjRlE z_SQ>x%JgcOyWJ}~Z!VP~jFS=Tt)QVZ#$P+ZgZfY|1EJBw4N$0$haXshP$A^>q!2Y4 z90?F2zwzEtP%Xe)_EUWS{st-Xd@RV8QP-rWnGB)G%Zr%K6r|{6DE+Osj2?r#2gzpF z>haxw?5#4Vm=&)c8)fP_Lb#EHE;A3Ec*_4gg7z+zfgJ^d6|A6P z{1-QkvU}85$EaI?@YX5$_yU9+NnFnmE=3FLGXgt}0Pvk+zB}W|rqab}#|Fg+M)rV^ z;0QAUU57jUkyYNlomvXZp8`R-#ZRw5SQKse{vWEex1q$MB>nz=(8aj;cQ2Evu_#tq zElc8V4JZNDTSuh?Pw~~ zpWBw}M+1f_VZT8^cQHYGRZzQKG=@@L22r+)vkn@@ere;N7@;^$jlj+<=(EPDR~iB} zAqw;kDm$S<_G1-8Dt;cWCDjiH3767?R~H#PC9OLcg+bSCj}a=YuLAr@8Bx&*Rh&Q{ zZNw~ikt{?(q{^7wAQ)H~b8JPz28AQ*hxGKN@F0aJAjO;;gzxN=Q;G!}N>*Yqwv6&h zQk}lISL)*>PuPGSB;3ysK7Z{Chz#n9tCk&#+#0%%v@o}94!w+T7m=koLVE9L!bjqu zsViR3ZhYX8;_}vs=*q;3DKo`L=Qk1XC0-VgzGP9MQ*o%82dy_Ds1v%nRWdMOH!5W& z6yj~5GJ2A`F^Tr5FTrNfX}D*`9uHtgfgX?alq1BN%psjxeK6zG;9!!3@7&)qN`{bA zglmRyEl@uV9O#x#8N!QGgla^3RGt5#dGNG+QGq7MoA-o+^Qc=hgjGy}`jRBn$w5iB zjQSlIl&E)Ra9*~IzSfNf7mn9HwWbG25JsWsU`3-|DnoXV_KFN4;HP)FP9|Qi`-BWj zb8b)#ggHF;x4$Ptn8NfE<+PpDRH?@(vAyRJ^D`}BU!e-cSAVo8A1Q!cR+EW(n!YuSn4`FrxbF}-cnTS$)pAuO26^aryT zp*boToXbk~Dy*jq56%$gO@@%AOk@SY8TMWpE|sZd%cyyd4V=N>$39>TEDo;n`FFGa zyn{VS|9OBzc|BRnF8mHW2V(W_FXsRuJ4Tx!R5&!tu|7kj;g@rXtUd|^*gcqdQ) zZ+vtsoFJ?`rlqu)d##vS6Z({*yHg&lC}fozkKK6eu^YRB3t5v{U%_#ed``{5CkVa! zOaBBo8$gEe;pGPZi}F<2iPV~3kK^gE66Y9GpuQK-^ax?(1Q4EM2gmZlg=%+!@UWm) zMLbbwu|)N6o+MuWgxt@QQF%Ls!<)ml&xTgflo<|k142ccm;_rfSS zcs?mWCg><(P7WR!O^{QED>7aA3Az91MSE<5*WFZ72sCl14i zBob-IOnrN27MYyvAop81+lRb-i-z(cm|BQp|5BNf-9FA;Ib;HkQWy3Z>!mF@VV9P! z+?F;>hndNxGRV!wBBKyY9+GvPDTdo_`2^;gWCYssXJjeVa$(VShHMhW!5Y9KGv}R- zxQECOlKKnJG?~G&~KR1J_^Sv@qqSt`xT$WHmme8ur=vG3C z>97#?jtdr4r!3(YwPkcCLkJD()0RA~= z*2HyaaJ2uNA^b39>F3q6l{Zs?P<07~fn)KvWx=QJp{X?w>Wk=d8UOoEj}wF=B0ai+ zJ~DyARd2V2gy`H^ov?3t1r0Rs; zUMfRKGk%;(l+9M^(<5EIc}b+d`N^*@`9X;=?{4l0t%&yCx)iCueGOo0xe4h|3Y~`` zxI63l!FkCJ{`{HSYle_D0LyYAi=WI8_9qWk$(NDc3lPpbLWWRrBL}8Q&Sji0^Tz~_ zGM!`Y7_YW)wbij zPOCnJ=53KCD+2i8QBWcRi};o^i69{tj;cvcut`BibZI(bxB%fpN)Zf*42hvHO5pA_ z%dXSSpwcB#3x8QFV3_G1H