ltcai 12.4.0 → 12.5.0
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.
- package/README.md +23 -37
- package/docs/CHANGELOG.md +13 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +9 -9
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/models.py +21 -1
- package/latticeai/api/search.py +8 -1
- package/latticeai/api/worker_compute.py +9 -1
- package/latticeai/core/embedding_providers/__init__.py +14 -0
- package/latticeai/core/embedding_providers/autodetect.py +91 -24
- package/latticeai/core/embedding_providers/base.py +10 -2
- package/latticeai/core/embedding_providers/profiles.py +44 -0
- package/latticeai/core/embedding_providers/text.py +325 -2
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/embedding_recommendation.py +229 -0
- package/latticeai/services/model_capability_registry.py +37 -0
- package/latticeai/services/model_catalog.py +130 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +1 -1
- package/scripts/bump_version.py +106 -6
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_screenshot_pixel_delta.py +6 -2
- package/src-tauri/Cargo.lock +10 -10
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +44 -44
- package/static/app/assets/{Act-inLiZfDE.js → Act-DQ2Ldwo8.js} +2 -2
- package/static/app/assets/{AdminConsole-iLFwSJSg.js → AdminConsole-y6EjiXeH.js} +1 -1
- package/static/app/assets/{Brain-Cb7noiNc.js → Brain-CRWWw-h9.js} +2 -2
- package/static/app/assets/{BrainHome-CfiJ4tau.js → BrainHome-cmvTu7Ea.js} +1 -1
- package/static/app/assets/{BrainSignals-ShxKNNqr.js → BrainSignals-gaNn5E4-.js} +1 -1
- package/static/app/assets/{Capture-Cajj1Trf.js → Capture-BJK7in1j.js} +1 -1
- package/static/app/assets/{Chronicle-CPd8GP_O.js → Chronicle-BuYZ9lMt.js} +1 -1
- package/static/app/assets/{CommandPalette-Cb_GWoYC.js → CommandPalette-BjFwuKaA.js} +1 -1
- package/static/app/assets/{DigitalBrainExplorer-Df6KVrBz.js → DigitalBrainExplorer-DTBRPYJ3.js} +1 -1
- package/static/app/assets/Library-BsmZ-YVh.js +1 -0
- package/static/app/assets/{LivingBrain-u9X_XGC2.js → LivingBrain-aT9hgkPV.js} +1 -1
- package/static/app/assets/ProductFlow-Ct0QLLY5.js +1 -0
- package/static/app/assets/{ReviewCard-C64YFHOR.js → ReviewCard-CdooYtWr.js} +1 -1
- package/static/app/assets/{RunsListPanel-DKZEJcxF.js → RunsListPanel-Btf2DIrG.js} +1 -1
- package/static/app/assets/{System-Dretrknf.js → System-B77M4t-M.js} +1 -1
- package/static/app/assets/{WorkflowGraph-CE6XgWjJ.js → WorkflowGraph-u3ZKLvZe.js} +1 -1
- package/static/app/assets/{WorkflowsPanel-MKU05J7_.js → WorkflowsPanel--nTFq3Cs.js} +2 -2
- package/static/app/assets/{actHelpers-BjMnKMCI.js → actHelpers-xNnCfUeC.js} +1 -1
- package/static/app/assets/arrow-left-Cp4NHbfX.js +1 -0
- package/static/app/assets/{bot-Qgvc2PGU.js → bot-CXwvpODC.js} +1 -1
- package/static/app/assets/{brain-BwGnubn8.js → brain-BzjoGgor.js} +1 -1
- package/static/app/assets/{button-DEloiIsj.js → button-_hHOhp7g.js} +1 -1
- package/static/app/assets/circle-check-qG5dMahn.js +1 -0
- package/static/app/assets/{circle-pause-CkkmXxA9.js → circle-pause-DBOqYYMh.js} +1 -1
- package/static/app/assets/{circle-play-rnb6A1zq.js → circle-play-DA0Vf2NH.js} +1 -1
- package/static/app/assets/{cpu-CzcGFQQq.js → cpu-DwsE0_o3.js} +1 -1
- package/static/app/assets/{download-BTQigJvp.js → download-iCoe2f2Q.js} +1 -1
- package/static/app/assets/{folder-open-CIXPPhhm.js → folder-open-C1vNOMhy.js} +1 -1
- package/static/app/assets/{hard-drive-CZXxdIcT.js → hard-drive-a73KaYth.js} +1 -1
- package/static/app/assets/index-Dbb2YhYw.css +2 -0
- package/static/app/assets/{index-CHQbWj9a.js → index-DpuEeYmP.js} +2 -2
- package/static/app/assets/{input-CTZHuOXe.js → input-BclQNHyj.js} +1 -1
- package/static/app/assets/{link-2-BsG1NQUA.js → link-2-D23wihBe.js} +1 -1
- package/static/app/assets/{permissionCopy-klyWL3ai.js → permissionCopy-DiGZRKAY.js} +1 -1
- package/static/app/assets/{primitives-ZmN4jzq-.js → primitives-1S-XCgmz.js} +1 -1
- package/static/app/assets/search-Cl2k5uhD.js +1 -0
- package/static/app/assets/{share-2-Cuv9rj7A.js → share-2-47_av_aF.js} +1 -1
- package/static/app/assets/{shield-alert-KKySFd03.js → shield-alert-BvKq2P-G.js} +1 -1
- package/static/app/assets/{textarea-CBwqgGSA.js → textarea-DcLO52bu.js} +1 -1
- package/static/app/assets/{useFocusTrap-D93vds_Z.js → useFocusTrap-CpqVPzJ_.js} +1 -1
- package/static/app/assets/{useMutation-B9hHPD0p.js → useMutation-DhI3jqCN.js} +1 -1
- package/static/app/assets/workspace-DwNHbqaX.js +1 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/Library-Dk5Mefl_.js +0 -1
- package/static/app/assets/ProductFlow-CpX0BMr9.js +0 -1
- package/static/app/assets/arrow-left-Cbck4Ig2.js +0 -1
- package/static/app/assets/circle-check-CDygnRMn.js +0 -1
- package/static/app/assets/index-C_dThdD6.css +0 -2
- package/static/app/assets/search-xjKVb4Nh.js +0 -1
- package/static/app/assets/workspace-JGzrc0D8.js +0 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Embedding model capability registry and PC-tailored hardware recommendations.
|
|
2
|
+
|
|
3
|
+
Dedicated module to keep `model_capability_registry.py` under the 1000-line cap
|
|
4
|
+
while providing first-class Google EmbeddingGemma 2 / 300M recommendation logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_embedding_capabilities(
|
|
13
|
+
model_capability_cls: type,
|
|
14
|
+
hardware_profile_cls: type,
|
|
15
|
+
verification_status_cls: type,
|
|
16
|
+
) -> List[Any]:
|
|
17
|
+
"""Construct ModelCapability instances for EmbeddingGemma 2 / 300M."""
|
|
18
|
+
return [
|
|
19
|
+
model_capability_cls(
|
|
20
|
+
id="google/embeddinggemma-2",
|
|
21
|
+
hf_repo_id="google/embeddinggemma-2",
|
|
22
|
+
name="Google EmbeddingGemma 2",
|
|
23
|
+
family="EmbeddingGemma",
|
|
24
|
+
tag="local-embedding",
|
|
25
|
+
size="1.5GB",
|
|
26
|
+
modality="text",
|
|
27
|
+
architecture="gemma3",
|
|
28
|
+
download_size_gb=1.48,
|
|
29
|
+
quantization="FP16",
|
|
30
|
+
provider_hints=["local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"],
|
|
31
|
+
download_strategy="hf_hub",
|
|
32
|
+
load_strategy="sentence_transformers",
|
|
33
|
+
hardware=hardware_profile_cls(
|
|
34
|
+
min_ram_gb=4.0, recommended_ram_gb=8.0, apple_silicon_pref=True, cuda_pref=True,
|
|
35
|
+
notes="Google DeepMind 740M multilingual & code semantic embedding model (768d). Uses ~1.5GB in FP16 HF or ~0.5GB in 4-bit MLX.",
|
|
36
|
+
),
|
|
37
|
+
source_country="미국", source_company="Google",
|
|
38
|
+
verification=verification_status_cls(
|
|
39
|
+
hf_exists=True, hf_last_checked="2026-11-11", has_config=True, has_tokenizer=True,
|
|
40
|
+
has_weights_hint=True, pipeline_tag="feature-extraction", likes=120, license="apache-2.0",
|
|
41
|
+
notes="Hugging Face google/embeddinggemma-2, 768d semantic embeddings.", verified_by="hf-api-light",
|
|
42
|
+
),
|
|
43
|
+
recommended_default=True,
|
|
44
|
+
display_priority=70,
|
|
45
|
+
category="embedding",
|
|
46
|
+
task="embedding",
|
|
47
|
+
dimensions=768,
|
|
48
|
+
),
|
|
49
|
+
model_capability_cls(
|
|
50
|
+
id="mlx-community/embeddinggemma-2-4bit",
|
|
51
|
+
hf_repo_id="mlx-community/embeddinggemma-2-4bit",
|
|
52
|
+
name="Google EmbeddingGemma 2 4-bit",
|
|
53
|
+
family="EmbeddingGemma",
|
|
54
|
+
tag="local-embedding",
|
|
55
|
+
size="0.4GB",
|
|
56
|
+
modality="text",
|
|
57
|
+
architecture="gemma3",
|
|
58
|
+
download_size_gb=0.42,
|
|
59
|
+
quantization="4bit",
|
|
60
|
+
provider_hints=["local_mlx"],
|
|
61
|
+
download_strategy="hf_hub",
|
|
62
|
+
load_strategy="mlx_embeddings",
|
|
63
|
+
hardware=hardware_profile_cls(
|
|
64
|
+
min_ram_gb=4.0, recommended_ram_gb=8.0, apple_silicon_pref=True,
|
|
65
|
+
notes="Apple Silicon MLX 4-bit quantized EmbeddingGemma 2 (768d, 0.42GB on disk, ~0.5GB RAM).",
|
|
66
|
+
),
|
|
67
|
+
source_country="미국", source_company="Google",
|
|
68
|
+
verification=verification_status_cls(
|
|
69
|
+
hf_exists=True, hf_last_checked="2026-11-11", has_config=True, has_tokenizer=True,
|
|
70
|
+
has_weights_hint=True, pipeline_tag="feature-extraction", likes=45, license="apache-2.0",
|
|
71
|
+
notes="library_name=mlx, ungated, 4-bit quantized EmbeddingGemma 2.", verified_by="hf-api-light",
|
|
72
|
+
),
|
|
73
|
+
recommended_default=True,
|
|
74
|
+
display_priority=71,
|
|
75
|
+
category="embedding",
|
|
76
|
+
task="embedding",
|
|
77
|
+
dimensions=768,
|
|
78
|
+
),
|
|
79
|
+
model_capability_cls(
|
|
80
|
+
id="google/embeddinggemma-300m",
|
|
81
|
+
hf_repo_id="google/embeddinggemma-300m",
|
|
82
|
+
name="Google EmbeddingGemma 300M",
|
|
83
|
+
family="EmbeddingGemma",
|
|
84
|
+
tag="local-embedding",
|
|
85
|
+
size="0.6GB",
|
|
86
|
+
modality="text",
|
|
87
|
+
architecture="gemma3",
|
|
88
|
+
download_size_gb=0.60,
|
|
89
|
+
quantization="FP16",
|
|
90
|
+
provider_hints=["local_mlx", "ollama", "vllm", "lmstudio", "llamacpp"],
|
|
91
|
+
download_strategy="hf_hub",
|
|
92
|
+
load_strategy="sentence_transformers",
|
|
93
|
+
hardware=hardware_profile_cls(
|
|
94
|
+
min_ram_gb=2.0, recommended_ram_gb=4.0, apple_silicon_pref=True, cuda_pref=True,
|
|
95
|
+
notes="Compact 300M multilingual semantic embedding model (768d) for constrained-RAM PCs (<8GB).",
|
|
96
|
+
),
|
|
97
|
+
source_country="미국", source_company="Google",
|
|
98
|
+
verification=verification_status_cls(
|
|
99
|
+
hf_exists=True, hf_last_checked="2026-11-11", has_config=True, has_tokenizer=True,
|
|
100
|
+
has_weights_hint=True, pipeline_tag="feature-extraction", likes=85, license="apache-2.0",
|
|
101
|
+
notes="Hugging Face google/embeddinggemma-300m, 768d semantic embeddings.", verified_by="hf-api-light",
|
|
102
|
+
),
|
|
103
|
+
recommended_default=False,
|
|
104
|
+
display_priority=72,
|
|
105
|
+
category="embedding",
|
|
106
|
+
task="embedding",
|
|
107
|
+
dimensions=768,
|
|
108
|
+
),
|
|
109
|
+
model_capability_cls(
|
|
110
|
+
id="mlx-community/embeddinggemma-300m-4bit",
|
|
111
|
+
hf_repo_id="mlx-community/embeddinggemma-300m-4bit",
|
|
112
|
+
name="Google EmbeddingGemma 300M 4-bit",
|
|
113
|
+
family="EmbeddingGemma",
|
|
114
|
+
tag="local-embedding",
|
|
115
|
+
size="0.3GB",
|
|
116
|
+
modality="text",
|
|
117
|
+
architecture="gemma3",
|
|
118
|
+
download_size_gb=0.30,
|
|
119
|
+
quantization="4bit",
|
|
120
|
+
provider_hints=["local_mlx"],
|
|
121
|
+
download_strategy="hf_hub",
|
|
122
|
+
load_strategy="mlx_embeddings",
|
|
123
|
+
hardware=hardware_profile_cls(
|
|
124
|
+
min_ram_gb=2.0, recommended_ram_gb=4.0, apple_silicon_pref=True,
|
|
125
|
+
notes="Ultralight 4-bit MLX EmbeddingGemma 300M (~0.3GB RAM) for low-memory Macs.",
|
|
126
|
+
),
|
|
127
|
+
source_country="미국", source_company="Google",
|
|
128
|
+
verification=verification_status_cls(
|
|
129
|
+
hf_exists=True, hf_last_checked="2026-11-11", has_config=True, has_tokenizer=True,
|
|
130
|
+
has_weights_hint=True, pipeline_tag="feature-extraction", likes=30, license="apache-2.0",
|
|
131
|
+
notes="library_name=mlx, ungated, 4-bit quantized EmbeddingGemma 300M.", verified_by="hf-api-light",
|
|
132
|
+
),
|
|
133
|
+
recommended_default=False,
|
|
134
|
+
display_priority=73,
|
|
135
|
+
category="embedding",
|
|
136
|
+
task="embedding",
|
|
137
|
+
dimensions=768,
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def recommend_embedding_for_hardware(
|
|
143
|
+
*,
|
|
144
|
+
ram_gb: Optional[float] = None,
|
|
145
|
+
apple_silicon: bool = False,
|
|
146
|
+
cuda: bool = False,
|
|
147
|
+
get_capability_fn: Optional[Callable[[str], Any]] = None,
|
|
148
|
+
) -> Dict[str, Any]:
|
|
149
|
+
"""Return a PC-tailored embedding model recommendation accounting for RAM & accelerator."""
|
|
150
|
+
effective_ram = 16.0 if ram_gb is None or ram_gb <= 0 else float(ram_gb)
|
|
151
|
+
cap = get_capability_fn("google/embeddinggemma-2") if get_capability_fn else None
|
|
152
|
+
mlx_cap = get_capability_fn("mlx-community/embeddinggemma-2-4bit") if get_capability_fn else None
|
|
153
|
+
|
|
154
|
+
if effective_ram >= 8.0:
|
|
155
|
+
use_mlx = bool(apple_silicon and mlx_cap is not None)
|
|
156
|
+
footprint_gb = 0.5 if use_mlx else 1.5
|
|
157
|
+
chosen_cap = cap or mlx_cap
|
|
158
|
+
base = chosen_cap.to_legacy_dict() if chosen_cap is not None else {}
|
|
159
|
+
return {
|
|
160
|
+
**base,
|
|
161
|
+
"id": "google/embeddinggemma-2",
|
|
162
|
+
"model_id": "google/embeddinggemma-2",
|
|
163
|
+
"hf_repo_id": "google/embeddinggemma-2",
|
|
164
|
+
"mlx_repo_id": "mlx-community/embeddinggemma-2-4bit",
|
|
165
|
+
"recommended_load_id": "mlx-community/embeddinggemma-2-4bit" if use_mlx else "google/embeddinggemma-2",
|
|
166
|
+
"recommended_engine": "local_mlx" if use_mlx else "huggingface",
|
|
167
|
+
"provider": "mlx" if use_mlx else "huggingface",
|
|
168
|
+
"profile_id": "mlx:embeddinggemma-2" if use_mlx else "local:embeddinggemma-2",
|
|
169
|
+
"name": "Google EmbeddingGemma 2",
|
|
170
|
+
"display_name": "Google EmbeddingGemma 2",
|
|
171
|
+
"family": "EmbeddingGemma",
|
|
172
|
+
"category": "embedding",
|
|
173
|
+
"role": "embedding",
|
|
174
|
+
"task": "embedding",
|
|
175
|
+
"tag": "local-embedding",
|
|
176
|
+
"modality": "text",
|
|
177
|
+
"dimensions": 768,
|
|
178
|
+
"size": "0.4GB" if use_mlx else "1.5GB",
|
|
179
|
+
"download_size": "0.4GB" if use_mlx else "1.5GB",
|
|
180
|
+
"download_size_gb": 0.42 if use_mlx else 1.48,
|
|
181
|
+
"memory_footprint_gb": footprint_gb,
|
|
182
|
+
"effective_llm_ram_gb": max(0.0, round(effective_ram - footprint_gb, 2)),
|
|
183
|
+
"status": "recommended",
|
|
184
|
+
"load_status": "ready",
|
|
185
|
+
"supported": True,
|
|
186
|
+
"recommended_default": True,
|
|
187
|
+
"display_priority": 70,
|
|
188
|
+
"reason": (
|
|
189
|
+
f"Apple Silicon ({int(round(effective_ram))}GB RAM) 맞춤 4-bit MLX / Hugging Face 시맨틱 임베딩 모델 (Google EmbeddingGemma 2, 768차원)"
|
|
190
|
+
if apple_silicon
|
|
191
|
+
else f"{int(round(effective_ram))}GB RAM PC 맞춤 Hugging Face 시맨틱 임베딩 모델 (Google EmbeddingGemma 2, 768차원)"
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
cap_300m = get_capability_fn("google/embeddinggemma-300m") if get_capability_fn else None
|
|
196
|
+
use_mlx = bool(apple_silicon)
|
|
197
|
+
footprint_gb = 0.3
|
|
198
|
+
base = cap_300m.to_legacy_dict() if cap_300m else {}
|
|
199
|
+
return {
|
|
200
|
+
**base,
|
|
201
|
+
"id": "google/embeddinggemma-300m",
|
|
202
|
+
"model_id": "google/embeddinggemma-300m",
|
|
203
|
+
"hf_repo_id": "google/embeddinggemma-300m",
|
|
204
|
+
"mlx_repo_id": "mlx-community/embeddinggemma-300m-4bit",
|
|
205
|
+
"recommended_load_id": "mlx-community/embeddinggemma-300m-4bit" if use_mlx else "google/embeddinggemma-300m",
|
|
206
|
+
"recommended_engine": "local_mlx" if use_mlx else "huggingface",
|
|
207
|
+
"provider": "mlx" if use_mlx else "huggingface",
|
|
208
|
+
"profile_id": "local:embeddinggemma-300m",
|
|
209
|
+
"name": "Google EmbeddingGemma 300M",
|
|
210
|
+
"display_name": "Google EmbeddingGemma 300M",
|
|
211
|
+
"family": "EmbeddingGemma",
|
|
212
|
+
"category": "embedding",
|
|
213
|
+
"role": "embedding",
|
|
214
|
+
"task": "embedding",
|
|
215
|
+
"tag": "local-embedding",
|
|
216
|
+
"modality": "text",
|
|
217
|
+
"dimensions": 768,
|
|
218
|
+
"size": "0.3GB" if use_mlx else "0.6GB",
|
|
219
|
+
"download_size": "0.3GB" if use_mlx else "0.6GB",
|
|
220
|
+
"download_size_gb": 0.30 if use_mlx else 0.60,
|
|
221
|
+
"memory_footprint_gb": footprint_gb,
|
|
222
|
+
"effective_llm_ram_gb": max(0.0, round(effective_ram - footprint_gb, 2)),
|
|
223
|
+
"status": "recommended",
|
|
224
|
+
"load_status": "ready",
|
|
225
|
+
"supported": True,
|
|
226
|
+
"recommended_default": True,
|
|
227
|
+
"display_priority": 72,
|
|
228
|
+
"reason": f"저사양 PC ({int(round(effective_ram))}GB RAM) 맞춤 초경량 시맨틱 임베딩 모델 (Google EmbeddingGemma 300M, 768차원)",
|
|
229
|
+
}
|
|
@@ -43,6 +43,13 @@ from __future__ import annotations
|
|
|
43
43
|
from dataclasses import dataclass, field
|
|
44
44
|
from typing import Any, Dict, List, Optional
|
|
45
45
|
|
|
46
|
+
from .embedding_recommendation import (
|
|
47
|
+
build_embedding_capabilities,
|
|
48
|
+
)
|
|
49
|
+
from .embedding_recommendation import (
|
|
50
|
+
recommend_embedding_for_hardware as _rec_embedding_for_hardware,
|
|
51
|
+
)
|
|
52
|
+
|
|
46
53
|
|
|
47
54
|
@dataclass(frozen=True)
|
|
48
55
|
class HardwareProfile:
|
|
@@ -109,6 +116,9 @@ class ModelCapability:
|
|
|
109
116
|
# UI / rec hints
|
|
110
117
|
recommended_default: bool = False
|
|
111
118
|
display_priority: int = 100
|
|
119
|
+
category: str = "chat"
|
|
120
|
+
task: str = "chat"
|
|
121
|
+
dimensions: Optional[int] = None
|
|
112
122
|
|
|
113
123
|
def to_legacy_dict(self) -> Dict[str, Any]:
|
|
114
124
|
"""Exact shape expected by older ENGINE_MODEL_CATALOG consumers + extra 5.2 fields."""
|
|
@@ -123,6 +133,9 @@ class ModelCapability:
|
|
|
123
133
|
"modality": self.modality,
|
|
124
134
|
"architecture": self.architecture,
|
|
125
135
|
"lifecycle": self.lifecycle,
|
|
136
|
+
"category": self.category,
|
|
137
|
+
"task": self.task,
|
|
138
|
+
"role": "embedding" if self.category == "embedding" else "chat",
|
|
126
139
|
"source_country": self.source_country,
|
|
127
140
|
"source_company": self.source_company,
|
|
128
141
|
"execution_method": self.execution_method,
|
|
@@ -162,6 +175,8 @@ class ModelCapability:
|
|
|
162
175
|
},
|
|
163
176
|
"recommended_default": self.recommended_default,
|
|
164
177
|
}
|
|
178
|
+
if self.dimensions is not None:
|
|
179
|
+
base["dimensions"] = self.dimensions
|
|
165
180
|
return base
|
|
166
181
|
|
|
167
182
|
|
|
@@ -526,6 +541,7 @@ _REGISTRY: List[ModelCapability] = [
|
|
|
526
541
|
display_priority=60,
|
|
527
542
|
),
|
|
528
543
|
]
|
|
544
|
+
_REGISTRY.extend(build_embedding_capabilities(ModelCapability, HardwareProfile, VerificationStatus))
|
|
529
545
|
|
|
530
546
|
|
|
531
547
|
# ── Recognised-only entries (LEGACY) ──────────────────────────────────────────
|
|
@@ -783,3 +799,24 @@ def get_verified_models() -> List[Dict[str, Any]]:
|
|
|
783
799
|
if c.verification.hf_exists and c.verification.has_config and c.verification.has_tokenizer
|
|
784
800
|
]
|
|
785
801
|
|
|
802
|
+
|
|
803
|
+
def get_embedding_capabilities() -> List[ModelCapability]:
|
|
804
|
+
"""Return recommended embedding model capabilities (Google EmbeddingGemma 2 / 300M)."""
|
|
805
|
+
return [c for c in _REGISTRY if c.category == "embedding"]
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def recommend_embedding_for_hardware(
|
|
809
|
+
*,
|
|
810
|
+
ram_gb: Optional[float] = None,
|
|
811
|
+
apple_silicon: bool = False,
|
|
812
|
+
cuda: bool = False,
|
|
813
|
+
) -> Dict[str, Any]:
|
|
814
|
+
"""Return a PC-tailored embedding model recommendation accounting for RAM & accelerator."""
|
|
815
|
+
return _rec_embedding_for_hardware(
|
|
816
|
+
ram_gb=ram_gb,
|
|
817
|
+
apple_silicon=apple_silicon,
|
|
818
|
+
cuda=cuda,
|
|
819
|
+
get_capability_fn=get_capability,
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
@@ -26,9 +26,15 @@ from latticeai.services.model_capability_registry import (
|
|
|
26
26
|
from latticeai.services.model_capability_registry import (
|
|
27
27
|
get_capability as _get_capability,
|
|
28
28
|
)
|
|
29
|
+
from latticeai.services.model_capability_registry import (
|
|
30
|
+
get_embedding_capabilities as _get_embedding_capabilities,
|
|
31
|
+
)
|
|
29
32
|
from latticeai.services.model_capability_registry import (
|
|
30
33
|
get_verified_models as _get_verified_models,
|
|
31
34
|
)
|
|
35
|
+
from latticeai.services.model_capability_registry import (
|
|
36
|
+
recommend_embedding_for_hardware as _recommend_embedding_for_hardware,
|
|
37
|
+
)
|
|
32
38
|
|
|
33
39
|
ENGINE_INSTALLERS: Dict[str, Dict[str, Any]] = {
|
|
34
40
|
"local_mlx": {
|
|
@@ -170,6 +176,18 @@ _ENGINE_ROUTES: Dict[str, Dict[str, str]] = {
|
|
|
170
176
|
"mlx-community/Qwen3.6-35B-A3B-4bit": _gguf_routes(
|
|
171
177
|
"mlx-community/Qwen3.6-35B-A3B-4bit", "ggml-org/Qwen3.6-35B-A3B-GGUF", "Qwen/Qwen3.6-35B-A3B",
|
|
172
178
|
),
|
|
179
|
+
"google/embeddinggemma-2": _gguf_routes(
|
|
180
|
+
"google/embeddinggemma-2", "ggml-org/embeddinggemma-2-GGUF", "google/embeddinggemma-2",
|
|
181
|
+
),
|
|
182
|
+
"mlx-community/embeddinggemma-2-4bit": _gguf_routes(
|
|
183
|
+
"mlx-community/embeddinggemma-2-4bit", "mlx-community/embeddinggemma-2-4bit-GGUF", "mlx-community/embeddinggemma-2-4bit",
|
|
184
|
+
),
|
|
185
|
+
"google/embeddinggemma-300m": _gguf_routes(
|
|
186
|
+
"google/embeddinggemma-300m", "ggml-org/embeddinggemma-300m-GGUF", "google/embeddinggemma-300m",
|
|
187
|
+
),
|
|
188
|
+
"mlx-community/embeddinggemma-300m-4bit": _gguf_routes(
|
|
189
|
+
"mlx-community/embeddinggemma-300m-4bit", "mlx-community/embeddinggemma-300m-4bit-GGUF", "mlx-community/embeddinggemma-300m-4bit",
|
|
190
|
+
),
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
# Names people actually type that are not the repo's own short name. Kept
|
|
@@ -182,6 +200,8 @@ _COMMON_MISNAMES: Dict[str, str] = {
|
|
|
182
200
|
"gemma-4-26b-optiq-4bit": "mlx-community/gemma-4-26B-A4B-it-qat-OptiQ-4bit",
|
|
183
201
|
"qwen-3.8-27b-4bit": "mlx-community/Qwen3.8-27B-4bit",
|
|
184
202
|
"qwen3.8-27b": "mlx-community/Qwen3.8-27B-4bit",
|
|
203
|
+
"embeddinggemma2": "google/embeddinggemma-2",
|
|
204
|
+
"embeddinggemma-2-hf": "google/embeddinggemma-2",
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
# Keyed by both the lowercase short name ("gemma-4-12b-it-4bit") and the
|
|
@@ -198,7 +218,9 @@ MODEL_ENGINE_ALIASES: Dict[str, Dict[str, str]] = {
|
|
|
198
218
|
# Also expose registry helpers directly from here for consumers who want the rich objects
|
|
199
219
|
get_all_capabilities = _get_all_capabilities
|
|
200
220
|
get_capability = _get_capability
|
|
221
|
+
get_embedding_capabilities = _get_embedding_capabilities
|
|
201
222
|
get_verified_models = _get_verified_models
|
|
223
|
+
recommend_embedding_for_hardware = _recommend_embedding_for_hardware
|
|
202
224
|
|
|
203
225
|
_VERSIONED_MODEL_PATTERNS = (
|
|
204
226
|
("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
|
|
@@ -213,6 +235,8 @@ def _version_tuple(raw: str) -> tuple[int, ...]:
|
|
|
213
235
|
|
|
214
236
|
def _model_family_version(model: Dict[str, Any]) -> Optional[tuple[str, tuple[int, ...]]]:
|
|
215
237
|
text = " ".join(str(model.get(key) or "") for key in ("family", "name", "id"))
|
|
238
|
+
if "embeddinggemma" in text.lower() or model.get("category") == "embedding" or model.get("task") == "embedding":
|
|
239
|
+
return None
|
|
216
240
|
for family, pattern in _VERSIONED_MODEL_PATTERNS:
|
|
217
241
|
match = pattern.search(text)
|
|
218
242
|
if match:
|
|
@@ -270,6 +294,8 @@ _BLOCKED_CATALOG_FRAGMENTS = (
|
|
|
270
294
|
|
|
271
295
|
def _is_blocked_catalog_id(model: Dict[str, Any]) -> bool:
|
|
272
296
|
ident = str(model.get("id") or "").lower()
|
|
297
|
+
if "embeddinggemma" in ident:
|
|
298
|
+
return False
|
|
273
299
|
return any(fragment in ident for fragment in _BLOCKED_CATALOG_FRAGMENTS)
|
|
274
300
|
|
|
275
301
|
|
|
@@ -311,3 +337,107 @@ def _finalize_engine_catalog(
|
|
|
311
337
|
|
|
312
338
|
|
|
313
339
|
ENGINE_MODEL_CATALOG = _finalize_engine_catalog(_RAW_ENGINE_MODEL_CATALOG)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def recommend_models_for_pc(
|
|
343
|
+
*,
|
|
344
|
+
ram_gb: float = 16.0,
|
|
345
|
+
apple_silicon: bool = True,
|
|
346
|
+
cuda: bool = False,
|
|
347
|
+
engine: str = "local_mlx",
|
|
348
|
+
) -> Dict[str, Any]:
|
|
349
|
+
"""Compute PC-tailored model recommendations including EmbeddingGemma 2.
|
|
350
|
+
|
|
351
|
+
Evaluates the system RAM/VRAM budget while accounting for the embedding
|
|
352
|
+
model's memory footprint (``google/embeddinggemma-2`` uses ~0.5 GB in 4-bit
|
|
353
|
+
MLX / ~1.5 GB in FP16 HF; ``google/embeddinggemma-300m`` uses ~0.3 GB) so
|
|
354
|
+
the recommended LLM + embedding model setup fits comfortably on the host.
|
|
355
|
+
"""
|
|
356
|
+
embedding_rec = recommend_embedding_for_hardware(
|
|
357
|
+
ram_gb=ram_gb,
|
|
358
|
+
apple_silicon=apple_silicon,
|
|
359
|
+
cuda=cuda,
|
|
360
|
+
)
|
|
361
|
+
embedding_ram_gb = float(embedding_rec.get("memory_footprint_gb") or 0.5)
|
|
362
|
+
effective_llm_ram_gb = max(0.0, float(ram_gb) - embedding_ram_gb)
|
|
363
|
+
|
|
364
|
+
catalog_rows = list(ENGINE_MODEL_CATALOG.get(engine) or ENGINE_MODEL_CATALOG.get("local_mlx") or [])
|
|
365
|
+
models_out: List[Dict[str, Any]] = []
|
|
366
|
+
chat_candidates: List[Dict[str, Any]] = []
|
|
367
|
+
|
|
368
|
+
for row in catalog_rows:
|
|
369
|
+
entry = dict(row)
|
|
370
|
+
category = str(entry.get("category") or "chat")
|
|
371
|
+
min_ram = float(entry.get("min_ram_gb") or 4.0)
|
|
372
|
+
rec_ram = float(entry.get("recommended_ram_gb") or max(min_ram, 8.0))
|
|
373
|
+
dl_gb = float(entry.get("download_size_gb") or 0.0)
|
|
374
|
+
if category == "embedding":
|
|
375
|
+
fits = float(ram_gb) + 0.01 >= min_ram
|
|
376
|
+
comfortable = float(ram_gb) + 0.01 >= rec_ram
|
|
377
|
+
status = "recommended" if comfortable else ("compatible" if fits else "not_recommended")
|
|
378
|
+
entry["compatibility"] = {
|
|
379
|
+
"status": status,
|
|
380
|
+
"reason": embedding_rec.get("reason") if status != "not_recommended" else f"최소 {min_ram:.0f}GB RAM이 필요합니다.",
|
|
381
|
+
"min_ram_gb": min_ram,
|
|
382
|
+
"recommended_ram_gb": rec_ram,
|
|
383
|
+
}
|
|
384
|
+
models_out.append(entry)
|
|
385
|
+
continue
|
|
386
|
+
|
|
387
|
+
# Account for the embedding model footprint when evaluating LLM fit.
|
|
388
|
+
exceeds_combined = dl_gb > 0.0 and (dl_gb + embedding_ram_gb) > float(ram_gb)
|
|
389
|
+
if exceeds_combined or effective_llm_ram_gb + 0.01 < min_ram:
|
|
390
|
+
status = "not_recommended"
|
|
391
|
+
reason = (
|
|
392
|
+
f"임베딩 모델({embedding_rec['id']}, ~{embedding_ram_gb:.1f}GB)과 함께 실행 시 "
|
|
393
|
+
f"최소 {min_ram + embedding_ram_gb:.1f}GB RAM이 필요합니다 (현재 {ram_gb:.1f}GB)."
|
|
394
|
+
)
|
|
395
|
+
elif effective_llm_ram_gb + 0.01 >= rec_ram or float(ram_gb) + 0.01 >= rec_ram:
|
|
396
|
+
status = "recommended"
|
|
397
|
+
reason = (
|
|
398
|
+
f"{ram_gb:.0f}GB RAM 환경에서 {embedding_rec['name']} (~{embedding_ram_gb:.1f}GB) 임베딩 모델과 "
|
|
399
|
+
f"동시에 여유 있게 실행됩니다 (권장 {rec_ram:.0f}GB)."
|
|
400
|
+
)
|
|
401
|
+
else:
|
|
402
|
+
status = "compatible"
|
|
403
|
+
reason = (
|
|
404
|
+
f"실행 가능하지만 임베딩 모델(~{embedding_ram_gb:.1f}GB) 병행 시 메모리가 빠듯할 수 있습니다 "
|
|
405
|
+
f"(최소 {min_ram:.0f}GB / 권장 {rec_ram:.0f}GB)."
|
|
406
|
+
)
|
|
407
|
+
entry["compatibility"] = {
|
|
408
|
+
"status": status,
|
|
409
|
+
"reason": reason,
|
|
410
|
+
"min_ram_gb": min_ram,
|
|
411
|
+
"recommended_ram_gb": rec_ram,
|
|
412
|
+
"embedding_reserved_ram_gb": embedding_ram_gb,
|
|
413
|
+
}
|
|
414
|
+
models_out.append(entry)
|
|
415
|
+
if status in {"recommended", "compatible"}:
|
|
416
|
+
chat_candidates.append(entry)
|
|
417
|
+
|
|
418
|
+
# Ensure the PC-tailored embedding recommendation is present in models_out
|
|
419
|
+
if not any(str(m.get("category") or "") == "embedding" for m in models_out):
|
|
420
|
+
models_out.append(dict(embedding_rec))
|
|
421
|
+
|
|
422
|
+
chat_candidates.sort(
|
|
423
|
+
key=lambda m: (
|
|
424
|
+
0 if m.get("compatibility", {}).get("status") == "recommended" else 1,
|
|
425
|
+
-float(m.get("recommended_ram_gb") or 0.0),
|
|
426
|
+
0 if m.get("recommended_default") else 1,
|
|
427
|
+
int(m.get("recommendation_priority") or 99),
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
top_pick = chat_candidates[0] if chat_candidates else None
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"ram_gb": float(ram_gb),
|
|
434
|
+
"effective_ram_gb": round(effective_llm_ram_gb, 2),
|
|
435
|
+
"embedding_reserved_ram_gb": embedding_ram_gb,
|
|
436
|
+
"apple_silicon": bool(apple_silicon),
|
|
437
|
+
"cuda": bool(cuda),
|
|
438
|
+
"top_pick": top_pick,
|
|
439
|
+
"embedding_model": embedding_rec["id"],
|
|
440
|
+
"embedding_recommendation": embedding_rec,
|
|
441
|
+
"models": models_out,
|
|
442
|
+
}
|
|
443
|
+
|
package/package.json
CHANGED
package/scripts/bump_version.py
CHANGED
|
@@ -13,6 +13,7 @@ Usage:
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import argparse
|
|
16
|
+
import hashlib
|
|
16
17
|
import json
|
|
17
18
|
import re
|
|
18
19
|
import sys
|
|
@@ -44,6 +45,25 @@ RUST_CRATES = (
|
|
|
44
45
|
)
|
|
45
46
|
RUST_LOCKFILES = ("rust/Cargo.lock", "src-tauri/Cargo.lock")
|
|
46
47
|
|
|
48
|
+
# Committed OpenAPI fragments (`rust/fixtures/openapi/<family>.json`) plus
|
|
49
|
+
# `_envelope.json` and `_index.json`. Serialized with `sort_keys=True, indent=2`
|
|
50
|
+
# (`scripts/gen_openapi_fragments.py::canonical_json`).
|
|
51
|
+
OPENAPI_FRAGMENTS = (
|
|
52
|
+
"admin",
|
|
53
|
+
"auth",
|
|
54
|
+
"chat",
|
|
55
|
+
"knowledge_search",
|
|
56
|
+
"mcp_market",
|
|
57
|
+
"memory_brain",
|
|
58
|
+
"models_misc",
|
|
59
|
+
"portability_network",
|
|
60
|
+
"review_proposals",
|
|
61
|
+
"static_ui",
|
|
62
|
+
"ui_redirects",
|
|
63
|
+
"worker_keep",
|
|
64
|
+
"workspace",
|
|
65
|
+
)
|
|
66
|
+
|
|
47
67
|
# (path, kind, pattern) — pattern groups: (prefix, version)
|
|
48
68
|
TARGETS = [
|
|
49
69
|
("latticeai/__init__.py", "regex", r'(__version__ = ")([^"]+)(")'),
|
|
@@ -68,9 +88,20 @@ TARGETS = [
|
|
|
68
88
|
for crate in RUST_CRATES
|
|
69
89
|
],
|
|
70
90
|
("static/app/asset-manifest.json", "json", "version"),
|
|
91
|
+
("frontend/openapi.json", "openapi-spec", None),
|
|
92
|
+
("rust/fixtures/openapi/_envelope.json", "openapi-envelope", None),
|
|
93
|
+
*[
|
|
94
|
+
(f"rust/fixtures/openapi/{family}.json", "openapi-fragment", None)
|
|
95
|
+
for family in OPENAPI_FRAGMENTS
|
|
96
|
+
],
|
|
97
|
+
("rust/fixtures/openapi/_index.json", "openapi-index", None),
|
|
71
98
|
]
|
|
72
99
|
|
|
73
100
|
|
|
101
|
+
def _canonical_openapi_json(payload: object) -> str:
|
|
102
|
+
return json.dumps(payload, sort_keys=True, indent=2) + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
74
105
|
def bump(version: str, *, check: bool = False) -> int:
|
|
75
106
|
if not re.fullmatch(r"\d+\.\d+\.\d+([-.][0-9A-Za-z.]+)?", version):
|
|
76
107
|
print(f"error: '{version}' is not a sane semantic version", file=sys.stderr)
|
|
@@ -82,26 +113,96 @@ def bump(version: str, *, check: bool = False) -> int:
|
|
|
82
113
|
print(f" skip {rel} (missing)")
|
|
83
114
|
continue
|
|
84
115
|
text = path.read_text(encoding="utf-8")
|
|
116
|
+
current = ""
|
|
117
|
+
new = text
|
|
85
118
|
if kind == "regex":
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
ok
|
|
119
|
+
m = re.search(spec, text, flags=re.MULTILINE)
|
|
120
|
+
ok = m is not None
|
|
121
|
+
if ok and m is not None:
|
|
122
|
+
current = m.group(2)
|
|
123
|
+
new, n = re.subn(spec, lambda match: match.group(1) + version + match.group(3), text, count=1, flags=re.MULTILINE)
|
|
124
|
+
changed = n == 1 and new != text
|
|
125
|
+
else:
|
|
126
|
+
changed = False
|
|
89
127
|
elif kind == "json":
|
|
90
128
|
data = json.loads(text)
|
|
91
129
|
ok = spec in data
|
|
92
|
-
changed = ok and data.get(spec) != version
|
|
93
130
|
if ok:
|
|
131
|
+
current = str(data.get(spec))
|
|
132
|
+
changed = current != version
|
|
94
133
|
data[spec] = version
|
|
95
134
|
new = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
135
|
+
else:
|
|
136
|
+
changed = False
|
|
96
137
|
elif kind == "package-lock":
|
|
97
138
|
data = json.loads(text)
|
|
98
139
|
ok = "version" in data
|
|
99
|
-
changed = ok and data.get("version") != version
|
|
100
140
|
if ok:
|
|
141
|
+
current = str(data.get("version"))
|
|
142
|
+
changed = current != version
|
|
101
143
|
data["version"] = version
|
|
102
144
|
if "packages" in data and "" in data["packages"]:
|
|
103
145
|
data["packages"][""]["version"] = version
|
|
104
146
|
new = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
147
|
+
else:
|
|
148
|
+
changed = False
|
|
149
|
+
elif kind == "openapi-spec":
|
|
150
|
+
data = json.loads(text)
|
|
151
|
+
info = data.get("info") if isinstance(data.get("info"), dict) else None
|
|
152
|
+
ok = info is not None and "version" in info
|
|
153
|
+
if ok and info is not None:
|
|
154
|
+
current = str(info["version"])
|
|
155
|
+
changed = current != version
|
|
156
|
+
info["version"] = version
|
|
157
|
+
new = _canonical_openapi_json(data)
|
|
158
|
+
else:
|
|
159
|
+
changed = False
|
|
160
|
+
elif kind == "openapi-envelope":
|
|
161
|
+
data = json.loads(text)
|
|
162
|
+
spec_obj = data.get("spec") if isinstance(data.get("spec"), dict) else None
|
|
163
|
+
info = spec_obj.get("info") if isinstance(spec_obj, dict) and isinstance(spec_obj.get("info"), dict) else None
|
|
164
|
+
ok = "spec_version" in data and info is not None and "version" in info
|
|
165
|
+
if ok and info is not None:
|
|
166
|
+
sv = str(data["spec_version"])
|
|
167
|
+
iv = str(info["version"])
|
|
168
|
+
current = sv if sv != version else iv
|
|
169
|
+
changed = sv != version or iv != version
|
|
170
|
+
data["spec_version"] = version
|
|
171
|
+
info["version"] = version
|
|
172
|
+
new = _canonical_openapi_json(data)
|
|
173
|
+
else:
|
|
174
|
+
changed = False
|
|
175
|
+
elif kind == "openapi-fragment":
|
|
176
|
+
data = json.loads(text)
|
|
177
|
+
ok = "spec_version" in data
|
|
178
|
+
if ok:
|
|
179
|
+
current = str(data["spec_version"])
|
|
180
|
+
changed = current != version
|
|
181
|
+
data["spec_version"] = version
|
|
182
|
+
new = _canonical_openapi_json(data)
|
|
183
|
+
else:
|
|
184
|
+
changed = False
|
|
185
|
+
elif kind == "openapi-index":
|
|
186
|
+
data = json.loads(text)
|
|
187
|
+
ok = "spec_version" in data
|
|
188
|
+
if ok:
|
|
189
|
+
current = str(data["spec_version"])
|
|
190
|
+
data["spec_version"] = version
|
|
191
|
+
if not check:
|
|
192
|
+
env_meta = data.get("envelope")
|
|
193
|
+
if isinstance(env_meta, dict) and "file" in env_meta:
|
|
194
|
+
env_path = path.parent / str(env_meta["file"])
|
|
195
|
+
if env_path.is_file():
|
|
196
|
+
env_meta["sha256"] = hashlib.sha256(env_path.read_bytes()).hexdigest()
|
|
197
|
+
for entry in data.get("fragments", []):
|
|
198
|
+
if isinstance(entry, dict) and "file" in entry:
|
|
199
|
+
frag_path = path.parent / str(entry["file"])
|
|
200
|
+
if frag_path.is_file():
|
|
201
|
+
entry["sha256"] = hashlib.sha256(frag_path.read_bytes()).hexdigest()
|
|
202
|
+
new = _canonical_openapi_json(data)
|
|
203
|
+
changed = current != version or new != text
|
|
204
|
+
else:
|
|
205
|
+
changed = False
|
|
105
206
|
else: # pragma: no cover
|
|
106
207
|
raise AssertionError(kind)
|
|
107
208
|
if not ok:
|
|
@@ -109,7 +210,6 @@ def bump(version: str, *, check: bool = False) -> int:
|
|
|
109
210
|
failures += 1
|
|
110
211
|
continue
|
|
111
212
|
if check:
|
|
112
|
-
current = re.search(spec, text, flags=re.MULTILINE).group(2) if kind == "regex" else json.loads(text)["version"]
|
|
113
213
|
status = "ok" if current == version else f"MISMATCH ({current})"
|
|
114
214
|
if current != version:
|
|
115
215
|
failures += 1
|
|
@@ -6,7 +6,7 @@ const root = process.cwd();
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
7
7
|
const version = pkg.version;
|
|
8
8
|
const releaseDir = `output/release/v${version}`;
|
|
9
|
-
const releaseTheme = "
|
|
9
|
+
const releaseTheme = "Deep Vector";
|
|
10
10
|
const title = `${version} — ${releaseTheme}`;
|
|
11
11
|
const escapedVersion = version.replaceAll(".", "\\.");
|
|
12
12
|
|