mempalace-code 1.0.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.
- mempalace/README.md +40 -0
- mempalace/__init__.py +6 -0
- mempalace/__main__.py +5 -0
- mempalace/cli.py +811 -0
- mempalace/config.py +149 -0
- mempalace/convo_miner.py +415 -0
- mempalace/dialect.py +1075 -0
- mempalace/entity_detector.py +853 -0
- mempalace/entity_registry.py +639 -0
- mempalace/export.py +378 -0
- mempalace/general_extractor.py +521 -0
- mempalace/knowledge_graph.py +410 -0
- mempalace/layers.py +515 -0
- mempalace/mcp_server.py +873 -0
- mempalace/migrate.py +153 -0
- mempalace/miner.py +1285 -0
- mempalace/normalize.py +328 -0
- mempalace/onboarding.py +489 -0
- mempalace/palace_graph.py +225 -0
- mempalace/py.typed +0 -0
- mempalace/room_detector_local.py +310 -0
- mempalace/searcher.py +305 -0
- mempalace/spellcheck.py +269 -0
- mempalace/split_mega_files.py +309 -0
- mempalace/storage.py +807 -0
- mempalace/version.py +3 -0
- mempalace_code-1.0.0.dist-info/METADATA +489 -0
- mempalace_code-1.0.0.dist-info/RECORD +32 -0
- mempalace_code-1.0.0.dist-info/WHEEL +4 -0
- mempalace_code-1.0.0.dist-info/entry_points.txt +2 -0
- mempalace_code-1.0.0.dist-info/licenses/LICENSE +192 -0
- mempalace_code-1.0.0.dist-info/licenses/NOTICE +17 -0
mempalace/searcher.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
searcher.py — Find anything. Exact words.
|
|
4
|
+
|
|
5
|
+
Semantic search against the palace.
|
|
6
|
+
Returns verbatim text — the actual words, never summaries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .storage import open_store
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("mempalace_mcp")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchError(Exception):
|
|
19
|
+
"""Raised when search cannot proceed (e.g. no palace found)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
|
|
23
|
+
"""
|
|
24
|
+
Search the palace. Returns verbatim drawer content.
|
|
25
|
+
Optionally filter by wing (project) or room (aspect).
|
|
26
|
+
"""
|
|
27
|
+
try:
|
|
28
|
+
store = open_store(palace_path, create=False)
|
|
29
|
+
except Exception:
|
|
30
|
+
print(f"\n No palace found at {palace_path}")
|
|
31
|
+
print(" Run: mempalace init <dir> then mempalace mine <dir>")
|
|
32
|
+
raise SearchError(f"No palace found at {palace_path}")
|
|
33
|
+
|
|
34
|
+
# Build where filter
|
|
35
|
+
where = {}
|
|
36
|
+
if wing and room:
|
|
37
|
+
where = {"$and": [{"wing": wing}, {"room": room}]}
|
|
38
|
+
elif wing:
|
|
39
|
+
where = {"wing": wing}
|
|
40
|
+
elif room:
|
|
41
|
+
where = {"room": room}
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
kwargs = {
|
|
45
|
+
"query_texts": [query],
|
|
46
|
+
"n_results": n_results,
|
|
47
|
+
"include": ["documents", "metadatas", "distances"],
|
|
48
|
+
}
|
|
49
|
+
if where:
|
|
50
|
+
kwargs["where"] = where
|
|
51
|
+
|
|
52
|
+
results = store.query(**kwargs)
|
|
53
|
+
|
|
54
|
+
except Exception as e:
|
|
55
|
+
print(f"\n Search error: {e}")
|
|
56
|
+
raise SearchError(f"Search error: {e}") from e
|
|
57
|
+
|
|
58
|
+
docs = results["documents"][0]
|
|
59
|
+
metas = results["metadatas"][0]
|
|
60
|
+
dists = results["distances"][0]
|
|
61
|
+
|
|
62
|
+
if not docs:
|
|
63
|
+
print(f'\n No results found for: "{query}"')
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
print(f"\n{'=' * 60}")
|
|
67
|
+
print(f' Results for: "{query}"')
|
|
68
|
+
if wing:
|
|
69
|
+
print(f" Wing: {wing}")
|
|
70
|
+
if room:
|
|
71
|
+
print(f" Room: {room}")
|
|
72
|
+
print(f"{'=' * 60}\n")
|
|
73
|
+
|
|
74
|
+
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
|
|
75
|
+
similarity = round(1 - dist, 3)
|
|
76
|
+
source = Path(meta.get("source_file", "?")).name
|
|
77
|
+
wing_name = meta.get("wing", "?")
|
|
78
|
+
room_name = meta.get("room", "?")
|
|
79
|
+
|
|
80
|
+
print(f" [{i}] {wing_name} / {room_name}")
|
|
81
|
+
print(f" Source: {source}")
|
|
82
|
+
print(f" Match: {similarity}")
|
|
83
|
+
print()
|
|
84
|
+
# Print the verbatim text, indented
|
|
85
|
+
for line in doc.strip().split("\n"):
|
|
86
|
+
print(f" {line}")
|
|
87
|
+
print()
|
|
88
|
+
print(f" {'─' * 56}")
|
|
89
|
+
|
|
90
|
+
print()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def search_memories(
|
|
94
|
+
query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5
|
|
95
|
+
) -> dict:
|
|
96
|
+
"""
|
|
97
|
+
Programmatic search — returns a dict instead of printing.
|
|
98
|
+
Used by the MCP server and other callers that need data.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
store = open_store(palace_path, create=False)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error("No palace found at %s: %s", palace_path, e)
|
|
104
|
+
return {
|
|
105
|
+
"error": "No palace found",
|
|
106
|
+
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
# Build where filter
|
|
110
|
+
where = {}
|
|
111
|
+
if wing and room:
|
|
112
|
+
where = {"$and": [{"wing": wing}, {"room": room}]}
|
|
113
|
+
elif wing:
|
|
114
|
+
where = {"wing": wing}
|
|
115
|
+
elif room:
|
|
116
|
+
where = {"room": room}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
kwargs = {
|
|
120
|
+
"query_texts": [query],
|
|
121
|
+
"n_results": n_results,
|
|
122
|
+
"include": ["documents", "metadatas", "distances"],
|
|
123
|
+
}
|
|
124
|
+
if where:
|
|
125
|
+
kwargs["where"] = where
|
|
126
|
+
|
|
127
|
+
results = store.query(**kwargs)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
return {"error": f"Search error: {e}"}
|
|
130
|
+
|
|
131
|
+
docs = results["documents"][0]
|
|
132
|
+
metas = results["metadatas"][0]
|
|
133
|
+
dists = results["distances"][0]
|
|
134
|
+
|
|
135
|
+
hits = []
|
|
136
|
+
for doc, meta, dist in zip(docs, metas, dists):
|
|
137
|
+
hits.append(
|
|
138
|
+
{
|
|
139
|
+
"text": doc,
|
|
140
|
+
"wing": meta.get("wing", "unknown"),
|
|
141
|
+
"room": meta.get("room", "unknown"),
|
|
142
|
+
"source_file": Path(meta.get("source_file", "?")).name,
|
|
143
|
+
"symbol_name": meta.get("symbol_name", "") or "",
|
|
144
|
+
"symbol_type": meta.get("symbol_type", "") or "",
|
|
145
|
+
"language": meta.get("language", "") or "",
|
|
146
|
+
"similarity": round(1 - dist, 3),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"query": query,
|
|
152
|
+
"filters": {"wing": wing, "room": room},
|
|
153
|
+
"results": hits,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
SUPPORTED_LANGUAGES = {
|
|
158
|
+
"python",
|
|
159
|
+
"go",
|
|
160
|
+
"typescript",
|
|
161
|
+
"javascript",
|
|
162
|
+
"rust",
|
|
163
|
+
"java",
|
|
164
|
+
"cpp",
|
|
165
|
+
"c",
|
|
166
|
+
"shell",
|
|
167
|
+
"ruby",
|
|
168
|
+
# web
|
|
169
|
+
"html",
|
|
170
|
+
"css",
|
|
171
|
+
# data / query
|
|
172
|
+
"sql",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
VALID_SYMBOL_TYPES = {
|
|
176
|
+
"function",
|
|
177
|
+
"class",
|
|
178
|
+
"method",
|
|
179
|
+
"struct",
|
|
180
|
+
"interface",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def code_search(
|
|
185
|
+
palace_path: str,
|
|
186
|
+
query: str,
|
|
187
|
+
language: str = None,
|
|
188
|
+
symbol_name: str = None,
|
|
189
|
+
symbol_type: str = None,
|
|
190
|
+
file_glob: str = None,
|
|
191
|
+
wing: str = None,
|
|
192
|
+
n_results: int = 10,
|
|
193
|
+
) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Code-optimized semantic search. Returns symbol name, type, language, and
|
|
196
|
+
full file path per hit.
|
|
197
|
+
|
|
198
|
+
Filters applied in two stages:
|
|
199
|
+
1. LanceDB where clause (pre-query): wing, language, symbol_type.
|
|
200
|
+
2. Python post-filter: symbol_name (case-insensitive substring),
|
|
201
|
+
file_glob (fnmatch against the stored source_file path).
|
|
202
|
+
|
|
203
|
+
Over-fetches n_results*3 (capped at 150) to compensate for post-filter
|
|
204
|
+
discard, then truncates to n_results.
|
|
205
|
+
"""
|
|
206
|
+
if language is not None:
|
|
207
|
+
language = language.lower()
|
|
208
|
+
if language not in SUPPORTED_LANGUAGES:
|
|
209
|
+
return {
|
|
210
|
+
"error": f"Unsupported language: {language!r}",
|
|
211
|
+
"supported_languages": sorted(SUPPORTED_LANGUAGES),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if symbol_type is not None:
|
|
215
|
+
symbol_type = symbol_type.lower()
|
|
216
|
+
if symbol_type not in VALID_SYMBOL_TYPES:
|
|
217
|
+
return {
|
|
218
|
+
"error": f"Invalid symbol_type: {symbol_type!r}",
|
|
219
|
+
"valid_symbol_types": sorted(VALID_SYMBOL_TYPES),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
n_results = max(1, min(50, n_results))
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
store = open_store(palace_path, create=False)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error("No palace found at %s: %s", palace_path, e)
|
|
228
|
+
return {
|
|
229
|
+
"error": "No palace found",
|
|
230
|
+
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Build LanceDB where clause for pre-query filtering
|
|
234
|
+
conditions = []
|
|
235
|
+
if wing:
|
|
236
|
+
conditions.append({"wing": wing})
|
|
237
|
+
if language:
|
|
238
|
+
conditions.append({"language": language})
|
|
239
|
+
if symbol_type:
|
|
240
|
+
conditions.append({"symbol_type": symbol_type})
|
|
241
|
+
|
|
242
|
+
where = None
|
|
243
|
+
if len(conditions) > 1:
|
|
244
|
+
where = {"$and": conditions}
|
|
245
|
+
elif len(conditions) == 1:
|
|
246
|
+
where = conditions[0]
|
|
247
|
+
|
|
248
|
+
fetch_count = min(n_results * 3, 150)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
kwargs = {
|
|
252
|
+
"query_texts": [query],
|
|
253
|
+
"n_results": fetch_count,
|
|
254
|
+
"include": ["documents", "metadatas", "distances"],
|
|
255
|
+
}
|
|
256
|
+
if where:
|
|
257
|
+
kwargs["where"] = where
|
|
258
|
+
|
|
259
|
+
results = store.query(**kwargs)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return {"error": f"Search error: {e}"}
|
|
262
|
+
|
|
263
|
+
docs = results["documents"][0]
|
|
264
|
+
metas = results["metadatas"][0]
|
|
265
|
+
dists = results["distances"][0]
|
|
266
|
+
|
|
267
|
+
hits = []
|
|
268
|
+
for doc, meta, dist in zip(docs, metas, dists):
|
|
269
|
+
sym_name = meta.get("symbol_name", "") or ""
|
|
270
|
+
src_file = meta.get("source_file", "") or ""
|
|
271
|
+
|
|
272
|
+
if symbol_name and symbol_name.lower() not in sym_name.lower():
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
if file_glob and not fnmatch.fnmatch(src_file, file_glob):
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
hits.append(
|
|
279
|
+
{
|
|
280
|
+
"text": doc,
|
|
281
|
+
"wing": meta.get("wing", "unknown"),
|
|
282
|
+
"room": meta.get("room", "unknown"),
|
|
283
|
+
"source_file": src_file,
|
|
284
|
+
"symbol_name": sym_name,
|
|
285
|
+
"symbol_type": meta.get("symbol_type", "") or "",
|
|
286
|
+
"language": meta.get("language", "") or "",
|
|
287
|
+
"line_range": None,
|
|
288
|
+
"similarity": round(1 - dist, 3),
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if len(hits) >= n_results:
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"query": query,
|
|
297
|
+
"filters": {
|
|
298
|
+
"language": language,
|
|
299
|
+
"symbol_name": symbol_name,
|
|
300
|
+
"symbol_type": symbol_type,
|
|
301
|
+
"file_glob": file_glob,
|
|
302
|
+
"wing": wing,
|
|
303
|
+
},
|
|
304
|
+
"results": hits,
|
|
305
|
+
}
|
mempalace/spellcheck.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
spellcheck.py — Spell-correct user messages before palace filing.
|
|
4
|
+
|
|
5
|
+
Preserves:
|
|
6
|
+
- Technical terms (words with digits, hyphens, underscores)
|
|
7
|
+
- CamelCase and ALL_CAPS identifiers
|
|
8
|
+
- Known entity names (from EntityRegistry if available)
|
|
9
|
+
- URLs and file paths
|
|
10
|
+
- Words shorter than 3 chars (common abbreviations, pronouns, etc.)
|
|
11
|
+
- Proper nouns already capitalized in context
|
|
12
|
+
|
|
13
|
+
Corrects:
|
|
14
|
+
- Genuine typos in lowercase, flowing text
|
|
15
|
+
- Common fat-finger words (3am → 3am, knoe → know)
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
from mempalace.spellcheck import spellcheck_user_text
|
|
19
|
+
corrected = spellcheck_user_text("lsresdy knoe the question befor")
|
|
20
|
+
# → "already know the question before" (best effort)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
# Lazy-load autocorrect — not everyone has it installed
|
|
28
|
+
_speller = None
|
|
29
|
+
_autocorrect_available = None
|
|
30
|
+
|
|
31
|
+
# System word list — loaded once, used to skip already-valid words
|
|
32
|
+
_system_words: Optional[set] = None
|
|
33
|
+
_SYSTEM_DICT = Path("/usr/share/dict/words")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_speller():
|
|
37
|
+
global _speller, _autocorrect_available
|
|
38
|
+
if _autocorrect_available is None:
|
|
39
|
+
try:
|
|
40
|
+
from autocorrect import Speller
|
|
41
|
+
|
|
42
|
+
_speller = Speller(lang="en")
|
|
43
|
+
_autocorrect_available = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
_autocorrect_available = False
|
|
46
|
+
return _speller if _autocorrect_available else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_system_words() -> set:
|
|
50
|
+
"""Load /usr/share/dict/words once and cache it."""
|
|
51
|
+
global _system_words
|
|
52
|
+
if _system_words is None:
|
|
53
|
+
if _SYSTEM_DICT.exists():
|
|
54
|
+
with open(_SYSTEM_DICT) as f:
|
|
55
|
+
_system_words = {w.strip().lower() for w in f if w.strip()}
|
|
56
|
+
else:
|
|
57
|
+
_system_words = set()
|
|
58
|
+
return _system_words
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
# Patterns that mark a token as "don't touch this"
|
|
63
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
# Matches any token with a digit anywhere in it: 3am, bge-large-v1.5, top-10
|
|
66
|
+
_HAS_DIGIT = re.compile(r"\d")
|
|
67
|
+
|
|
68
|
+
# CamelCase: ChromaDB, MemPalace, LongMemEval
|
|
69
|
+
_IS_CAMEL = re.compile(r"[A-Z][a-z]+[A-Z]")
|
|
70
|
+
|
|
71
|
+
# ALL_CAPS or all-caps with underscores: NDCG, R@5, MAX_RESULTS
|
|
72
|
+
_IS_ALLCAPS = re.compile(r"^[A-Z_@#$%^&*()+=\[\]{}|<>?.:/\\]+$")
|
|
73
|
+
|
|
74
|
+
# Technical token: contains hyphens or underscores (bge-large, train_test)
|
|
75
|
+
_IS_TECHNICAL = re.compile(r"[-_]")
|
|
76
|
+
|
|
77
|
+
# URL-like or file-path-like
|
|
78
|
+
_IS_URL = re.compile(r"https?://|www\.|/Users/|~/|\.[a-z]{2,4}$", re.IGNORECASE)
|
|
79
|
+
|
|
80
|
+
# Code fences, markdown, or emoji-heavy
|
|
81
|
+
_IS_CODE_OR_EMOJI = re.compile(r"[`*_#{}[\]\\]")
|
|
82
|
+
|
|
83
|
+
# Very short tokens — skip (I, a, ok, my, etc. — also avoids ambiguous 3-char typos
|
|
84
|
+
# like "kno" which autocorrect resolves as "no" rather than "know")
|
|
85
|
+
_MIN_LENGTH = 4
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _should_skip(token: str, known_names: set) -> bool:
|
|
89
|
+
"""Return True if this token should be left as-is."""
|
|
90
|
+
if len(token) < _MIN_LENGTH:
|
|
91
|
+
return True
|
|
92
|
+
if _HAS_DIGIT.search(token):
|
|
93
|
+
return True
|
|
94
|
+
if _IS_CAMEL.search(token):
|
|
95
|
+
return True
|
|
96
|
+
if _IS_ALLCAPS.match(token):
|
|
97
|
+
return True
|
|
98
|
+
if _IS_TECHNICAL.search(token):
|
|
99
|
+
return True
|
|
100
|
+
if _IS_URL.search(token):
|
|
101
|
+
return True
|
|
102
|
+
if _IS_CODE_OR_EMOJI.search(token):
|
|
103
|
+
return True
|
|
104
|
+
# Known proper names (entity registry)
|
|
105
|
+
if token.lower() in known_names:
|
|
106
|
+
return True
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
# Load known entity names from registry (optional, best-effort)
|
|
112
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _load_known_names() -> set:
|
|
116
|
+
"""Pull all registered names from EntityRegistry. Returns empty set on failure."""
|
|
117
|
+
try:
|
|
118
|
+
from mempalace.entity_registry import EntityRegistry
|
|
119
|
+
|
|
120
|
+
reg = EntityRegistry.load()
|
|
121
|
+
names = set()
|
|
122
|
+
for entity in reg._data.get("entities", {}).values():
|
|
123
|
+
names.add(entity.get("canonical", "").lower())
|
|
124
|
+
for alias in entity.get("aliases", []):
|
|
125
|
+
names.add(alias.lower())
|
|
126
|
+
return names
|
|
127
|
+
except Exception:
|
|
128
|
+
return set()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
# Edit distance — used to guard against over-aggressive autocorrect
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _edit_distance(a: str, b: str) -> int:
|
|
137
|
+
"""Levenshtein distance between two strings."""
|
|
138
|
+
if a == b:
|
|
139
|
+
return 0
|
|
140
|
+
if not a:
|
|
141
|
+
return len(b)
|
|
142
|
+
if not b:
|
|
143
|
+
return len(a)
|
|
144
|
+
prev = list(range(len(b) + 1))
|
|
145
|
+
for i, ca in enumerate(a, 1):
|
|
146
|
+
curr = [i]
|
|
147
|
+
for j, cb in enumerate(b, 1):
|
|
148
|
+
curr.append(min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
149
|
+
prev = curr
|
|
150
|
+
return prev[-1]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
# Core correction
|
|
155
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
# Split on word boundaries but keep punctuation attached to tokens
|
|
158
|
+
_TOKEN_RE = re.compile(r"(\S+)")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def spellcheck_user_text(text: str, known_names: Optional[set] = None) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Spell-correct a user message.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
text: Raw user message text.
|
|
167
|
+
known_names: Set of lowercase names/terms to preserve. If None,
|
|
168
|
+
attempts to load from EntityRegistry automatically.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Corrected text. Falls back to original if autocorrect not installed.
|
|
172
|
+
"""
|
|
173
|
+
speller = _get_speller()
|
|
174
|
+
if speller is None:
|
|
175
|
+
return text # autocorrect not installed — pass through unchanged
|
|
176
|
+
|
|
177
|
+
if known_names is None:
|
|
178
|
+
known_names = _load_known_names()
|
|
179
|
+
|
|
180
|
+
# Process token by token, preserving all whitespace
|
|
181
|
+
sys_words = _get_system_words()
|
|
182
|
+
|
|
183
|
+
def _fix(match):
|
|
184
|
+
token = match.group(0)
|
|
185
|
+
# Strip trailing punctuation for checking, reattach after
|
|
186
|
+
stripped = token.rstrip(".,!?;:'\")")
|
|
187
|
+
punct = token[len(stripped) :]
|
|
188
|
+
|
|
189
|
+
if not stripped or _should_skip(stripped, known_names):
|
|
190
|
+
return token
|
|
191
|
+
|
|
192
|
+
# Only correct lowercase words (capitalized words are likely proper nouns)
|
|
193
|
+
if stripped[0].isupper():
|
|
194
|
+
return token
|
|
195
|
+
|
|
196
|
+
# Skip words that are already valid English — prevents "coherently" → "inherently"
|
|
197
|
+
if stripped.lower() in sys_words:
|
|
198
|
+
return token
|
|
199
|
+
|
|
200
|
+
corrected = speller(stripped)
|
|
201
|
+
|
|
202
|
+
# Guard: don't apply if corrected word is too different from original.
|
|
203
|
+
# Extra safety net for words not in the system dict but also not typos.
|
|
204
|
+
if corrected != stripped:
|
|
205
|
+
dist = _edit_distance(stripped, corrected)
|
|
206
|
+
max_edits = 2 if len(stripped) <= 7 else 3
|
|
207
|
+
if dist > max_edits:
|
|
208
|
+
return token
|
|
209
|
+
|
|
210
|
+
return corrected + punct
|
|
211
|
+
|
|
212
|
+
return _TOKEN_RE.sub(_fix, text)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def spellcheck_transcript_line(line: str) -> str:
|
|
216
|
+
"""
|
|
217
|
+
Spell-correct a single transcript line.
|
|
218
|
+
Only touches lines that start with '>' (user turns).
|
|
219
|
+
Assistant turns are never modified.
|
|
220
|
+
"""
|
|
221
|
+
stripped = line.lstrip()
|
|
222
|
+
if not stripped.startswith(">"):
|
|
223
|
+
return line
|
|
224
|
+
|
|
225
|
+
# '> actual message here'
|
|
226
|
+
prefix_len = len(line) - len(stripped) + 2 # '> '
|
|
227
|
+
message = line[prefix_len:]
|
|
228
|
+
if not message.strip():
|
|
229
|
+
return line
|
|
230
|
+
|
|
231
|
+
corrected = spellcheck_user_text(message)
|
|
232
|
+
return line[:prefix_len] + corrected
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def spellcheck_transcript(content: str) -> str:
|
|
236
|
+
"""
|
|
237
|
+
Spell-correct all user turns in a full transcript.
|
|
238
|
+
Only lines starting with '>' are touched.
|
|
239
|
+
"""
|
|
240
|
+
lines = content.split("\n")
|
|
241
|
+
return "\n".join(spellcheck_transcript_line(line) for line in lines)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
# Quick test
|
|
246
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
test_cases = [
|
|
250
|
+
"lsresdy knoe the question befor",
|
|
251
|
+
"isn't there meny diferent benchmarks tesing questions?",
|
|
252
|
+
"also can you pleese spell chekc my questions befroe storing",
|
|
253
|
+
"it's realy hard for me to writte coherently at 3am",
|
|
254
|
+
"Mempalace cant be fine-tunned if you alredy kno the question",
|
|
255
|
+
# Should NOT change these:
|
|
256
|
+
"ChromaDB bge-large-en-v1.5 NDCG@10 R@5",
|
|
257
|
+
"Riley picked up Sam from school",
|
|
258
|
+
"hybrid_v4 top-k=50 longmemeval_bench.py",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
print("Spell-check test\n" + "=" * 50)
|
|
262
|
+
for msg in test_cases:
|
|
263
|
+
result = spellcheck_user_text(msg, known_names={"riley", "sam", "mempalace"})
|
|
264
|
+
changed = " ← CHANGED" if result != msg else ""
|
|
265
|
+
print(f"\nIN: {msg}")
|
|
266
|
+
if result != msg:
|
|
267
|
+
print(f"OUT: {result}{changed}")
|
|
268
|
+
else:
|
|
269
|
+
print("OUT: (unchanged)")
|