chatspatial 1.1.0__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.
Files changed (67) hide show
  1. chatspatial/__init__.py +11 -0
  2. chatspatial/__main__.py +141 -0
  3. chatspatial/cli/__init__.py +7 -0
  4. chatspatial/config.py +53 -0
  5. chatspatial/models/__init__.py +85 -0
  6. chatspatial/models/analysis.py +513 -0
  7. chatspatial/models/data.py +2462 -0
  8. chatspatial/server.py +1763 -0
  9. chatspatial/spatial_mcp_adapter.py +720 -0
  10. chatspatial/tools/__init__.py +3 -0
  11. chatspatial/tools/annotation.py +1903 -0
  12. chatspatial/tools/cell_communication.py +1603 -0
  13. chatspatial/tools/cnv_analysis.py +605 -0
  14. chatspatial/tools/condition_comparison.py +595 -0
  15. chatspatial/tools/deconvolution/__init__.py +402 -0
  16. chatspatial/tools/deconvolution/base.py +318 -0
  17. chatspatial/tools/deconvolution/card.py +244 -0
  18. chatspatial/tools/deconvolution/cell2location.py +326 -0
  19. chatspatial/tools/deconvolution/destvi.py +144 -0
  20. chatspatial/tools/deconvolution/flashdeconv.py +101 -0
  21. chatspatial/tools/deconvolution/rctd.py +317 -0
  22. chatspatial/tools/deconvolution/spotlight.py +216 -0
  23. chatspatial/tools/deconvolution/stereoscope.py +109 -0
  24. chatspatial/tools/deconvolution/tangram.py +135 -0
  25. chatspatial/tools/differential.py +625 -0
  26. chatspatial/tools/embeddings.py +298 -0
  27. chatspatial/tools/enrichment.py +1863 -0
  28. chatspatial/tools/integration.py +807 -0
  29. chatspatial/tools/preprocessing.py +723 -0
  30. chatspatial/tools/spatial_domains.py +808 -0
  31. chatspatial/tools/spatial_genes.py +836 -0
  32. chatspatial/tools/spatial_registration.py +441 -0
  33. chatspatial/tools/spatial_statistics.py +1476 -0
  34. chatspatial/tools/trajectory.py +495 -0
  35. chatspatial/tools/velocity.py +405 -0
  36. chatspatial/tools/visualization/__init__.py +155 -0
  37. chatspatial/tools/visualization/basic.py +393 -0
  38. chatspatial/tools/visualization/cell_comm.py +699 -0
  39. chatspatial/tools/visualization/cnv.py +320 -0
  40. chatspatial/tools/visualization/core.py +684 -0
  41. chatspatial/tools/visualization/deconvolution.py +852 -0
  42. chatspatial/tools/visualization/enrichment.py +660 -0
  43. chatspatial/tools/visualization/integration.py +205 -0
  44. chatspatial/tools/visualization/main.py +164 -0
  45. chatspatial/tools/visualization/multi_gene.py +739 -0
  46. chatspatial/tools/visualization/persistence.py +335 -0
  47. chatspatial/tools/visualization/spatial_stats.py +469 -0
  48. chatspatial/tools/visualization/trajectory.py +639 -0
  49. chatspatial/tools/visualization/velocity.py +411 -0
  50. chatspatial/utils/__init__.py +115 -0
  51. chatspatial/utils/adata_utils.py +1372 -0
  52. chatspatial/utils/compute.py +327 -0
  53. chatspatial/utils/data_loader.py +499 -0
  54. chatspatial/utils/dependency_manager.py +462 -0
  55. chatspatial/utils/device_utils.py +165 -0
  56. chatspatial/utils/exceptions.py +185 -0
  57. chatspatial/utils/image_utils.py +267 -0
  58. chatspatial/utils/mcp_utils.py +137 -0
  59. chatspatial/utils/path_utils.py +243 -0
  60. chatspatial/utils/persistence.py +78 -0
  61. chatspatial/utils/scipy_compat.py +143 -0
  62. chatspatial-1.1.0.dist-info/METADATA +242 -0
  63. chatspatial-1.1.0.dist-info/RECORD +67 -0
  64. chatspatial-1.1.0.dist-info/WHEEL +5 -0
  65. chatspatial-1.1.0.dist-info/entry_points.txt +2 -0
  66. chatspatial-1.1.0.dist-info/licenses/LICENSE +21 -0
  67. chatspatial-1.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,185 @@
1
+ """
2
+ Exception classes for ChatSpatial.
3
+
4
+ Exception Hierarchy Design Principles:
5
+ ======================================
6
+ 1. SEMANTIC CLARITY: Exception type immediately indicates error category
7
+ 2. MINIMAL HIERARCHY: Only add subclasses when semantically distinct
8
+ 3. CONSISTENCY: Same semantic error uses same exception type everywhere
9
+ 4. DEBUGGABILITY: Always preserve exception chain with `raise X from e`
10
+
11
+ Exception Hierarchy:
12
+ ====================
13
+ ChatSpatialError (base)
14
+ ├── DataError (data access/availability/format issues)
15
+ │ ├── DataNotFoundError (required data missing)
16
+ │ └── DataCompatibilityError (format/species mismatch)
17
+ ├── ParameterError (invalid user input/parameters)
18
+ ├── ProcessingError (algorithm/computation failures)
19
+ └── DependencyError (missing packages/R environment)
20
+
21
+ Usage Guidelines:
22
+ =================
23
+
24
+ INSTEAD OF ValueError, USE:
25
+ ---------------------------
26
+ - ParameterError: Invalid parameter values, invalid combinations
27
+ Example: "n_clusters must be > 0", "method must be 'leiden' or 'louvain'"
28
+
29
+ - DataError: Data validation failures (missing columns, wrong format)
30
+ Example: "Column 'cell_type' not found", "Data contains NaN values"
31
+
32
+ INSTEAD OF RuntimeError, USE:
33
+ -----------------------------
34
+ - ProcessingError: Algorithm failures, computation errors
35
+ Example: "Clustering failed to converge", "Model training failed"
36
+
37
+ - DependencyError: Package/environment issues
38
+ Example: "scvi-tools not installed", "R environment not configured"
39
+
40
+ ALWAYS use exception chaining:
41
+ ------------------------------
42
+ try:
43
+ result = compute()
44
+ except Exception as e:
45
+ raise ProcessingError(f"Computation failed: {e}") from e
46
+
47
+ NEVER silently swallow exceptions:
48
+ ----------------------------------
49
+ # BAD
50
+ except Exception:
51
+ pass
52
+
53
+ # GOOD
54
+ except ExpectedError as e:
55
+ logger.debug(f"Expected error: {e}")
56
+ # Handle gracefully
57
+ """
58
+
59
+
60
+ class ChatSpatialError(Exception):
61
+ """Base exception for all ChatSpatial errors.
62
+
63
+ All custom exceptions inherit from this class, allowing:
64
+ - Catch all ChatSpatial errors: `except ChatSpatialError`
65
+ - Distinguish from Python built-in exceptions
66
+ """
67
+
68
+
69
+ # =============================================================================
70
+ # Data Errors - Issues with data access, availability, or format
71
+ # =============================================================================
72
+
73
+
74
+ class DataError(ChatSpatialError):
75
+ """Data-related errors (missing, invalid format, validation failures).
76
+
77
+ Use when:
78
+ - Required data column/key is missing
79
+ - Data format is invalid (wrong dtype, contains NaN/Inf)
80
+ - Data validation fails (too few cells, no spatial coordinates)
81
+
82
+ Examples:
83
+ raise DataError("Spatial coordinates contain NaN values")
84
+ raise DataError("Expression matrix is empty")
85
+ raise DataError(f"Column '{col}' not found in adata.obs")
86
+ """
87
+
88
+
89
+ class DataNotFoundError(DataError):
90
+ """Required data not found in the expected location.
91
+
92
+ Use when:
93
+ - Dataset ID not found in registry
94
+ - Required analysis results missing (e.g., deconvolution not run)
95
+ - Expected file/resource doesn't exist
96
+
97
+ Examples:
98
+ raise DataNotFoundError(f"Dataset '{data_id}' not found")
99
+ raise DataNotFoundError("Deconvolution results not found")
100
+ raise DataNotFoundError("Velocity results not found in adata.obsm")
101
+ """
102
+
103
+
104
+ class DataCompatibilityError(DataError):
105
+ """Data format or compatibility issues between datasets.
106
+
107
+ Use when:
108
+ - Gene naming convention mismatch (Ensembl vs Symbol)
109
+ - Species mismatch between spatial and reference data
110
+ - Incompatible data formats for integration
111
+
112
+ Examples:
113
+ raise DataCompatibilityError("Gene naming mismatch: symbols vs Ensembl")
114
+ raise DataCompatibilityError("Species mismatch: mouse vs human")
115
+ """
116
+
117
+
118
+ # =============================================================================
119
+ # Parameter Errors - Invalid user input or parameter combinations
120
+ # =============================================================================
121
+
122
+
123
+ class ParameterError(ChatSpatialError):
124
+ """Invalid parameter values or combinations.
125
+
126
+ Use when:
127
+ - Parameter value out of valid range
128
+ - Invalid parameter combination
129
+ - Required parameter missing
130
+ - Parameter type is wrong
131
+
132
+ REPLACES: ValueError for parameter validation
133
+
134
+ Examples:
135
+ raise ParameterError("n_clusters must be > 0")
136
+ raise ParameterError("method must be one of: 'leiden', 'louvain'")
137
+ raise ParameterError("Cannot use both 'n_clusters' and 'resolution'")
138
+ raise ParameterError("species parameter is required")
139
+ """
140
+
141
+
142
+ # =============================================================================
143
+ # Processing Errors - Algorithm or computation failures
144
+ # =============================================================================
145
+
146
+
147
+ class ProcessingError(ChatSpatialError):
148
+ """Errors during analysis processing or computation.
149
+
150
+ Use when:
151
+ - Algorithm fails to converge
152
+ - Numerical computation errors
153
+ - Model training failures
154
+ - Unexpected processing failures
155
+
156
+ REPLACES: RuntimeError for processing failures
157
+
158
+ Examples:
159
+ raise ProcessingError("Leiden clustering failed to converge")
160
+ raise ProcessingError(f"Model training failed: {e}") from e
161
+ raise ProcessingError("PCA computation failed: matrix is singular")
162
+ """
163
+
164
+
165
+ # =============================================================================
166
+ # Dependency Errors - Missing packages or environment issues
167
+ # =============================================================================
168
+
169
+
170
+ class DependencyError(ChatSpatialError):
171
+ """Missing or incompatible dependency.
172
+
173
+ Use when:
174
+ - Required Python package not installed
175
+ - R environment not configured
176
+ - R package missing
177
+ - Version incompatibility
178
+
179
+ REPLACES: ImportError for optional dependencies
180
+
181
+ Examples:
182
+ raise DependencyError("scvi-tools required: pip install scvi-tools")
183
+ raise DependencyError("R environment not configured")
184
+ raise DependencyError("CellChat R package not installed")
185
+ """
@@ -0,0 +1,267 @@
1
+ """
2
+ Image utilities for spatial transcriptomics MCP.
3
+
4
+ This module provides standardized functions for handling images in the MCP.
5
+ All functions return Image objects that can be directly used in MCP tools.
6
+ """
7
+
8
+ import base64
9
+ import io
10
+ import uuid
11
+ from contextlib import contextmanager
12
+ from typing import TYPE_CHECKING, Any, Optional
13
+ from collections.abc import Generator
14
+
15
+ from mcp.types import ImageContent
16
+
17
+ from .exceptions import ProcessingError
18
+ from .path_utils import get_safe_output_path
19
+
20
+ if TYPE_CHECKING:
21
+ from ..spatial_mcp_adapter import ToolContext
22
+
23
+
24
+ # =============================================================================
25
+ # Matplotlib Backend Management
26
+ # =============================================================================
27
+
28
+
29
+ def _ensure_non_interactive_backend() -> None:
30
+ """Ensure matplotlib uses non-interactive backend to prevent GUI popups on macOS."""
31
+ import matplotlib
32
+
33
+ current_backend = matplotlib.get_backend()
34
+ if current_backend != "Agg":
35
+ matplotlib.use("Agg")
36
+ import matplotlib.pyplot as plt
37
+
38
+ plt.ioff()
39
+
40
+
41
+ @contextmanager
42
+ def non_interactive_backend() -> Generator[None, None, None]:
43
+ """Context manager for temporary non-interactive matplotlib backend.
44
+
45
+ Use this when calling external plotting functions (e.g., cellrank, scvelo)
46
+ that may trigger interactive backend behavior. The original backend is
47
+ restored after the context exits.
48
+
49
+ For internal plotting code that doesn't need backend restoration,
50
+ use _ensure_non_interactive_backend() instead.
51
+
52
+ Usage:
53
+ with non_interactive_backend():
54
+ cr.pl.circular_projection(adata, ...)
55
+ fig = plt.gcf()
56
+
57
+ Yields:
58
+ None
59
+ """
60
+ import matplotlib
61
+
62
+ original_backend = matplotlib.get_backend()
63
+ needs_restore = original_backend != "Agg"
64
+
65
+ try:
66
+ if needs_restore:
67
+ matplotlib.use("Agg")
68
+ import matplotlib.pyplot as plt
69
+
70
+ plt.ioff()
71
+ yield
72
+ finally:
73
+ if needs_restore:
74
+ matplotlib.use(original_backend)
75
+
76
+
77
+ if TYPE_CHECKING:
78
+ import matplotlib.pyplot as plt
79
+
80
+
81
+ # Standard savefig parameters for consistent figure output
82
+ SAVEFIG_PARAMS: dict[str, Any] = {
83
+ "bbox_inches": "tight",
84
+ "transparent": False,
85
+ "facecolor": "white",
86
+ "edgecolor": "none",
87
+ "pad_inches": 0.1,
88
+ "metadata": {"Software": "spatial-transcriptomics-mcp"},
89
+ }
90
+
91
+
92
+ def bytes_to_image_content(data: bytes, format: str = "png") -> ImageContent:
93
+ """Convert raw image bytes to MCP ImageContent.
94
+
95
+ This unified utility function handles the conversion from raw image bytes
96
+ to the MCP-compatible ImageContent type with proper MIME type mapping.
97
+
98
+ Args:
99
+ data: Raw image bytes
100
+ format: Image format (png, jpg, jpeg, gif, webp)
101
+
102
+ Returns:
103
+ ImageContent object ready for MCP tool return
104
+
105
+ Examples:
106
+ >>> img_bytes = fig.savefig(buf, format='png')
107
+ >>> content = bytes_to_image_content(img_bytes, format='png')
108
+ """
109
+ # MIME type mapping for common image formats
110
+ format_to_mime = {
111
+ "png": "image/png",
112
+ "jpg": "image/jpeg",
113
+ "jpeg": "image/jpeg",
114
+ "gif": "image/gif",
115
+ "webp": "image/webp",
116
+ }
117
+
118
+ # Get MIME type, default to PNG if format is unknown
119
+ mime_type = format_to_mime.get(format.lower(), "image/png")
120
+
121
+ # Encode to base64 string as required by ImageContent
122
+ encoded_data = base64.b64encode(data).decode("utf-8")
123
+
124
+ return ImageContent(type="image", data=encoded_data, mimeType=mime_type)
125
+
126
+
127
+ def fig_to_image(
128
+ fig: "plt.Figure",
129
+ dpi: int = 100,
130
+ format: str = "png",
131
+ close_fig: bool = True,
132
+ ) -> ImageContent:
133
+ """Convert matplotlib figure to ImageContent
134
+
135
+ This function respects user's DPI and format settings without any
136
+ automatic compression or quality reduction. Large images are handled
137
+ by optimize_fig_to_image_with_cache which saves them to disk.
138
+
139
+ Args:
140
+ fig: Matplotlib figure
141
+ dpi: Resolution in dots per inch (user's setting is always respected)
142
+ format: Image format (png or jpg)
143
+ close_fig: Whether to close the figure after conversion
144
+
145
+ Returns:
146
+ ImageContent object ready for MCP tool return
147
+ """
148
+ _ensure_non_interactive_backend() # Prevent GUI popups on macOS
149
+ import matplotlib.pyplot as plt
150
+
151
+ buf = io.BytesIO()
152
+
153
+ # Save figure with user's exact settings - no compromise
154
+ # Check for extra artists (e.g., legends positioned outside plot area)
155
+ extra_artists = getattr(fig, "_chatspatial_extra_artists", None)
156
+
157
+ try:
158
+ if format == "jpg":
159
+ try:
160
+ # Try with quality parameter first (newer matplotlib)
161
+ fig.savefig(
162
+ buf,
163
+ format=format,
164
+ dpi=dpi,
165
+ bbox_extra_artists=extra_artists,
166
+ quality=85,
167
+ **SAVEFIG_PARAMS,
168
+ )
169
+ except TypeError:
170
+ # Fallback for older matplotlib without quality parameter
171
+ fig.savefig(
172
+ buf,
173
+ format=format,
174
+ dpi=dpi,
175
+ bbox_extra_artists=extra_artists,
176
+ **SAVEFIG_PARAMS,
177
+ )
178
+ else: # PNG
179
+ fig.savefig(
180
+ buf,
181
+ format=format,
182
+ dpi=dpi,
183
+ bbox_extra_artists=extra_artists,
184
+ **SAVEFIG_PARAMS,
185
+ )
186
+
187
+ buf.seek(0)
188
+ img_data = buf.read()
189
+
190
+ if close_fig:
191
+ plt.close(fig)
192
+
193
+ # Convert to ImageContent using unified utility
194
+ return bytes_to_image_content(img_data, format=format)
195
+
196
+ except Exception as e:
197
+ if close_fig:
198
+ plt.close(fig)
199
+ raise ProcessingError(f"Failed to convert figure to image: {e}") from e
200
+
201
+
202
+ # ============ Token Optimization and Publication Export Support ============
203
+
204
+
205
+ async def optimize_fig_to_image_with_cache(
206
+ fig: "plt.Figure",
207
+ params: Any,
208
+ ctx: Optional["ToolContext"] = None,
209
+ data_id: Optional[str] = None,
210
+ plot_type: Optional[str] = None,
211
+ mode: str = "auto", # Kept for API compatibility, ignored
212
+ ) -> str:
213
+ """Save figure to file and return path for MCP protocol efficiency.
214
+
215
+ MCP protocol has ~3.3x overhead for embedded ImageContent, so we always
216
+ save to file and return path as text (following MCP best practice:
217
+ "Prefer using URIs over embedded content for large files").
218
+
219
+ Args:
220
+ fig: Matplotlib figure
221
+ params: Visualization parameters (used for DPI extraction)
222
+ ctx: ToolContext (unused, kept for API compatibility)
223
+ data_id: Dataset ID (unused, kept for API compatibility)
224
+ plot_type: Plot type (used for filename generation)
225
+ mode: Ignored (always saves to file)
226
+
227
+ Returns:
228
+ str with file path (FastMCP auto-converts to TextContent)
229
+ """
230
+ _ensure_non_interactive_backend()
231
+ import matplotlib.pyplot as plt
232
+
233
+ target_dpi = params.dpi if hasattr(params, "dpi") and params.dpi else 100
234
+ extra_artists = getattr(fig, "_chatspatial_extra_artists", None)
235
+
236
+ # Generate image
237
+ buf = io.BytesIO()
238
+ fig.savefig(
239
+ buf,
240
+ format="png",
241
+ dpi=target_dpi,
242
+ bbox_extra_artists=extra_artists,
243
+ **SAVEFIG_PARAMS,
244
+ )
245
+ actual_size = buf.tell()
246
+
247
+ # Save to file (use centralized path utility for consistent output handling)
248
+ output_dir = get_safe_output_path("./visualizations")
249
+ filename = (
250
+ f"{plot_type}_{uuid.uuid4().hex[:8]}.png"
251
+ if plot_type
252
+ else f"viz_{uuid.uuid4().hex[:8]}.png"
253
+ )
254
+ filepath = output_dir / filename
255
+
256
+ buf.seek(0)
257
+ with open(filepath, "wb") as f:
258
+ f.write(buf.read())
259
+ buf.close()
260
+ plt.close(fig)
261
+
262
+ return (
263
+ f"Visualization saved: {filepath}\n"
264
+ f"Type: {plot_type or 'visualization'}\n"
265
+ f"Size: {actual_size // 1024}KB\n"
266
+ f"Resolution: {target_dpi} DPI"
267
+ )
@@ -0,0 +1,137 @@
1
+ """
2
+ MCP utilities for ChatSpatial.
3
+
4
+ Tools for MCP server: error handling decorator and output suppression.
5
+ """
6
+
7
+ import io
8
+ import logging
9
+ import traceback
10
+ import warnings
11
+ from contextlib import contextmanager, redirect_stderr, redirect_stdout
12
+ from functools import wraps
13
+ from typing import get_type_hints
14
+
15
+ # =============================================================================
16
+ # Output Suppression
17
+ # =============================================================================
18
+ @contextmanager
19
+ def suppress_output():
20
+ """
21
+ Context manager to suppress stdout, stderr, warnings, and logging.
22
+
23
+ Usage:
24
+ with suppress_output():
25
+ noisy_function()
26
+ """
27
+ old_level = logging.getLogger().level
28
+ logging.getLogger().setLevel(logging.ERROR)
29
+
30
+ with warnings.catch_warnings():
31
+ warnings.simplefilter("ignore")
32
+ stdout_buffer = io.StringIO()
33
+ stderr_buffer = io.StringIO()
34
+
35
+ try:
36
+ with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
37
+ yield
38
+ finally:
39
+ logging.getLogger().setLevel(old_level)
40
+
41
+
42
+ # =============================================================================
43
+ # MCP Tool Error Handler
44
+ # =============================================================================
45
+ def _get_return_type_category(func) -> str:
46
+ """
47
+ Determine return type category using proper type inspection.
48
+
49
+ Returns one of: "image", "basemodel", "str", "simple", "unknown"
50
+ """
51
+ try:
52
+ from typing import Union, get_args, get_origin
53
+
54
+ from mcp.types import ImageContent
55
+ from pydantic import BaseModel
56
+
57
+ hints = get_type_hints(func)
58
+ return_type = hints.get("return")
59
+
60
+ if return_type is None:
61
+ return "unknown"
62
+
63
+ # Handle Union types (including Optional which is Union[X, None])
64
+ origin = get_origin(return_type)
65
+ if origin is Union:
66
+ types = [t for t in get_args(return_type) if t is not type(None)]
67
+ else:
68
+ types = [return_type]
69
+
70
+ # Check ImageContent first (it's a BaseModel subclass, so order matters)
71
+ for t in types:
72
+ if isinstance(t, type) and issubclass(t, ImageContent):
73
+ return "image"
74
+
75
+ # Check for BaseModel subclasses
76
+ for t in types:
77
+ if isinstance(t, type) and issubclass(t, BaseModel):
78
+ return "basemodel"
79
+
80
+ # Check for str
81
+ if str in types:
82
+ return "str"
83
+
84
+ return "simple"
85
+
86
+ except Exception:
87
+ return "unknown"
88
+
89
+
90
+ def mcp_tool_error_handler(include_traceback: bool = True):
91
+ """
92
+ Decorator for MCP tools to handle errors gracefully.
93
+
94
+ Errors are returned in the result object (not raised as exceptions),
95
+ allowing LLMs to see and potentially handle them.
96
+ """
97
+
98
+ def decorator(func):
99
+ return_type_category = _get_return_type_category(func)
100
+
101
+ @wraps(func)
102
+ async def wrapper(*args, **kwargs):
103
+ try:
104
+ return await func(*args, **kwargs)
105
+
106
+ except Exception as e:
107
+ error_msg = str(e)
108
+
109
+ if return_type_category == "image":
110
+ # Return error as text (Union[ImageContent, str] allows this)
111
+ msg = f"Visualization Error: {error_msg}"
112
+ if include_traceback and not isinstance(e, (ValueError, KeyError)):
113
+ msg += f"\n\nDetails:\n{traceback.format_exc()}"
114
+ return msg
115
+
116
+ elif return_type_category == "basemodel":
117
+ # Re-raise for FastMCP to handle
118
+ raise
119
+
120
+ elif return_type_category == "str":
121
+ return f"Error: {error_msg}"
122
+
123
+ else:
124
+ # Return error dict for simple types
125
+ content = [{"type": "text", "text": f"Error: {error_msg}"}]
126
+ if include_traceback and not isinstance(e, ValueError):
127
+ content.append(
128
+ {
129
+ "type": "text",
130
+ "text": f"Traceback:\n{traceback.format_exc()}",
131
+ }
132
+ )
133
+ return {"content": content, "isError": True}
134
+
135
+ return wrapper
136
+
137
+ return decorator