remdb 0.3.0__py3-none-any.whl → 0.3.114__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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/__init__.py +129 -2
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +16 -2
- rem/agentic/agents/sse_simulator.py +500 -0
- rem/agentic/context.py +28 -22
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/otel/setup.py +92 -4
- rem/agentic/providers/phoenix.py +32 -43
- rem/agentic/providers/pydantic_ai.py +142 -22
- rem/agentic/schema.py +358 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +238 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +151 -37
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +17 -2
- rem/api/mcp_router/tools.py +143 -7
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +277 -0
- rem/api/routers/auth.py +124 -0
- rem/api/routers/chat/completions.py +152 -16
- rem/api/routers/chat/models.py +7 -3
- rem/api/routers/chat/sse_events.py +526 -0
- rem/api/routers/chat/streaming.py +608 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +148 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +357 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/middleware.py +126 -27
- rem/cli/commands/README.md +201 -70
- rem/cli/commands/ask.py +13 -10
- rem/cli/commands/cluster.py +1359 -0
- rem/cli/commands/configure.py +4 -3
- rem/cli/commands/db.py +350 -137
- rem/cli/commands/experiments.py +76 -72
- rem/cli/commands/process.py +22 -15
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +95 -49
- rem/cli/main.py +29 -6
- rem/config.py +2 -2
- rem/models/core/core_model.py +7 -1
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +21 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/user.py +10 -3
- rem/registry.py +373 -0
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/content/providers.py +94 -140
- rem/services/content/service.py +92 -20
- rem/services/dreaming/affinity_service.py +2 -16
- rem/services/dreaming/moment_service.py +2 -15
- rem/services/embeddings/api.py +24 -17
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
- rem/services/phoenix/client.py +252 -19
- rem/services/postgres/README.md +159 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +426 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +86 -5
- rem/services/postgres/service.py +6 -6
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +14 -0
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +17 -1
- rem/services/session/reload.py +1 -1
- rem/services/user_service.py +98 -0
- rem/settings.py +169 -17
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +231 -54
- rem/sql/migrations/002_install_models.sql +457 -393
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/embeddings.py +17 -4
- rem/utils/files.py +167 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +191 -35
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +9 -14
- rem/workers/README.md +14 -14
- rem/workers/db_maintainer.py +74 -0
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1038
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/entry_points.txt +0 -0
rem/utils/files.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File utilities for consistent file handling throughout REM.
|
|
3
|
+
|
|
4
|
+
Provides context managers and helpers for temporary file operations,
|
|
5
|
+
ensuring proper cleanup and consistent patterns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tempfile
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Generator, Optional
|
|
12
|
+
|
|
13
|
+
from loguru import logger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def temp_file_from_bytes(
|
|
18
|
+
content: bytes,
|
|
19
|
+
suffix: str = "",
|
|
20
|
+
prefix: str = "rem_",
|
|
21
|
+
dir: Optional[str] = None,
|
|
22
|
+
) -> Generator[Path, None, None]:
|
|
23
|
+
"""
|
|
24
|
+
Create a temporary file from bytes, yield path, cleanup automatically.
|
|
25
|
+
|
|
26
|
+
This context manager ensures proper cleanup of temporary files even
|
|
27
|
+
if an exception occurs during processing.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
content: Bytes to write to the temporary file
|
|
31
|
+
suffix: File extension (e.g., ".pdf", ".wav")
|
|
32
|
+
prefix: Prefix for the temp file name
|
|
33
|
+
dir: Directory for temp file (uses system temp if None)
|
|
34
|
+
|
|
35
|
+
Yields:
|
|
36
|
+
Path to the temporary file
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
>>> with temp_file_from_bytes(pdf_bytes, suffix=".pdf") as tmp_path:
|
|
40
|
+
... result = process_pdf(tmp_path)
|
|
41
|
+
# File is automatically cleaned up after the block
|
|
42
|
+
|
|
43
|
+
Note:
|
|
44
|
+
The file is created with delete=False so we control cleanup.
|
|
45
|
+
This allows the file to be read by external processes.
|
|
46
|
+
"""
|
|
47
|
+
tmp_path: Optional[Path] = None
|
|
48
|
+
try:
|
|
49
|
+
with tempfile.NamedTemporaryFile(
|
|
50
|
+
suffix=suffix,
|
|
51
|
+
prefix=prefix,
|
|
52
|
+
dir=dir,
|
|
53
|
+
delete=False,
|
|
54
|
+
) as tmp:
|
|
55
|
+
tmp.write(content)
|
|
56
|
+
tmp_path = Path(tmp.name)
|
|
57
|
+
|
|
58
|
+
yield tmp_path
|
|
59
|
+
|
|
60
|
+
finally:
|
|
61
|
+
if tmp_path is not None:
|
|
62
|
+
try:
|
|
63
|
+
tmp_path.unlink(missing_ok=True)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.warning(f"Failed to cleanup temp file {tmp_path}: {e}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@contextmanager
|
|
69
|
+
def temp_file_empty(
|
|
70
|
+
suffix: str = "",
|
|
71
|
+
prefix: str = "rem_",
|
|
72
|
+
dir: Optional[str] = None,
|
|
73
|
+
) -> Generator[Path, None, None]:
|
|
74
|
+
"""
|
|
75
|
+
Create an empty temporary file, yield path, cleanup automatically.
|
|
76
|
+
|
|
77
|
+
Useful when you need to write to a file after creation or when
|
|
78
|
+
an external process will write to the file.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
suffix: File extension
|
|
82
|
+
prefix: Prefix for the temp file name
|
|
83
|
+
dir: Directory for temp file
|
|
84
|
+
|
|
85
|
+
Yields:
|
|
86
|
+
Path to the empty temporary file
|
|
87
|
+
"""
|
|
88
|
+
tmp_path: Optional[Path] = None
|
|
89
|
+
try:
|
|
90
|
+
with tempfile.NamedTemporaryFile(
|
|
91
|
+
suffix=suffix,
|
|
92
|
+
prefix=prefix,
|
|
93
|
+
dir=dir,
|
|
94
|
+
delete=False,
|
|
95
|
+
) as tmp:
|
|
96
|
+
tmp_path = Path(tmp.name)
|
|
97
|
+
|
|
98
|
+
yield tmp_path
|
|
99
|
+
|
|
100
|
+
finally:
|
|
101
|
+
if tmp_path is not None:
|
|
102
|
+
try:
|
|
103
|
+
tmp_path.unlink(missing_ok=True)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning(f"Failed to cleanup temp file {tmp_path}: {e}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@contextmanager
|
|
109
|
+
def temp_directory(
|
|
110
|
+
prefix: str = "rem_",
|
|
111
|
+
dir: Optional[str] = None,
|
|
112
|
+
) -> Generator[Path, None, None]:
|
|
113
|
+
"""
|
|
114
|
+
Create a temporary directory, yield path, cleanup automatically.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
prefix: Prefix for the temp directory name
|
|
118
|
+
dir: Parent directory for temp directory
|
|
119
|
+
|
|
120
|
+
Yields:
|
|
121
|
+
Path to the temporary directory
|
|
122
|
+
"""
|
|
123
|
+
import shutil
|
|
124
|
+
|
|
125
|
+
tmp_dir: Optional[Path] = None
|
|
126
|
+
try:
|
|
127
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix=prefix, dir=dir))
|
|
128
|
+
yield tmp_dir
|
|
129
|
+
|
|
130
|
+
finally:
|
|
131
|
+
if tmp_dir is not None:
|
|
132
|
+
try:
|
|
133
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.warning(f"Failed to cleanup temp directory {tmp_dir}: {e}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_parent_exists(path: Path) -> Path:
|
|
139
|
+
"""
|
|
140
|
+
Ensure parent directory exists, creating if necessary.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path: File path whose parent should exist
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The original path (for chaining)
|
|
147
|
+
"""
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
return path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def safe_delete(path: Path) -> bool:
|
|
153
|
+
"""
|
|
154
|
+
Safely delete a file, returning success status.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
path: Path to delete
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if deleted or didn't exist, False on error
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
path.unlink(missing_ok=True)
|
|
164
|
+
return True
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(f"Failed to delete {path}: {e}")
|
|
167
|
+
return False
|
rem/utils/mime_types.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized MIME type mappings for file format detection.
|
|
3
|
+
|
|
4
|
+
Provides bidirectional mappings between file extensions and MIME types.
|
|
5
|
+
Use these constants throughout the codebase instead of inline dictionaries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Extension to MIME type mapping (extension includes leading dot)
|
|
9
|
+
EXTENSION_TO_MIME: dict[str, str] = {
|
|
10
|
+
# Images
|
|
11
|
+
".png": "image/png",
|
|
12
|
+
".jpg": "image/jpeg",
|
|
13
|
+
".jpeg": "image/jpeg",
|
|
14
|
+
".gif": "image/gif",
|
|
15
|
+
".webp": "image/webp",
|
|
16
|
+
".bmp": "image/bmp",
|
|
17
|
+
".tiff": "image/tiff",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
# Documents
|
|
20
|
+
".pdf": "application/pdf",
|
|
21
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
22
|
+
".doc": "application/msword",
|
|
23
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
24
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
25
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
26
|
+
".xls": "application/vnd.ms-excel",
|
|
27
|
+
# Audio
|
|
28
|
+
".wav": "audio/wav",
|
|
29
|
+
".mp3": "audio/mpeg",
|
|
30
|
+
".m4a": "audio/x-m4a",
|
|
31
|
+
".flac": "audio/flac",
|
|
32
|
+
".ogg": "audio/ogg",
|
|
33
|
+
".aac": "audio/aac",
|
|
34
|
+
# Video
|
|
35
|
+
".mp4": "video/mp4",
|
|
36
|
+
".webm": "video/webm",
|
|
37
|
+
".avi": "video/x-msvideo",
|
|
38
|
+
".mov": "video/quicktime",
|
|
39
|
+
# Text/Code
|
|
40
|
+
".txt": "text/plain",
|
|
41
|
+
".md": "text/markdown",
|
|
42
|
+
".markdown": "text/markdown",
|
|
43
|
+
".json": "application/json",
|
|
44
|
+
".yaml": "application/x-yaml",
|
|
45
|
+
".yml": "application/x-yaml",
|
|
46
|
+
".xml": "application/xml",
|
|
47
|
+
".html": "text/html",
|
|
48
|
+
".css": "text/css",
|
|
49
|
+
".js": "application/javascript",
|
|
50
|
+
".py": "text/x-python",
|
|
51
|
+
".ts": "application/typescript",
|
|
52
|
+
".csv": "text/csv",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# MIME type to extension mapping (reverse of above, preferring shorter extensions)
|
|
56
|
+
MIME_TO_EXTENSION: dict[str, str] = {
|
|
57
|
+
# Images
|
|
58
|
+
"image/png": ".png",
|
|
59
|
+
"image/jpeg": ".jpg",
|
|
60
|
+
"image/gif": ".gif",
|
|
61
|
+
"image/webp": ".webp",
|
|
62
|
+
"image/bmp": ".bmp",
|
|
63
|
+
"image/tiff": ".tiff",
|
|
64
|
+
"image/svg+xml": ".svg",
|
|
65
|
+
# Documents
|
|
66
|
+
"application/pdf": ".pdf",
|
|
67
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
68
|
+
"application/msword": ".doc",
|
|
69
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
70
|
+
"application/vnd.ms-powerpoint": ".ppt",
|
|
71
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
72
|
+
"application/vnd.ms-excel": ".xls",
|
|
73
|
+
# Audio
|
|
74
|
+
"audio/wav": ".wav",
|
|
75
|
+
"audio/mpeg": ".mp3",
|
|
76
|
+
"audio/x-m4a": ".m4a",
|
|
77
|
+
"audio/mp4": ".m4a",
|
|
78
|
+
"audio/flac": ".flac",
|
|
79
|
+
"audio/ogg": ".ogg",
|
|
80
|
+
"audio/aac": ".aac",
|
|
81
|
+
# Video
|
|
82
|
+
"video/mp4": ".mp4",
|
|
83
|
+
"video/webm": ".webm",
|
|
84
|
+
"video/x-msvideo": ".avi",
|
|
85
|
+
"video/quicktime": ".mov",
|
|
86
|
+
# Text/Code
|
|
87
|
+
"text/plain": ".txt",
|
|
88
|
+
"text/markdown": ".md",
|
|
89
|
+
"application/json": ".json",
|
|
90
|
+
"application/x-yaml": ".yaml",
|
|
91
|
+
"application/xml": ".xml",
|
|
92
|
+
"text/html": ".html",
|
|
93
|
+
"text/css": ".css",
|
|
94
|
+
"application/javascript": ".js",
|
|
95
|
+
"text/x-python": ".py",
|
|
96
|
+
"application/typescript": ".ts",
|
|
97
|
+
"text/csv": ".csv",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Grouped by category for convenience
|
|
101
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
|
|
102
|
+
DOCUMENT_EXTENSIONS = {".pdf", ".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls"}
|
|
103
|
+
AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac"}
|
|
104
|
+
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov"}
|
|
105
|
+
TEXT_EXTENSIONS = {".txt", ".md", ".markdown", ".json", ".yaml", ".yml", ".xml", ".html", ".css", ".js", ".py", ".ts", ".csv"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_extension(mime_type: str, default: str = ".bin") -> str:
|
|
109
|
+
"""
|
|
110
|
+
Get file extension for a MIME type.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
mime_type: MIME type string (e.g., "image/png")
|
|
114
|
+
default: Default extension if MIME type not found
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
File extension with leading dot (e.g., ".png")
|
|
118
|
+
"""
|
|
119
|
+
return MIME_TO_EXTENSION.get(mime_type, default)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_mime_type(extension: str, default: str = "application/octet-stream") -> str:
|
|
123
|
+
"""
|
|
124
|
+
Get MIME type for a file extension.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
extension: File extension with or without leading dot
|
|
128
|
+
default: Default MIME type if extension not found
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
MIME type string (e.g., "image/png")
|
|
132
|
+
"""
|
|
133
|
+
# Normalize extension to have leading dot
|
|
134
|
+
ext = extension if extension.startswith(".") else f".{extension}"
|
|
135
|
+
return EXTENSION_TO_MIME.get(ext.lower(), default)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_image(extension_or_mime: str) -> bool:
|
|
139
|
+
"""Check if extension or MIME type represents an image."""
|
|
140
|
+
if extension_or_mime.startswith("."):
|
|
141
|
+
return extension_or_mime.lower() in IMAGE_EXTENSIONS
|
|
142
|
+
return extension_or_mime.startswith("image/")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_audio(extension_or_mime: str) -> bool:
|
|
146
|
+
"""Check if extension or MIME type represents audio."""
|
|
147
|
+
if extension_or_mime.startswith("."):
|
|
148
|
+
return extension_or_mime.lower() in AUDIO_EXTENSIONS
|
|
149
|
+
return extension_or_mime.startswith("audio/")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_document(extension_or_mime: str) -> bool:
|
|
153
|
+
"""Check if extension or MIME type represents a document."""
|
|
154
|
+
if extension_or_mime.startswith("."):
|
|
155
|
+
return extension_or_mime.lower() in DOCUMENT_EXTENSIONS
|
|
156
|
+
# Check common document MIME types
|
|
157
|
+
doc_mimes = {"application/pdf", "application/msword"}
|
|
158
|
+
return extension_or_mime in doc_mimes or "officedocument" in extension_or_mime
|
rem/utils/model_helpers.py
CHANGED
|
@@ -16,8 +16,12 @@ Embedding Field Detection:
|
|
|
16
16
|
Table Name Inference:
|
|
17
17
|
1. model_config.json_schema_extra.table_name
|
|
18
18
|
2. CamelCase → snake_case + pluralization
|
|
19
|
+
|
|
20
|
+
Model Resolution:
|
|
21
|
+
- model_from_arbitrary_casing: Resolve model class from flexible input casing
|
|
19
22
|
"""
|
|
20
23
|
|
|
24
|
+
import re
|
|
21
25
|
from typing import Any, Type
|
|
22
26
|
|
|
23
27
|
from loguru import logger
|
|
@@ -94,7 +98,9 @@ def get_table_name(model: Type[BaseModel]) -> str:
|
|
|
94
98
|
if isinstance(model_config, dict):
|
|
95
99
|
json_extra = model_config.get("json_schema_extra", {})
|
|
96
100
|
if isinstance(json_extra, dict) and "table_name" in json_extra:
|
|
97
|
-
|
|
101
|
+
table_name = json_extra["table_name"]
|
|
102
|
+
if isinstance(table_name, str):
|
|
103
|
+
return table_name
|
|
98
104
|
|
|
99
105
|
# Infer from class name
|
|
100
106
|
name = model.__name__
|
|
@@ -234,3 +240,152 @@ def get_model_metadata(model: Type[BaseModel]) -> dict[str, Any]:
|
|
|
234
240
|
"entity_key_field": get_entity_key_field(model),
|
|
235
241
|
"embeddable_fields": get_embeddable_fields(model),
|
|
236
242
|
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def normalize_to_title_case(name: str) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Normalize arbitrary casing to TitleCase (PascalCase).
|
|
248
|
+
|
|
249
|
+
Handles various input formats:
|
|
250
|
+
- kebab-case: domain-resource → DomainResource
|
|
251
|
+
- snake_case: domain_resource → DomainResource
|
|
252
|
+
- lowercase: domainresource → Domainresource (single word)
|
|
253
|
+
- TitleCase: DomainResource → DomainResource (passthrough)
|
|
254
|
+
- Mixed: Domain-Resource, DOMAIN_RESOURCE → DomainResource
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
name: Input name in any casing format
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
TitleCase (PascalCase) version of the name
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> normalize_to_title_case("domain-resource")
|
|
264
|
+
'DomainResource'
|
|
265
|
+
>>> normalize_to_title_case("domain_resources")
|
|
266
|
+
'DomainResources'
|
|
267
|
+
>>> normalize_to_title_case("DomainResource")
|
|
268
|
+
'DomainResource'
|
|
269
|
+
"""
|
|
270
|
+
# If already TitleCase (starts with uppercase, has no delimiters, and has
|
|
271
|
+
# at least one lowercase letter), return as-is
|
|
272
|
+
if (
|
|
273
|
+
name
|
|
274
|
+
and name[0].isupper()
|
|
275
|
+
and '-' not in name
|
|
276
|
+
and '_' not in name
|
|
277
|
+
and any(c.islower() for c in name)
|
|
278
|
+
):
|
|
279
|
+
return name
|
|
280
|
+
|
|
281
|
+
# Split on common delimiters (hyphen, underscore)
|
|
282
|
+
parts = re.split(r'[-_]', name)
|
|
283
|
+
|
|
284
|
+
# Capitalize first letter of each part, lowercase the rest
|
|
285
|
+
normalized_parts = [part.capitalize() for part in parts if part]
|
|
286
|
+
|
|
287
|
+
return "".join(normalized_parts)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def model_from_arbitrary_casing(
|
|
291
|
+
name: str,
|
|
292
|
+
registry: dict[str, Type[BaseModel]] | None = None,
|
|
293
|
+
) -> Type[BaseModel]:
|
|
294
|
+
"""
|
|
295
|
+
Resolve a model class from arbitrary casing input.
|
|
296
|
+
|
|
297
|
+
REM entity models use strict TitleCase (PascalCase) naming. This function
|
|
298
|
+
allows flexible input formats while maintaining consistency:
|
|
299
|
+
|
|
300
|
+
Input formats supported:
|
|
301
|
+
- kebab-case: domain-resource, domain-resources
|
|
302
|
+
- snake_case: domain_resource, domain_resources
|
|
303
|
+
- lowercase: resource, domainresource
|
|
304
|
+
- TitleCase: Resource, DomainResource
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
name: Model name in any supported casing format
|
|
308
|
+
registry: Optional dict mapping TitleCase names to model classes.
|
|
309
|
+
If not provided, uses rem.models.entities module.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The resolved Pydantic model class
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
ValueError: If no model matches the normalized name
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
>>> model = model_from_arbitrary_casing("domain-resources")
|
|
319
|
+
>>> model.__name__
|
|
320
|
+
'DomainResource'
|
|
321
|
+
>>> model = model_from_arbitrary_casing("Resource")
|
|
322
|
+
>>> model.__name__
|
|
323
|
+
'Resource'
|
|
324
|
+
"""
|
|
325
|
+
# Build default registry from entities module if not provided
|
|
326
|
+
if registry is None:
|
|
327
|
+
from rem.models.entities import (
|
|
328
|
+
DomainResource,
|
|
329
|
+
Feedback,
|
|
330
|
+
File,
|
|
331
|
+
ImageResource,
|
|
332
|
+
Message,
|
|
333
|
+
Moment,
|
|
334
|
+
Ontology,
|
|
335
|
+
OntologyConfig,
|
|
336
|
+
Resource,
|
|
337
|
+
Schema,
|
|
338
|
+
Session,
|
|
339
|
+
User,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
registry = {
|
|
343
|
+
"Resource": Resource,
|
|
344
|
+
"Resources": Resource, # Plural alias
|
|
345
|
+
"DomainResource": DomainResource,
|
|
346
|
+
"DomainResources": DomainResource, # Plural alias
|
|
347
|
+
"ImageResource": ImageResource,
|
|
348
|
+
"ImageResources": ImageResource,
|
|
349
|
+
"File": File,
|
|
350
|
+
"Files": File,
|
|
351
|
+
"Message": Message,
|
|
352
|
+
"Messages": Message,
|
|
353
|
+
"Moment": Moment,
|
|
354
|
+
"Moments": Moment,
|
|
355
|
+
"Session": Session,
|
|
356
|
+
"Sessions": Session,
|
|
357
|
+
"Feedback": Feedback,
|
|
358
|
+
"User": User,
|
|
359
|
+
"Users": User,
|
|
360
|
+
"Schema": Schema,
|
|
361
|
+
"Schemas": Schema,
|
|
362
|
+
"Ontology": Ontology,
|
|
363
|
+
"Ontologies": Ontology,
|
|
364
|
+
"OntologyConfig": OntologyConfig,
|
|
365
|
+
"OntologyConfigs": OntologyConfig,
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
# Normalize input to TitleCase
|
|
369
|
+
normalized = normalize_to_title_case(name)
|
|
370
|
+
|
|
371
|
+
# Look up in registry
|
|
372
|
+
if normalized in registry:
|
|
373
|
+
logger.debug(f"Resolved model '{name}' → {registry[normalized].__name__}")
|
|
374
|
+
return registry[normalized]
|
|
375
|
+
|
|
376
|
+
# Try without trailing 's' (singular form)
|
|
377
|
+
if normalized.endswith("s") and normalized[:-1] in registry:
|
|
378
|
+
logger.debug(f"Resolved model '{name}' → {registry[normalized[:-1]].__name__} (singular)")
|
|
379
|
+
return registry[normalized[:-1]]
|
|
380
|
+
|
|
381
|
+
# Try with trailing 's' (plural form)
|
|
382
|
+
plural = normalized + "s"
|
|
383
|
+
if plural in registry:
|
|
384
|
+
logger.debug(f"Resolved model '{name}' → {registry[plural].__name__} (plural)")
|
|
385
|
+
return registry[plural]
|
|
386
|
+
|
|
387
|
+
available = sorted(set(m.__name__ for m in registry.values()))
|
|
388
|
+
raise ValueError(
|
|
389
|
+
f"Unknown model: '{name}' (normalized: '{normalized}'). "
|
|
390
|
+
f"Available models: {', '.join(available)}"
|
|
391
|
+
)
|