mito-ai 0.1.33__py3-none-any.whl → 0.1.49__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mito_ai/__init__.py +49 -9
- mito_ai/_version.py +1 -1
- mito_ai/anthropic_client.py +142 -67
- mito_ai/{app_builder → app_deploy}/__init__.py +1 -1
- mito_ai/app_deploy/app_deploy_utils.py +44 -0
- mito_ai/app_deploy/handlers.py +345 -0
- mito_ai/{app_builder → app_deploy}/models.py +35 -22
- mito_ai/app_manager/__init__.py +4 -0
- mito_ai/app_manager/handlers.py +167 -0
- mito_ai/app_manager/models.py +71 -0
- mito_ai/app_manager/utils.py +24 -0
- mito_ai/auth/README.md +18 -0
- mito_ai/auth/__init__.py +6 -0
- mito_ai/auth/handlers.py +96 -0
- mito_ai/auth/urls.py +13 -0
- mito_ai/chat_history/handlers.py +63 -0
- mito_ai/chat_history/urls.py +32 -0
- mito_ai/completions/completion_handlers/agent_execution_handler.py +1 -1
- mito_ai/completions/completion_handlers/chat_completion_handler.py +4 -4
- mito_ai/completions/completion_handlers/utils.py +99 -37
- mito_ai/completions/handlers.py +57 -20
- mito_ai/completions/message_history.py +9 -1
- mito_ai/completions/models.py +31 -7
- mito_ai/completions/prompt_builders/agent_execution_prompt.py +21 -2
- mito_ai/completions/prompt_builders/agent_smart_debug_prompt.py +8 -0
- mito_ai/completions/prompt_builders/agent_system_message.py +115 -42
- mito_ai/completions/prompt_builders/chat_name_prompt.py +6 -6
- mito_ai/completions/prompt_builders/chat_prompt.py +18 -11
- mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
- mito_ai/completions/prompt_builders/prompt_constants.py +23 -4
- mito_ai/completions/prompt_builders/utils.py +72 -10
- mito_ai/completions/providers.py +81 -47
- mito_ai/constants.py +25 -24
- mito_ai/file_uploads/__init__.py +3 -0
- mito_ai/file_uploads/handlers.py +248 -0
- mito_ai/file_uploads/urls.py +21 -0
- mito_ai/gemini_client.py +44 -48
- mito_ai/log/handlers.py +10 -3
- mito_ai/log/urls.py +3 -3
- mito_ai/openai_client.py +30 -44
- mito_ai/path_utils.py +70 -0
- mito_ai/streamlit_conversion/agent_utils.py +37 -0
- mito_ai/streamlit_conversion/prompts/prompt_constants.py +172 -0
- mito_ai/streamlit_conversion/prompts/prompt_utils.py +10 -0
- mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +46 -0
- mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +28 -0
- mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +45 -0
- mito_ai/streamlit_conversion/prompts/streamlit_system_prompt.py +56 -0
- mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
- mito_ai/streamlit_conversion/search_replace_utils.py +94 -0
- mito_ai/streamlit_conversion/streamlit_agent_handler.py +144 -0
- mito_ai/streamlit_conversion/streamlit_utils.py +85 -0
- mito_ai/streamlit_conversion/validate_streamlit_app.py +105 -0
- mito_ai/streamlit_preview/__init__.py +6 -0
- mito_ai/streamlit_preview/handlers.py +111 -0
- mito_ai/streamlit_preview/manager.py +152 -0
- mito_ai/streamlit_preview/urls.py +22 -0
- mito_ai/streamlit_preview/utils.py +29 -0
- mito_ai/tests/chat_history/test_chat_history.py +211 -0
- mito_ai/tests/completions/completion_handlers_utils_test.py +190 -0
- mito_ai/tests/deploy_app/test_app_deploy_utils.py +89 -0
- mito_ai/tests/file_uploads/__init__.py +2 -0
- mito_ai/tests/file_uploads/test_handlers.py +282 -0
- mito_ai/tests/message_history/test_generate_short_chat_name.py +0 -4
- mito_ai/tests/message_history/test_message_history_utils.py +103 -23
- mito_ai/tests/open_ai_utils_test.py +18 -22
- mito_ai/tests/providers/test_anthropic_client.py +447 -0
- mito_ai/tests/providers/test_azure.py +2 -6
- mito_ai/tests/providers/test_capabilities.py +120 -0
- mito_ai/tests/{test_gemini_client.py → providers/test_gemini_client.py} +40 -36
- mito_ai/tests/providers/test_mito_server_utils.py +448 -0
- mito_ai/tests/providers/test_model_resolution.py +130 -0
- mito_ai/tests/providers/test_openai_client.py +57 -0
- mito_ai/tests/providers/test_provider_completion_exception.py +66 -0
- mito_ai/tests/providers/test_provider_limits.py +42 -0
- mito_ai/tests/providers/test_providers.py +382 -0
- mito_ai/tests/providers/test_retry_logic.py +389 -0
- mito_ai/tests/providers/test_stream_mito_server_utils.py +140 -0
- mito_ai/tests/providers/utils.py +85 -0
- mito_ai/tests/streamlit_conversion/__init__.py +3 -0
- mito_ai/tests/streamlit_conversion/test_apply_search_replace.py +240 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +246 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +193 -0
- mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +112 -0
- mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +118 -0
- mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +292 -0
- mito_ai/tests/test_constants.py +31 -3
- mito_ai/tests/test_telemetry.py +12 -0
- mito_ai/tests/user/__init__.py +2 -0
- mito_ai/tests/user/test_user.py +120 -0
- mito_ai/tests/utils/test_anthropic_utils.py +6 -6
- mito_ai/user/handlers.py +45 -0
- mito_ai/user/urls.py +21 -0
- mito_ai/utils/anthropic_utils.py +55 -121
- mito_ai/utils/create.py +17 -1
- mito_ai/utils/error_classes.py +42 -0
- mito_ai/utils/gemini_utils.py +39 -94
- mito_ai/utils/message_history_utils.py +7 -4
- mito_ai/utils/mito_server_utils.py +242 -0
- mito_ai/utils/open_ai_utils.py +38 -155
- mito_ai/utils/provider_utils.py +49 -0
- mito_ai/utils/server_limits.py +1 -1
- mito_ai/utils/telemetry_utils.py +137 -5
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +102 -100
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/package.json +4 -2
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +3 -1
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +2 -2
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js +15948 -8403
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +198 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.8b24b5b3b93f95205b56.js +58 -33
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.8b24b5b3b93f95205b56.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +10 -2
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js +533 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +6941 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +1021 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +59698 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js +7440 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +2 -240
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +1 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/METADATA +5 -2
- mito_ai-0.1.49.dist-info/RECORD +205 -0
- mito_ai/app_builder/handlers.py +0 -218
- mito_ai/tests/providers_test.py +0 -438
- mito_ai/tests/test_anthropic_client.py +0 -270
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js +0 -7842
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js.map +0 -1
- mito_ai-0.1.33.dist-info/RECORD +0 -134
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/entry_points.txt +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
from mito_ai.streamlit_conversion.prompts.prompt_constants import search_replace_instructions
|
|
6
|
+
from mito_ai.streamlit_conversion.prompts.prompt_utils import add_line_numbers_to_code
|
|
7
|
+
|
|
8
|
+
def get_update_existing_app_prompt(notebook: List[dict], streamlit_app_code: str, edit_prompt: str) -> str:
|
|
9
|
+
"""
|
|
10
|
+
This prompt is used to update an existing streamlit app.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
existing_streamlit_app_code_with_line_numbers = add_line_numbers_to_code(streamlit_app_code)
|
|
14
|
+
|
|
15
|
+
return f"""
|
|
16
|
+
|
|
17
|
+
GOAL: You've previously created a first draft of the Streamlit app. Now the user reviewed it and provided feedback.Update the existing streamlit app according to the feedback provided by the user. Use the input notebook to help you understand what code needs to be added, changed, or modified to fulfill the user's edit request.
|
|
18
|
+
|
|
19
|
+
**CRITICAL COMPLETION REQUIREMENT:**
|
|
20
|
+
You have ONE and ONLY ONE opportunity to complete this edit request. If you do not finish the entire task completely, the application will be broken and unusable. This is your final chance to get it right.
|
|
21
|
+
|
|
22
|
+
**COMPLETION RULES:**
|
|
23
|
+
1. **NEVER leave partial work** - If the edit request requires generating a list with 100 items, provide ALL 100 items.
|
|
24
|
+
2. **NEVER use placeholders** - This is your only opportunity to fulfill this edit request, so do not leave yourself another TODOs.
|
|
25
|
+
3. **NEVER assume "good enough"** - Complete the task to 100% satisfaction.
|
|
26
|
+
4. **If the task seems large, that's exactly why it needs to be done now** - This is your only chance
|
|
27
|
+
|
|
28
|
+
**HOW TO DETERMINE IF TASK IS COMPLETE:**
|
|
29
|
+
- If building a list/dictionary: Include ALL items that should be in the final data structure.
|
|
30
|
+
- If creating functions: Implement ALL required functionality.
|
|
31
|
+
- If converting a visualization: Copy over ALL of the visualization code from the notebook, including all styling and formatting.
|
|
32
|
+
|
|
33
|
+
{search_replace_instructions}
|
|
34
|
+
|
|
35
|
+
===============================================
|
|
36
|
+
|
|
37
|
+
INPUT NOTEBOOK:
|
|
38
|
+
{notebook}
|
|
39
|
+
|
|
40
|
+
===============================================
|
|
41
|
+
|
|
42
|
+
EXISTING STREAMLIT APP:
|
|
43
|
+
{existing_streamlit_app_code_with_line_numbers}
|
|
44
|
+
|
|
45
|
+
===============================================
|
|
46
|
+
|
|
47
|
+
USER EDIT REQUEST:
|
|
48
|
+
{edit_prompt}
|
|
49
|
+
|
|
50
|
+
"""
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
from mito_ai.utils.error_classes import StreamlitConversionError
|
|
8
|
+
from mito_ai.utils.telemetry_utils import log
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_search_replace_blocks(message_content: str) -> List[Tuple[str, str]]:
|
|
12
|
+
"""
|
|
13
|
+
Extract all search_replace blocks from Claude's response.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
List of tuples (search_text, replace_text) for each search/replace block
|
|
17
|
+
"""
|
|
18
|
+
if "```search_replace" not in message_content:
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
pattern = r'```search_replace\n(.*?)```'
|
|
22
|
+
matches = re.findall(pattern, message_content, re.DOTALL)
|
|
23
|
+
|
|
24
|
+
search_replace_pairs = []
|
|
25
|
+
for match in matches:
|
|
26
|
+
# Split by the separator
|
|
27
|
+
if "=======" not in match:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
parts = match.split("=======", 1)
|
|
31
|
+
if len(parts) != 2:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
search_part = parts[0]
|
|
35
|
+
replace_part = parts[1]
|
|
36
|
+
|
|
37
|
+
# Extract search text (after SEARCH marker)
|
|
38
|
+
if ">>>>>>> SEARCH" in search_part:
|
|
39
|
+
search_text = search_part.split(">>>>>>> SEARCH", 1)[1].strip()
|
|
40
|
+
else:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Extract replace text (before REPLACE marker)
|
|
44
|
+
if "<<<<<<< REPLACE" in replace_part:
|
|
45
|
+
replace_text = replace_part.split("<<<<<<< REPLACE", 1)[0].strip()
|
|
46
|
+
else:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
search_replace_pairs.append((search_text, replace_text))
|
|
50
|
+
|
|
51
|
+
return search_replace_pairs
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply_search_replace(text: str, search_replace_pairs: List[Tuple[str, str]]) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Apply search/replace operations to the given text.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
text : str
|
|
61
|
+
The original file contents.
|
|
62
|
+
search_replace_pairs : List[Tuple[str, str]]
|
|
63
|
+
List of (search_text, replace_text) tuples to apply.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
str
|
|
68
|
+
The updated contents after applying all search/replace operations.
|
|
69
|
+
|
|
70
|
+
Raises
|
|
71
|
+
------
|
|
72
|
+
ValueError
|
|
73
|
+
If a search text is not found or found multiple times.
|
|
74
|
+
"""
|
|
75
|
+
if not search_replace_pairs:
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
result = text
|
|
79
|
+
|
|
80
|
+
for search_text, replace_text in search_replace_pairs:
|
|
81
|
+
# Count occurrences of search text
|
|
82
|
+
count = result.count(search_text)
|
|
83
|
+
|
|
84
|
+
if count == 0:
|
|
85
|
+
print("Search Text Not Found: ", repr(search_text))
|
|
86
|
+
raise StreamlitConversionError(f"Search text not found: {repr(search_text)}", error_code=500)
|
|
87
|
+
elif count > 1:
|
|
88
|
+
print("Search Text Found Multiple Times: ", repr(search_text))
|
|
89
|
+
log("mito_ai_search_text_found_multiple_times", params={"search_text": repr(search_text)}, key_type="mito_server_key")
|
|
90
|
+
|
|
91
|
+
# Perform the replacement
|
|
92
|
+
result = result.replace(search_text, replace_text, 1)
|
|
93
|
+
|
|
94
|
+
return result
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
from anthropic.types import MessageParam
|
|
5
|
+
from typing import List, cast
|
|
6
|
+
from mito_ai.streamlit_conversion.agent_utils import extract_todo_placeholders, get_response_from_agent
|
|
7
|
+
from mito_ai.streamlit_conversion.prompts.streamlit_app_creation_prompt import get_streamlit_app_creation_prompt
|
|
8
|
+
from mito_ai.streamlit_conversion.prompts.streamlit_error_correction_prompt import get_streamlit_error_correction_prompt
|
|
9
|
+
from mito_ai.streamlit_conversion.prompts.streamlit_finish_todo_prompt import get_finish_todo_prompt
|
|
10
|
+
from mito_ai.streamlit_conversion.prompts.update_existing_app_prompt import get_update_existing_app_prompt
|
|
11
|
+
from mito_ai.streamlit_conversion.validate_streamlit_app import validate_app
|
|
12
|
+
from mito_ai.streamlit_conversion.streamlit_utils import extract_code_blocks, create_app_file, get_app_code_from_file, parse_jupyter_notebook_to_extract_required_content
|
|
13
|
+
from mito_ai.streamlit_conversion.search_replace_utils import extract_search_replace_blocks, apply_search_replace
|
|
14
|
+
from mito_ai.completions.models import MessageType
|
|
15
|
+
from mito_ai.utils.error_classes import StreamlitConversionError
|
|
16
|
+
from mito_ai.utils.telemetry_utils import log_streamlit_app_validation_retry, log_streamlit_app_conversion_success
|
|
17
|
+
from mito_ai.path_utils import AbsoluteNotebookPath, AppFileName, get_absolute_notebook_dir_path, get_absolute_app_path, get_app_file_name
|
|
18
|
+
|
|
19
|
+
async def generate_new_streamlit_code(notebook: List[dict]) -> str:
|
|
20
|
+
"""Send a query to the agent, get its response and parse the code"""
|
|
21
|
+
|
|
22
|
+
prompt_text = get_streamlit_app_creation_prompt(notebook)
|
|
23
|
+
|
|
24
|
+
messages: List[MessageParam] = [
|
|
25
|
+
cast(MessageParam, {
|
|
26
|
+
"role": "user",
|
|
27
|
+
"content": [{
|
|
28
|
+
"type": "text",
|
|
29
|
+
"text": prompt_text
|
|
30
|
+
}]
|
|
31
|
+
})
|
|
32
|
+
]
|
|
33
|
+
agent_response = await get_response_from_agent(messages)
|
|
34
|
+
converted_code = extract_code_blocks(agent_response)
|
|
35
|
+
|
|
36
|
+
# Extract the TODOs from the agent's response
|
|
37
|
+
todo_placeholders = extract_todo_placeholders(agent_response)
|
|
38
|
+
|
|
39
|
+
for todo_placeholder in todo_placeholders:
|
|
40
|
+
print(f"Processing AI TODO: {todo_placeholder}")
|
|
41
|
+
todo_prompt = get_finish_todo_prompt(notebook, converted_code, todo_placeholder)
|
|
42
|
+
todo_messages: List[MessageParam] = [
|
|
43
|
+
cast(MessageParam, {
|
|
44
|
+
"role": "user",
|
|
45
|
+
"content": [{
|
|
46
|
+
"type": "text",
|
|
47
|
+
"text": todo_prompt
|
|
48
|
+
}]
|
|
49
|
+
})
|
|
50
|
+
]
|
|
51
|
+
todo_response = await get_response_from_agent(todo_messages)
|
|
52
|
+
|
|
53
|
+
# Apply the search/replace to the streamlit app
|
|
54
|
+
search_replace_pairs = extract_search_replace_blocks(todo_response)
|
|
55
|
+
converted_code = apply_search_replace(converted_code, search_replace_pairs)
|
|
56
|
+
|
|
57
|
+
return converted_code
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def update_existing_streamlit_code(notebook: List[dict], streamlit_app_code: str, edit_prompt: str) -> str:
|
|
61
|
+
"""Send a query to the agent, get its response and parse the code"""
|
|
62
|
+
prompt_text = get_update_existing_app_prompt(notebook, streamlit_app_code, edit_prompt)
|
|
63
|
+
|
|
64
|
+
messages: List[MessageParam] = [
|
|
65
|
+
cast(MessageParam, {
|
|
66
|
+
"role": "user",
|
|
67
|
+
"content": [{
|
|
68
|
+
"type": "text",
|
|
69
|
+
"text": prompt_text
|
|
70
|
+
}]
|
|
71
|
+
})
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
agent_response = await get_response_from_agent(messages)
|
|
75
|
+
print(f"[Mito AI Search/Replace Tool]:\n {agent_response}")
|
|
76
|
+
|
|
77
|
+
# Apply the search/replace to the streamlit app
|
|
78
|
+
search_replace_pairs = extract_search_replace_blocks(agent_response)
|
|
79
|
+
converted_code = apply_search_replace(streamlit_app_code, search_replace_pairs)
|
|
80
|
+
print(f"[Mito AI Search/Replace Tool]\nConverted code\n: {converted_code}")
|
|
81
|
+
return converted_code
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def correct_error_in_generation(error: str, streamlit_app_code: str) -> str:
|
|
85
|
+
"""If errors are present, send it back to the agent to get corrections in code"""
|
|
86
|
+
messages: List[MessageParam] = [
|
|
87
|
+
cast(MessageParam, {
|
|
88
|
+
"role": "user",
|
|
89
|
+
"content": [{
|
|
90
|
+
"type": "text",
|
|
91
|
+
"text": get_streamlit_error_correction_prompt(error, streamlit_app_code)
|
|
92
|
+
}]
|
|
93
|
+
})
|
|
94
|
+
]
|
|
95
|
+
agent_response = await get_response_from_agent(messages)
|
|
96
|
+
|
|
97
|
+
# Apply the search/replace to the streamlit app
|
|
98
|
+
search_replace_pairs = extract_search_replace_blocks(agent_response)
|
|
99
|
+
streamlit_app_code = apply_search_replace(streamlit_app_code, search_replace_pairs)
|
|
100
|
+
|
|
101
|
+
return streamlit_app_code
|
|
102
|
+
|
|
103
|
+
async def streamlit_handler(notebook_path: AbsoluteNotebookPath, app_file_name: AppFileName, edit_prompt: str = "") -> None:
|
|
104
|
+
"""Handler function for streamlit code generation and validation"""
|
|
105
|
+
|
|
106
|
+
# Convert to absolute path for consistent handling
|
|
107
|
+
notebook_code = parse_jupyter_notebook_to_extract_required_content(notebook_path)
|
|
108
|
+
app_directory = get_absolute_notebook_dir_path(notebook_path)
|
|
109
|
+
app_path = get_absolute_app_path(app_directory, app_file_name)
|
|
110
|
+
|
|
111
|
+
if edit_prompt != "":
|
|
112
|
+
# If the user is editing an existing streamlit app, use the update function
|
|
113
|
+
streamlit_code = get_app_code_from_file(app_path)
|
|
114
|
+
|
|
115
|
+
if streamlit_code is None:
|
|
116
|
+
raise StreamlitConversionError("Error updating existing streamlit app because app.py file was not found.", 404)
|
|
117
|
+
|
|
118
|
+
streamlit_code = await update_existing_streamlit_code(notebook_code, streamlit_code, edit_prompt)
|
|
119
|
+
else:
|
|
120
|
+
# Otherwise generate a new streamlit app
|
|
121
|
+
streamlit_code = await generate_new_streamlit_code(notebook_code)
|
|
122
|
+
|
|
123
|
+
# Then, after creating/updating the app, validate that the new code runs
|
|
124
|
+
errors = validate_app(streamlit_code, notebook_path)
|
|
125
|
+
tries = 0
|
|
126
|
+
while len(errors)>0 and tries < 5:
|
|
127
|
+
for error in errors:
|
|
128
|
+
streamlit_code = await correct_error_in_generation(error, streamlit_code)
|
|
129
|
+
|
|
130
|
+
errors = validate_app(streamlit_code, notebook_path)
|
|
131
|
+
|
|
132
|
+
if len(errors)>0:
|
|
133
|
+
# TODO: We can't easily get the key type here, so for the beta release
|
|
134
|
+
# we are just defaulting to the mito server key since that is by far the most common.
|
|
135
|
+
log_streamlit_app_validation_retry('mito_server_key', MessageType.STREAMLIT_CONVERSION, errors)
|
|
136
|
+
tries+=1
|
|
137
|
+
|
|
138
|
+
if len(errors)>0:
|
|
139
|
+
final_errors = ', '.join(errors)
|
|
140
|
+
raise StreamlitConversionError(f"Streamlit agent failed generating code after max retries. Errors: {final_errors}", 500)
|
|
141
|
+
|
|
142
|
+
# Finally, update the app.py file with the new code
|
|
143
|
+
create_app_file(app_path, streamlit_code)
|
|
144
|
+
log_streamlit_app_conversion_success('mito_server_key', MessageType.STREAMLIT_CONVERSION, edit_prompt)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
from typing import Dict, List, Optional, Any
|
|
7
|
+
from mito_ai.path_utils import AbsoluteAppPath, AbsoluteNotebookPath
|
|
8
|
+
from mito_ai.utils.error_classes import StreamlitConversionError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_code_blocks(message_content: str) -> str:
|
|
12
|
+
"""
|
|
13
|
+
Extract all code blocks from Claude's response.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message_content (str): The actual content from the agent's response
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
str: Removes the ```python``` part to be able to parse the code
|
|
20
|
+
"""
|
|
21
|
+
if "```python" not in message_content:
|
|
22
|
+
return message_content
|
|
23
|
+
|
|
24
|
+
# Use regex to find all Python code blocks
|
|
25
|
+
pattern = r'```python\n(.*?)```'
|
|
26
|
+
matches = re.findall(pattern, message_content, re.DOTALL)
|
|
27
|
+
|
|
28
|
+
# Concatenate with single newlines
|
|
29
|
+
result = '\n'.join(matches)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def create_app_file(app_path: AbsoluteAppPath, code: str) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Create .py file and write code to it with error handling
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
with open(app_path, 'w', encoding='utf-8') as f:
|
|
38
|
+
f.write(code)
|
|
39
|
+
except IOError as e:
|
|
40
|
+
raise StreamlitConversionError(f"Error creating app file: {str(e)}", 500)
|
|
41
|
+
|
|
42
|
+
def get_app_code_from_file(app_path: AbsoluteAppPath) -> Optional[str]:
|
|
43
|
+
with open(app_path, 'r', encoding='utf-8') as f:
|
|
44
|
+
return f.read()
|
|
45
|
+
|
|
46
|
+
def parse_jupyter_notebook_to_extract_required_content(notebook_path: AbsoluteNotebookPath) -> List[Dict[str, Any]]:
|
|
47
|
+
"""
|
|
48
|
+
Read a Jupyter notebook and filter cells to keep only cell_type and source fields.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
notebook_path: Absolute path to the .ipynb file
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
dict: Filtered notebook dictionary with only cell_type and source in cells
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If the notebook file doesn't exist
|
|
58
|
+
json.JSONDecodeError: If the file is not valid JSON
|
|
59
|
+
KeyError: If the notebook doesn't have the expected structure
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Read the notebook file
|
|
64
|
+
with open(notebook_path, 'r', encoding='utf-8') as f:
|
|
65
|
+
notebook_data: Dict[str, Any] = json.load(f)
|
|
66
|
+
|
|
67
|
+
# Check if 'cells' key exists
|
|
68
|
+
if 'cells' not in notebook_data:
|
|
69
|
+
raise StreamlitConversionError("Notebook does not contain 'cells' key", 400)
|
|
70
|
+
|
|
71
|
+
# Filter each cell to keep only cell_type and source
|
|
72
|
+
filtered_cells: List[Dict[str, Any]] = []
|
|
73
|
+
for cell in notebook_data['cells']:
|
|
74
|
+
filtered_cell: Dict[str, Any] = {
|
|
75
|
+
'cell_type': cell.get('cell_type', ''),
|
|
76
|
+
'source': cell.get('source', [])
|
|
77
|
+
}
|
|
78
|
+
filtered_cells.append(filtered_cell)
|
|
79
|
+
|
|
80
|
+
return filtered_cells
|
|
81
|
+
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
raise StreamlitConversionError(f"Notebook file not found: {notebook_path}", 404)
|
|
84
|
+
except json.JSONDecodeError as e:
|
|
85
|
+
raise StreamlitConversionError(f"Invalid JSON in notebook file: {str(e)}", 400)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import traceback
|
|
7
|
+
import ast
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import List, Tuple, Optional, Dict, Any, Generator
|
|
10
|
+
from streamlit.testing.v1 import AppTest
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from mito_ai.path_utils import AbsoluteNotebookPath, get_absolute_notebook_dir_path
|
|
13
|
+
|
|
14
|
+
warnings.filterwarnings("ignore", message=".*bare mode.*")
|
|
15
|
+
|
|
16
|
+
def get_syntax_error(app_code: str) -> Optional[str]:
|
|
17
|
+
"""Check if the Python code has valid syntax"""
|
|
18
|
+
try:
|
|
19
|
+
ast.parse(app_code)
|
|
20
|
+
return None
|
|
21
|
+
except SyntaxError as e:
|
|
22
|
+
error_msg = ''.join(traceback.format_exception(type(e), e, e.__traceback__))
|
|
23
|
+
return error_msg
|
|
24
|
+
|
|
25
|
+
def get_runtime_errors(app_code: str, app_path: AbsoluteNotebookPath) -> Optional[List[Dict[str, Any]]]:
|
|
26
|
+
"""Start the Streamlit app in a subprocess"""
|
|
27
|
+
|
|
28
|
+
directory = get_absolute_notebook_dir_path(app_path)
|
|
29
|
+
|
|
30
|
+
@contextmanager
|
|
31
|
+
def change_working_directory(path: str) -> Generator[None, Any, None]:
|
|
32
|
+
"""
|
|
33
|
+
Context manager to temporarily change working directory
|
|
34
|
+
so that relative paths are still valid when we run the app
|
|
35
|
+
"""
|
|
36
|
+
if path == '':
|
|
37
|
+
yield
|
|
38
|
+
|
|
39
|
+
original_cwd = os.getcwd()
|
|
40
|
+
try:
|
|
41
|
+
os.chdir(path)
|
|
42
|
+
yield
|
|
43
|
+
finally:
|
|
44
|
+
os.chdir(original_cwd)
|
|
45
|
+
|
|
46
|
+
with change_working_directory(directory):
|
|
47
|
+
# Create a temporary file that uses UTF-8 encoding so
|
|
48
|
+
# we don't run into issues with non-ASCII characters on Windows.
|
|
49
|
+
# We use utf-8 encoding when writing the app.py file so this validation
|
|
50
|
+
# code mirrors the actual file.
|
|
51
|
+
|
|
52
|
+
# Note: Since the AppTest.from_file tries to open the file, we need to first close the file
|
|
53
|
+
# by exiting the context manager and using the delete=False flag so that the file still exists.
|
|
54
|
+
# Windows can't open the same file twice at the same time. We cleanup at the end.
|
|
55
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as f:
|
|
56
|
+
f.write(app_code)
|
|
57
|
+
temp_path = f.name
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Run Streamlit test from file with UTF-8 encoding
|
|
61
|
+
app_test = AppTest.from_file(temp_path, default_timeout=30)
|
|
62
|
+
app_test.run()
|
|
63
|
+
|
|
64
|
+
# Check for exceptions
|
|
65
|
+
if app_test.exception:
|
|
66
|
+
errors = [{'type': 'exception', 'details': exc.value, 'message': exc.message, 'stack_trace': exc.stack_trace} for exc in app_test.exception]
|
|
67
|
+
return errors
|
|
68
|
+
|
|
69
|
+
# Check for error messages
|
|
70
|
+
if app_test.error:
|
|
71
|
+
errors = [{'type': 'error', 'details': err.value} for err in app_test.error]
|
|
72
|
+
return errors
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
finally:
|
|
76
|
+
# Clean up the temporary file
|
|
77
|
+
try:
|
|
78
|
+
os.unlink(temp_path)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass # File might already be deleted
|
|
81
|
+
|
|
82
|
+
def check_for_errors(app_code: str, app_path: AbsoluteNotebookPath) -> List[Dict[str, Any]]:
|
|
83
|
+
"""Complete validation pipeline"""
|
|
84
|
+
errors: List[Dict[str, Any]] = []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
# Step 1: Check syntax
|
|
88
|
+
syntax_error = get_syntax_error(app_code)
|
|
89
|
+
if syntax_error:
|
|
90
|
+
errors.append({'type': 'syntax', 'details': syntax_error})
|
|
91
|
+
|
|
92
|
+
runtime_errors = get_runtime_errors(app_code, app_path)
|
|
93
|
+
if runtime_errors:
|
|
94
|
+
errors.extend(runtime_errors)
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
errors.append({'type': 'validation', 'details': str(e)})
|
|
98
|
+
|
|
99
|
+
return errors
|
|
100
|
+
|
|
101
|
+
def validate_app(app_code: str, notebook_path: AbsoluteNotebookPath) -> List[str]:
|
|
102
|
+
"""Convenience function to validate Streamlit code"""
|
|
103
|
+
errors = check_for_errors(app_code, notebook_path)
|
|
104
|
+
stringified_errors = [str(error) for error in errors]
|
|
105
|
+
return stringified_errors
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from mito_ai.streamlit_preview.utils import validate_request_body
|
|
6
|
+
import tornado
|
|
7
|
+
from jupyter_server.base.handlers import APIHandler
|
|
8
|
+
from mito_ai.streamlit_preview.manager import StreamlitPreviewManager
|
|
9
|
+
from mito_ai.path_utils import get_absolute_notebook_dir_path, get_absolute_notebook_path, get_absolute_app_path, does_app_path_exist, get_app_file_name
|
|
10
|
+
from mito_ai.utils.telemetry_utils import log_streamlit_app_conversion_error, log_streamlit_app_preview_failure, log_streamlit_app_preview_success
|
|
11
|
+
from mito_ai.completions.models import MessageType
|
|
12
|
+
from mito_ai.utils.error_classes import StreamlitConversionError, StreamlitPreviewError
|
|
13
|
+
from mito_ai.streamlit_conversion.streamlit_agent_handler import streamlit_handler
|
|
14
|
+
import traceback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StreamlitPreviewHandler(APIHandler):
|
|
18
|
+
"""REST handler for streamlit preview operations."""
|
|
19
|
+
|
|
20
|
+
def initialize(self) -> None:
|
|
21
|
+
"""Initialize the handler."""
|
|
22
|
+
self.preview_manager = StreamlitPreviewManager()
|
|
23
|
+
|
|
24
|
+
@tornado.web.authenticated
|
|
25
|
+
async def post(self) -> None:
|
|
26
|
+
"""Start a new streamlit preview."""
|
|
27
|
+
try:
|
|
28
|
+
# Parse and validate request
|
|
29
|
+
body = self.get_json_body()
|
|
30
|
+
notebook_path, notebook_id, force_recreate, edit_prompt = validate_request_body(body)
|
|
31
|
+
|
|
32
|
+
# Ensure app exists
|
|
33
|
+
absolute_notebook_path = get_absolute_notebook_path(notebook_path)
|
|
34
|
+
absolute_notebook_dir_path = get_absolute_notebook_dir_path(absolute_notebook_path)
|
|
35
|
+
app_file_name = get_app_file_name(notebook_id)
|
|
36
|
+
absolute_app_path = get_absolute_app_path(absolute_notebook_dir_path, app_file_name)
|
|
37
|
+
app_path_exists = does_app_path_exist(absolute_app_path)
|
|
38
|
+
|
|
39
|
+
if not app_path_exists or force_recreate:
|
|
40
|
+
if not app_path_exists:
|
|
41
|
+
print("[Mito AI] App path not found, generating streamlit code")
|
|
42
|
+
else:
|
|
43
|
+
print("[Mito AI] Force recreating streamlit app")
|
|
44
|
+
|
|
45
|
+
await streamlit_handler(absolute_notebook_path, app_file_name, edit_prompt)
|
|
46
|
+
|
|
47
|
+
# Start preview
|
|
48
|
+
# TODO: There's a bug here where when the user rebuilds and already running app. Instead of
|
|
49
|
+
# creating a new process, we should update the existing process. The app displayed to the user
|
|
50
|
+
# does update, but that's just because of hot reloading when we overwrite the app.py file.
|
|
51
|
+
preview_id = str(uuid.uuid4())
|
|
52
|
+
port = self.preview_manager.start_streamlit_preview(absolute_notebook_dir_path, app_file_name, preview_id)
|
|
53
|
+
|
|
54
|
+
# Return success response
|
|
55
|
+
self.finish({
|
|
56
|
+
"type": 'success',
|
|
57
|
+
"id": preview_id,
|
|
58
|
+
"port": port,
|
|
59
|
+
"url": f"http://localhost:{port}"
|
|
60
|
+
})
|
|
61
|
+
log_streamlit_app_preview_success('mito_server_key', MessageType.STREAMLIT_CONVERSION, edit_prompt)
|
|
62
|
+
|
|
63
|
+
except StreamlitConversionError as e:
|
|
64
|
+
print(e)
|
|
65
|
+
self.set_status(e.error_code)
|
|
66
|
+
error_message = str(e)
|
|
67
|
+
formatted_traceback = traceback.format_exc()
|
|
68
|
+
self.finish({"error": error_message})
|
|
69
|
+
log_streamlit_app_conversion_error(
|
|
70
|
+
'mito_server_key',
|
|
71
|
+
MessageType.STREAMLIT_CONVERSION,
|
|
72
|
+
error_message,
|
|
73
|
+
formatted_traceback,
|
|
74
|
+
edit_prompt,
|
|
75
|
+
)
|
|
76
|
+
except StreamlitPreviewError as e:
|
|
77
|
+
print(e)
|
|
78
|
+
error_message = str(e)
|
|
79
|
+
formatted_traceback = traceback.format_exc()
|
|
80
|
+
self.set_status(e.error_code)
|
|
81
|
+
self.finish({"error": error_message})
|
|
82
|
+
log_streamlit_app_preview_failure('mito_server_key', MessageType.STREAMLIT_CONVERSION, error_message, formatted_traceback, edit_prompt)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"Exception in streamlit preview handler: {e}")
|
|
85
|
+
self.set_status(500)
|
|
86
|
+
error_message = str(e)
|
|
87
|
+
formatted_traceback = traceback.format_exc()
|
|
88
|
+
self.finish({"error": error_message})
|
|
89
|
+
log_streamlit_app_preview_failure('mito_server_key', MessageType.STREAMLIT_CONVERSION, error_message, formatted_traceback, "")
|
|
90
|
+
|
|
91
|
+
@tornado.web.authenticated
|
|
92
|
+
def delete(self, preview_id: str) -> None:
|
|
93
|
+
"""Stop a streamlit preview."""
|
|
94
|
+
try:
|
|
95
|
+
if not preview_id:
|
|
96
|
+
self.set_status(400)
|
|
97
|
+
self.finish({"error": "Missing preview_id parameter"})
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Stop the preview
|
|
101
|
+
stopped = self.preview_manager.stop_preview(preview_id)
|
|
102
|
+
|
|
103
|
+
if stopped:
|
|
104
|
+
self.set_status(204) # No content
|
|
105
|
+
else:
|
|
106
|
+
self.set_status(404)
|
|
107
|
+
self.finish({"error": f"Preview {preview_id} not found"})
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self.set_status(500)
|
|
111
|
+
self.finish({"error": f"Internal server error: {str(e)}"})
|