memory-lab-mcp 0.1.1__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.
- claude_memory_lab/__init__.py +3 -0
- claude_memory_lab/auto_memory_import.py +427 -0
- claude_memory_lab/cjk_glossary.py +197 -0
- claude_memory_lab/cli.py +743 -0
- claude_memory_lab/config.py +152 -0
- claude_memory_lab/db.py +245 -0
- claude_memory_lab/entities.py +110 -0
- claude_memory_lab/entrypoint.py +123 -0
- claude_memory_lab/eval.py +772 -0
- claude_memory_lab/host_hooks.py +1472 -0
- claude_memory_lab/ingest.py +958 -0
- claude_memory_lab/mcp_server.py +551 -0
- claude_memory_lab/memory.py +587 -0
- claude_memory_lab/memory_scope.py +678 -0
- claude_memory_lab/paths.py +77 -0
- claude_memory_lab/project_lifecycle.py +770 -0
- claude_memory_lab/redact.py +97 -0
- claude_memory_lab/risk_keywords.txt +17 -0
- claude_memory_lab/search.py +606 -0
- claude_memory_lab/text.py +125 -0
- claude_memory_lab/version.py +15 -0
- claude_memory_lab/wiki.py +627 -0
- claude_memory_lab/wiring.py +572 -0
- memory_lab_mcp-0.1.1.dist-info/METADATA +449 -0
- memory_lab_mcp-0.1.1.dist-info/RECORD +29 -0
- memory_lab_mcp-0.1.1.dist-info/WHEEL +5 -0
- memory_lab_mcp-0.1.1.dist-info/entry_points.txt +3 -0
- memory_lab_mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
- memory_lab_mcp-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from claude_memory_lab.memory import (
|
|
12
|
+
add_memory,
|
|
13
|
+
classify_risk,
|
|
14
|
+
get_memory,
|
|
15
|
+
normalize_project_key,
|
|
16
|
+
supersede_memory,
|
|
17
|
+
update_memory,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SOURCE_METADATA_KEYS = (
|
|
21
|
+
"source_kind",
|
|
22
|
+
"source_path",
|
|
23
|
+
"source_type",
|
|
24
|
+
"source_name",
|
|
25
|
+
"origin_session_id",
|
|
26
|
+
"content_sha256",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
TYPE_MAPPING = {
|
|
30
|
+
"feedback": "preference",
|
|
31
|
+
"project": "knowledge",
|
|
32
|
+
"reference": "knowledge",
|
|
33
|
+
"user": "preference",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class AutoMemory:
|
|
39
|
+
path: Path
|
|
40
|
+
name: str
|
|
41
|
+
content: str
|
|
42
|
+
source_type: str
|
|
43
|
+
memory_type: str
|
|
44
|
+
origin_session_id: str | None
|
|
45
|
+
content_sha256: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ImportAction:
|
|
50
|
+
kind: str
|
|
51
|
+
memory: AutoMemory
|
|
52
|
+
memory_id: int | None = None
|
|
53
|
+
duplicate_id: int | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_scalar(value: str, path: Path, key: str) -> str:
|
|
57
|
+
value = value.strip()
|
|
58
|
+
if not value or value in {">", "|", ">-", "|-"}:
|
|
59
|
+
raise ValueError(f"unsupported {key} in {path}")
|
|
60
|
+
if value[0] in {'"', "'"}:
|
|
61
|
+
try:
|
|
62
|
+
parsed = ast.literal_eval(value)
|
|
63
|
+
except (SyntaxError, ValueError) as exc:
|
|
64
|
+
raise ValueError(f"invalid quoted {key} in {path}") from exc
|
|
65
|
+
if not isinstance(parsed, str):
|
|
66
|
+
raise ValueError(f"invalid {key} in {path}")
|
|
67
|
+
return parsed.strip()
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_auto_memory(path: Path) -> AutoMemory:
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
lines = text.splitlines()
|
|
74
|
+
if not lines or lines[0].strip() != "---":
|
|
75
|
+
raise ValueError(f"missing frontmatter in {path}")
|
|
76
|
+
try:
|
|
77
|
+
closing = next(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---")
|
|
78
|
+
except StopIteration as exc:
|
|
79
|
+
raise ValueError(f"unterminated frontmatter in {path}") from exc
|
|
80
|
+
|
|
81
|
+
fields: dict[str, str] = {}
|
|
82
|
+
metadata: dict[str, str] = {}
|
|
83
|
+
section: str | None = None
|
|
84
|
+
for line in lines[1:closing]:
|
|
85
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
86
|
+
continue
|
|
87
|
+
indentation = len(line) - len(line.lstrip())
|
|
88
|
+
if ":" not in line:
|
|
89
|
+
raise ValueError(f"malformed frontmatter in {path}")
|
|
90
|
+
key, value = line.strip().split(":", 1)
|
|
91
|
+
if indentation == 0:
|
|
92
|
+
section = key if not value.strip() else None
|
|
93
|
+
if value.strip():
|
|
94
|
+
fields[key] = _parse_scalar(value, path, key)
|
|
95
|
+
elif section == "metadata":
|
|
96
|
+
metadata[key] = _parse_scalar(value, path, f"metadata.{key}")
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"unsupported nested frontmatter in {path}")
|
|
99
|
+
|
|
100
|
+
name = fields.get("name", "").strip()
|
|
101
|
+
content = fields.get("description", "").strip()
|
|
102
|
+
source_type = (fields.get("type") or metadata.get("type") or "").strip().lower()
|
|
103
|
+
if not name:
|
|
104
|
+
raise ValueError(f"missing name in {path}")
|
|
105
|
+
if not content:
|
|
106
|
+
raise ValueError(f"missing description in {path}")
|
|
107
|
+
if source_type not in TYPE_MAPPING:
|
|
108
|
+
raise ValueError(f"unknown auto-memory type {source_type!r} in {path}")
|
|
109
|
+
|
|
110
|
+
return AutoMemory(
|
|
111
|
+
path=path,
|
|
112
|
+
name=name,
|
|
113
|
+
content=content,
|
|
114
|
+
source_type=source_type,
|
|
115
|
+
memory_type=TYPE_MAPPING[source_type],
|
|
116
|
+
origin_session_id=fields.get("originSessionId") or metadata.get("originSessionId"),
|
|
117
|
+
content_sha256=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_selected_memories(source_dir: Path, includes: list[str]) -> list[AutoMemory]:
|
|
122
|
+
root = source_dir.expanduser().resolve()
|
|
123
|
+
if not root.is_dir():
|
|
124
|
+
raise ValueError(f"auto-memory directory not found: {root}")
|
|
125
|
+
|
|
126
|
+
memories = []
|
|
127
|
+
seen_paths = set()
|
|
128
|
+
for include in includes:
|
|
129
|
+
path = (root / include).resolve()
|
|
130
|
+
if path.parent != root:
|
|
131
|
+
raise ValueError(f"auto-memory path must be directly inside {root}: {include}")
|
|
132
|
+
if path.name == "MEMORY.md" or path in seen_paths:
|
|
133
|
+
continue
|
|
134
|
+
seen_paths.add(path)
|
|
135
|
+
if path.suffix.lower() != ".md" or not path.is_file():
|
|
136
|
+
raise ValueError(f"auto-memory file not found: {path}")
|
|
137
|
+
memories.append(parse_auto_memory(path))
|
|
138
|
+
return memories
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _metadata(memory: AutoMemory) -> dict[str, object]:
|
|
142
|
+
metadata: dict[str, object] = {
|
|
143
|
+
"source_kind": "claude_auto_memory",
|
|
144
|
+
"source_path": str(memory.path),
|
|
145
|
+
"source_type": memory.source_type,
|
|
146
|
+
"source_name": memory.name,
|
|
147
|
+
"content_sha256": memory.content_sha256,
|
|
148
|
+
}
|
|
149
|
+
if memory.origin_session_id:
|
|
150
|
+
metadata["origin_session_id"] = memory.origin_session_id
|
|
151
|
+
return metadata
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _source_metadata_view(metadata: dict[str, object]) -> dict[str, object]:
|
|
155
|
+
return {key: metadata.get(key) for key in SOURCE_METADATA_KEYS}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def plan_auto_memory_import(
|
|
159
|
+
conn: sqlite3.Connection | None,
|
|
160
|
+
source_dir: Path,
|
|
161
|
+
includes: list[str],
|
|
162
|
+
project: str,
|
|
163
|
+
) -> list[ImportAction]:
|
|
164
|
+
memories = load_selected_memories(source_dir, includes)
|
|
165
|
+
project_key = normalize_project_key(project)
|
|
166
|
+
if project_key is None:
|
|
167
|
+
raise ValueError("project must not be blank")
|
|
168
|
+
rows = (
|
|
169
|
+
conn.execute(
|
|
170
|
+
"""
|
|
171
|
+
select id, content, memory_type, status, risk_tier, metadata_json
|
|
172
|
+
from memories
|
|
173
|
+
where status in ('active', 'pending')
|
|
174
|
+
and (lower(project_key) = ? or project_key is null)
|
|
175
|
+
order by case status when 'active' then 0 else 1 end, id
|
|
176
|
+
""",
|
|
177
|
+
(project_key,),
|
|
178
|
+
).fetchall()
|
|
179
|
+
if conn is not None
|
|
180
|
+
else []
|
|
181
|
+
)
|
|
182
|
+
by_source: dict[str, sqlite3.Row] = {}
|
|
183
|
+
by_hash: dict[str, list[sqlite3.Row]] = {}
|
|
184
|
+
row_hashes: dict[int, str] = {}
|
|
185
|
+
for row in rows:
|
|
186
|
+
metadata = json.loads(row["metadata_json"])
|
|
187
|
+
content_hash = metadata.get("content_sha256")
|
|
188
|
+
if not isinstance(content_hash, str):
|
|
189
|
+
content_hash = hashlib.sha256(
|
|
190
|
+
row["content"].strip().encode("utf-8")
|
|
191
|
+
).hexdigest()
|
|
192
|
+
row_hashes[int(row["id"])] = content_hash
|
|
193
|
+
by_hash.setdefault(content_hash, []).append(row)
|
|
194
|
+
if metadata.get("source_kind") == "claude_auto_memory":
|
|
195
|
+
source_path = metadata.get("source_path")
|
|
196
|
+
if isinstance(source_path, str):
|
|
197
|
+
by_source[source_path] = row
|
|
198
|
+
|
|
199
|
+
vacated_ids = {
|
|
200
|
+
int(source_row["id"])
|
|
201
|
+
for memory in memories
|
|
202
|
+
if (source_row := by_source.get(str(memory.path))) is not None
|
|
203
|
+
and row_hashes[int(source_row["id"])] != memory.content_sha256
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def available_duplicate(content_hash: str) -> sqlite3.Row | None:
|
|
207
|
+
return next(
|
|
208
|
+
(
|
|
209
|
+
row
|
|
210
|
+
for row in by_hash.get(content_hash, [])
|
|
211
|
+
if int(row["id"]) not in vacated_ids
|
|
212
|
+
),
|
|
213
|
+
None,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if os.getenv("MEMORY_LAB_REQUIRE_REVIEW", "") == "1":
|
|
217
|
+
pending_changes = []
|
|
218
|
+
for memory in memories:
|
|
219
|
+
source_row = by_source.get(str(memory.path))
|
|
220
|
+
if source_row is None or source_row["status"] != "active":
|
|
221
|
+
continue
|
|
222
|
+
source_id = int(source_row["id"])
|
|
223
|
+
if row_hashes[source_id] == memory.content_sha256:
|
|
224
|
+
continue
|
|
225
|
+
tier = (
|
|
226
|
+
"high"
|
|
227
|
+
if source_row["risk_tier"] == "high"
|
|
228
|
+
else classify_risk(memory.content, memory.memory_type)
|
|
229
|
+
)
|
|
230
|
+
if tier == "high" and available_duplicate(memory.content_sha256) is None:
|
|
231
|
+
pending_changes.append(
|
|
232
|
+
(row_hashes[source_id], memory.content_sha256)
|
|
233
|
+
)
|
|
234
|
+
pending_old_hashes = {old_hash for old_hash, _ in pending_changes}
|
|
235
|
+
if any(
|
|
236
|
+
new_hash in pending_old_hashes
|
|
237
|
+
for _, new_hash in pending_changes
|
|
238
|
+
):
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"review-gated content dependency must be resolved before import"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
actions_by_index: dict[int, ImportAction] = {}
|
|
244
|
+
planned_hashes: set[str] = set()
|
|
245
|
+
indexed_memories = list(enumerate(memories))
|
|
246
|
+
for index, memory in indexed_memories:
|
|
247
|
+
source_row = by_source.get(str(memory.path))
|
|
248
|
+
if source_row is None:
|
|
249
|
+
continue
|
|
250
|
+
source_metadata = json.loads(source_row["metadata_json"])
|
|
251
|
+
if source_row["memory_type"] != memory.memory_type:
|
|
252
|
+
raise ValueError(f"auto-memory type changed for {memory.path}")
|
|
253
|
+
if source_metadata.get("content_sha256") == memory.content_sha256:
|
|
254
|
+
if _source_metadata_view(source_metadata) == _source_metadata_view(
|
|
255
|
+
_metadata(memory)
|
|
256
|
+
):
|
|
257
|
+
actions_by_index[index] = ImportAction(
|
|
258
|
+
"unchanged", memory, int(source_row["id"])
|
|
259
|
+
)
|
|
260
|
+
else:
|
|
261
|
+
actions_by_index[index] = ImportAction(
|
|
262
|
+
"update", memory, int(source_row["id"])
|
|
263
|
+
)
|
|
264
|
+
planned_hashes.add(memory.content_sha256)
|
|
265
|
+
continue
|
|
266
|
+
duplicate_row = available_duplicate(memory.content_sha256)
|
|
267
|
+
if duplicate_row is not None:
|
|
268
|
+
actions_by_index[index] = ImportAction(
|
|
269
|
+
"duplicate",
|
|
270
|
+
memory,
|
|
271
|
+
int(source_row["id"]),
|
|
272
|
+
int(duplicate_row["id"]),
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
if memory.content_sha256 in planned_hashes:
|
|
276
|
+
actions_by_index[index] = ImportAction(
|
|
277
|
+
"duplicate", memory, int(source_row["id"])
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
actions_by_index[index] = ImportAction(
|
|
281
|
+
"update", memory, int(source_row["id"])
|
|
282
|
+
)
|
|
283
|
+
planned_hashes.add(memory.content_sha256)
|
|
284
|
+
|
|
285
|
+
for index, memory in indexed_memories:
|
|
286
|
+
if index in actions_by_index:
|
|
287
|
+
continue
|
|
288
|
+
duplicate_row = available_duplicate(memory.content_sha256)
|
|
289
|
+
if duplicate_row is not None or memory.content_sha256 in planned_hashes:
|
|
290
|
+
actions_by_index[index] = ImportAction("duplicate", memory)
|
|
291
|
+
continue
|
|
292
|
+
actions_by_index[index] = ImportAction("add", memory)
|
|
293
|
+
planned_hashes.add(memory.content_sha256)
|
|
294
|
+
|
|
295
|
+
return [actions_by_index[index] for index in range(len(memories))]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _transfer_pending_predecessors(
|
|
299
|
+
conn: sqlite3.Connection,
|
|
300
|
+
pending_ids: set[int],
|
|
301
|
+
created_by_hash: dict[str, int],
|
|
302
|
+
) -> None:
|
|
303
|
+
for pending_id in pending_ids:
|
|
304
|
+
pending = get_memory(conn, pending_id)
|
|
305
|
+
predecessor_value = pending.metadata.get("pending_supersedes")
|
|
306
|
+
predecessor_ids = (
|
|
307
|
+
predecessor_value
|
|
308
|
+
if isinstance(predecessor_value, list)
|
|
309
|
+
else [predecessor_value]
|
|
310
|
+
if predecessor_value is not None
|
|
311
|
+
else []
|
|
312
|
+
)
|
|
313
|
+
remaining = []
|
|
314
|
+
for predecessor_id in predecessor_ids:
|
|
315
|
+
predecessor = get_memory(conn, int(predecessor_id))
|
|
316
|
+
if predecessor is None:
|
|
317
|
+
raise ValueError(f"memory {predecessor_id} not found")
|
|
318
|
+
predecessor_hash = predecessor.metadata.get("content_sha256")
|
|
319
|
+
if not isinstance(predecessor_hash, str):
|
|
320
|
+
predecessor_hash = hashlib.sha256(
|
|
321
|
+
predecessor.content.strip().encode("utf-8")
|
|
322
|
+
).hexdigest()
|
|
323
|
+
successor_id = created_by_hash.get(predecessor_hash)
|
|
324
|
+
successor = (
|
|
325
|
+
get_memory(conn, successor_id)
|
|
326
|
+
if successor_id is not None
|
|
327
|
+
else None
|
|
328
|
+
)
|
|
329
|
+
if (
|
|
330
|
+
successor is not None
|
|
331
|
+
and successor.status == "active"
|
|
332
|
+
and successor.id != predecessor.id
|
|
333
|
+
):
|
|
334
|
+
supersede_memory(
|
|
335
|
+
conn,
|
|
336
|
+
predecessor.id,
|
|
337
|
+
successor.id,
|
|
338
|
+
"human",
|
|
339
|
+
commit=False,
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
remaining.append(predecessor.id)
|
|
343
|
+
metadata = dict(pending.metadata)
|
|
344
|
+
metadata.pop("pending_supersedes", None)
|
|
345
|
+
if remaining:
|
|
346
|
+
metadata["pending_supersedes"] = (
|
|
347
|
+
remaining[0] if len(remaining) == 1 else remaining
|
|
348
|
+
)
|
|
349
|
+
conn.execute(
|
|
350
|
+
"update memories set metadata_json = ? where id = ?",
|
|
351
|
+
(json.dumps(metadata, sort_keys=True), pending.id),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def execute_auto_memory_import(
|
|
356
|
+
conn: sqlite3.Connection,
|
|
357
|
+
actions: list[ImportAction],
|
|
358
|
+
project: str,
|
|
359
|
+
) -> None:
|
|
360
|
+
created_by_hash: dict[str, int] = {}
|
|
361
|
+
pending_ids: set[int] = set()
|
|
362
|
+
conn.execute("savepoint import_auto_memory")
|
|
363
|
+
try:
|
|
364
|
+
for action in actions:
|
|
365
|
+
if action.kind == "add":
|
|
366
|
+
written = add_memory(
|
|
367
|
+
conn,
|
|
368
|
+
action.memory.content,
|
|
369
|
+
action.memory.memory_type,
|
|
370
|
+
"human",
|
|
371
|
+
project_key=project,
|
|
372
|
+
metadata=_metadata(action.memory),
|
|
373
|
+
commit=False,
|
|
374
|
+
)
|
|
375
|
+
if written.status == "active" or action.memory.content_sha256 not in created_by_hash:
|
|
376
|
+
created_by_hash[action.memory.content_sha256] = written.id
|
|
377
|
+
if written.status == "pending":
|
|
378
|
+
pending_ids.add(written.id)
|
|
379
|
+
elif action.kind == "update":
|
|
380
|
+
written = update_memory(
|
|
381
|
+
conn,
|
|
382
|
+
action.memory_id,
|
|
383
|
+
action.memory.content,
|
|
384
|
+
"human",
|
|
385
|
+
metadata=_metadata(action.memory),
|
|
386
|
+
commit=False,
|
|
387
|
+
)
|
|
388
|
+
if written.status == "active" or action.memory.content_sha256 not in created_by_hash:
|
|
389
|
+
created_by_hash[action.memory.content_sha256] = written.id
|
|
390
|
+
if written.status == "pending":
|
|
391
|
+
pending_ids.add(written.id)
|
|
392
|
+
|
|
393
|
+
for action in actions:
|
|
394
|
+
if action.kind != "duplicate" or action.memory_id is None:
|
|
395
|
+
continue
|
|
396
|
+
old = get_memory(conn, action.memory_id)
|
|
397
|
+
if old is not None and old.status == "superseded":
|
|
398
|
+
continue
|
|
399
|
+
successor_id = created_by_hash.get(
|
|
400
|
+
action.memory.content_sha256
|
|
401
|
+
) or action.duplicate_id
|
|
402
|
+
if successor_id is None:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"duplicate successor not found for {action.memory.path}"
|
|
405
|
+
)
|
|
406
|
+
supersede_memory(
|
|
407
|
+
conn,
|
|
408
|
+
action.memory_id,
|
|
409
|
+
successor_id,
|
|
410
|
+
"human",
|
|
411
|
+
commit=False,
|
|
412
|
+
)
|
|
413
|
+
successor = get_memory(conn, successor_id)
|
|
414
|
+
if successor is not None and successor.status == "pending":
|
|
415
|
+
pending_ids.add(successor.id)
|
|
416
|
+
|
|
417
|
+
_transfer_pending_predecessors(
|
|
418
|
+
conn,
|
|
419
|
+
pending_ids,
|
|
420
|
+
created_by_hash,
|
|
421
|
+
)
|
|
422
|
+
conn.execute("release savepoint import_auto_memory")
|
|
423
|
+
conn.commit()
|
|
424
|
+
except Exception:
|
|
425
|
+
conn.execute("rollback to savepoint import_auto_memory")
|
|
426
|
+
conn.execute("release savepoint import_auto_memory")
|
|
427
|
+
raise
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Chinese-to-English query bridge for a lexical, English-language store.
|
|
2
|
+
|
|
3
|
+
Memories are written in English while the questions are asked in Chinese, so a
|
|
4
|
+
pure-CJK query has nothing to match: FTS5 yields no terms for it, and the
|
|
5
|
+
`content LIKE '%term%'` fallback finds no Chinese inside an English memory.
|
|
6
|
+
Measured on the live store, that made Chinese questions fail outright.
|
|
7
|
+
|
|
8
|
+
This maps the recurring Chinese vocabulary onto the English words the memories
|
|
9
|
+
actually use, and appends the expansions to the query before it is tokenized.
|
|
10
|
+
It is deliberately a hand-maintained lookup rather than embeddings or a
|
|
11
|
+
translation call: it costs nothing at query time, is deterministic, and its
|
|
12
|
+
effect is measurable by `eval/memory-v2.json`.
|
|
13
|
+
|
|
14
|
+
Keys are matched as substrings, so Traditional and Simplified forms are listed
|
|
15
|
+
separately, and every key is at least two characters: single characters fire
|
|
16
|
+
inside unrelated words (打掃 -> scan, 封鎖 -> lock) and were measured to add no
|
|
17
|
+
recall. Terms were taken from the 120 most recent real Chinese queries in
|
|
18
|
+
query_log plus the phrasings in the retrieval eval; general question vocabulary
|
|
19
|
+
is preferred over proper nouns, which would only fit the eval.
|
|
20
|
+
|
|
21
|
+
ponytail: expansions are appended unbounded. Measured ceiling — a pathological
|
|
22
|
+
query containing every key expands to ~260 FTS terms and still returns in under
|
|
23
|
+
30 ms; a realistic long Chinese prompt takes a few ms. Flat OR chains of 10000
|
|
24
|
+
terms were also measured to run fine, so there is no depth limit to respect —
|
|
25
|
+
add a cap only if query latency actually regresses.
|
|
26
|
+
|
|
27
|
+
Entries that are generic enough to appear in almost any sentence (自動, 完整,
|
|
28
|
+
實際, 安全, 版本 ...) are deliberately absent: they expanded into terms that
|
|
29
|
+
matched unrelated memories and doubled the hard-negative false-positive rate
|
|
30
|
+
without adding any recall.
|
|
31
|
+
"""
|
|
32
|
+
GLOSSARY = {
|
|
33
|
+
# retrieval / memory domain
|
|
34
|
+
"記憶": "memory memories",
|
|
35
|
+
"记忆": "memory memories",
|
|
36
|
+
"搜尋": "search retrieval",
|
|
37
|
+
"搜索": "search retrieval",
|
|
38
|
+
"檢索": "retrieval search",
|
|
39
|
+
"注入": "inject injection",
|
|
40
|
+
"相關性": "relevance",
|
|
41
|
+
"門檻": "threshold cutoff",
|
|
42
|
+
"阈值": "threshold cutoff",
|
|
43
|
+
"分數": "score scores scoring",
|
|
44
|
+
"分数": "score scores scoring",
|
|
45
|
+
"排序": "rank ranking sort",
|
|
46
|
+
"過濾": "filter",
|
|
47
|
+
"筆記": "note notes",
|
|
48
|
+
"笔记": "note notes",
|
|
49
|
+
"索引": "index",
|
|
50
|
+
"快取": "cache",
|
|
51
|
+
"缓存": "cache",
|
|
52
|
+
# engineering verbs / nouns
|
|
53
|
+
"測試": "test tests testing",
|
|
54
|
+
"测试": "test tests testing",
|
|
55
|
+
"問題": "issue issues problem problems bug bugs",
|
|
56
|
+
"问题": "issue issues problem problems bug bugs",
|
|
57
|
+
"代碼": "code",
|
|
58
|
+
"代码": "code",
|
|
59
|
+
"程式碼": "code",
|
|
60
|
+
"執行": "run execute",
|
|
61
|
+
"执行": "run execute",
|
|
62
|
+
"驗收": "acceptance verify",
|
|
63
|
+
"验收": "acceptance verify",
|
|
64
|
+
"結果": "result",
|
|
65
|
+
"结果": "result",
|
|
66
|
+
"專案": "project projects",
|
|
67
|
+
"项目": "project projects",
|
|
68
|
+
"設定": "config setting",
|
|
69
|
+
"设定": "config setting",
|
|
70
|
+
"配置": "config configuration",
|
|
71
|
+
"資料": "data",
|
|
72
|
+
"数据": "data",
|
|
73
|
+
"資料庫": "database",
|
|
74
|
+
"数据库": "database",
|
|
75
|
+
"確認": "confirm verify",
|
|
76
|
+
"确认": "confirm verify",
|
|
77
|
+
"檢查": "check",
|
|
78
|
+
"检查": "check",
|
|
79
|
+
"實作": "implement implementation",
|
|
80
|
+
"实现": "implement implementation",
|
|
81
|
+
"合併": "merge",
|
|
82
|
+
"流程": "workflow process",
|
|
83
|
+
"規格": "spec specification",
|
|
84
|
+
"规格": "spec specification",
|
|
85
|
+
"契約": "contract",
|
|
86
|
+
"契约": "contract",
|
|
87
|
+
"約束": "constraint",
|
|
88
|
+
"约束": "constraint",
|
|
89
|
+
"風險": "risk",
|
|
90
|
+
"风险": "risk",
|
|
91
|
+
"行為": "behavior",
|
|
92
|
+
"行为": "behavior",
|
|
93
|
+
"狀態": "status state",
|
|
94
|
+
"状态": "status state",
|
|
95
|
+
"架構": "architecture",
|
|
96
|
+
"效能": "performance",
|
|
97
|
+
"性能": "performance",
|
|
98
|
+
"權限": "permission",
|
|
99
|
+
"備份": "backup",
|
|
100
|
+
"遷移": "migration migrate",
|
|
101
|
+
"迁移": "migration migrate",
|
|
102
|
+
"部署": "deploy deploys deployment",
|
|
103
|
+
"回滾": "rollback",
|
|
104
|
+
"發布": "release publish",
|
|
105
|
+
"提交": "commit",
|
|
106
|
+
"分支": "branch",
|
|
107
|
+
"標籤": "label tag",
|
|
108
|
+
"欄位": "column columns field fields",
|
|
109
|
+
"監控": "monitor",
|
|
110
|
+
"指標": "metric",
|
|
111
|
+
"警報": "alert",
|
|
112
|
+
"逾時": "timeout",
|
|
113
|
+
"重試": "retry",
|
|
114
|
+
"併發": "concurrency concurrent",
|
|
115
|
+
"憑證": "credential",
|
|
116
|
+
"登入": "login authenticate",
|
|
117
|
+
"端點": "endpoint",
|
|
118
|
+
"請求": "request",
|
|
119
|
+
"回應": "response",
|
|
120
|
+
"迴圈": "loop",
|
|
121
|
+
"循环": "loop",
|
|
122
|
+
"清單": "list",
|
|
123
|
+
"決定": "decision decisions decide decided",
|
|
124
|
+
"决定": "decision decisions decide decided",
|
|
125
|
+
"錯誤": "error errors bug bugs",
|
|
126
|
+
"错误": "error errors bug bugs",
|
|
127
|
+
"修法": "fix option approach",
|
|
128
|
+
"修復": "fix fixes fixed repair",
|
|
129
|
+
"修复": "fix fixes fixed repair",
|
|
130
|
+
"實測": "measured verified",
|
|
131
|
+
"編碼": "encoding",
|
|
132
|
+
"编码": "encoding",
|
|
133
|
+
# general vocabulary that recurs in questions rather than in answers
|
|
134
|
+
"原因": "cause reason root cause",
|
|
135
|
+
"模組": "module modules",
|
|
136
|
+
"模块": "module",
|
|
137
|
+
"檔案": "file files",
|
|
138
|
+
"文件": "file document",
|
|
139
|
+
"內容": "content",
|
|
140
|
+
"内容": "content",
|
|
141
|
+
"證明": "proof prove evidence",
|
|
142
|
+
"证明": "proof prove evidence",
|
|
143
|
+
"候選": "candidate",
|
|
144
|
+
"回傳": "return returns",
|
|
145
|
+
"返回": "return returns",
|
|
146
|
+
"結論": "conclusion conclude",
|
|
147
|
+
"分析": "analysis analyze",
|
|
148
|
+
"可靠": "reliable trust sound",
|
|
149
|
+
"相信": "trust",
|
|
150
|
+
"內建": "bundled built-in",
|
|
151
|
+
"内置": "bundled built-in",
|
|
152
|
+
"標示": "flag label",
|
|
153
|
+
"解決": "resolve fix",
|
|
154
|
+
"解决": "resolve fix",
|
|
155
|
+
"限制": "limit limits constraint constraints ceiling",
|
|
156
|
+
"重複": "duplicate repeated",
|
|
157
|
+
"重复": "duplicate repeated",
|
|
158
|
+
"順序": "order ordering",
|
|
159
|
+
"範圍": "scope range",
|
|
160
|
+
"范围": "scope range",
|
|
161
|
+
# project-specific vocabulary
|
|
162
|
+
"卡片": "card cards",
|
|
163
|
+
"掃描": "scan scanner",
|
|
164
|
+
"扫描": "scan scanner",
|
|
165
|
+
"辨識": "recognition identify OCR",
|
|
166
|
+
"识别": "recognition identify OCR",
|
|
167
|
+
"圖像": "image",
|
|
168
|
+
"图像": "image",
|
|
169
|
+
"裁切": "crop",
|
|
170
|
+
"權益": "equity",
|
|
171
|
+
"帳戶": "account",
|
|
172
|
+
"账户": "account",
|
|
173
|
+
"槓桿": "leverage",
|
|
174
|
+
"杠杆": "leverage",
|
|
175
|
+
"部位": "position sizing",
|
|
176
|
+
"策略": "strategy",
|
|
177
|
+
"交易": "trade trading",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def expand_cjk_query(query: str) -> str:
|
|
182
|
+
"""Append English equivalents for the Chinese terms present in the query.
|
|
183
|
+
|
|
184
|
+
Returns the query unchanged when it contains no known Chinese term, so
|
|
185
|
+
English queries keep their exact current behaviour.
|
|
186
|
+
"""
|
|
187
|
+
words = [
|
|
188
|
+
word
|
|
189
|
+
for chinese, english in GLOSSARY.items()
|
|
190
|
+
if chinese in query
|
|
191
|
+
for word in english.split()
|
|
192
|
+
]
|
|
193
|
+
if not words:
|
|
194
|
+
return query
|
|
195
|
+
# Deduplicate: FTS5 really does double-count a repeated term, and overlapping
|
|
196
|
+
# entries (資料/資料庫, 問題/錯誤 both yielding "bug") would silently weight it.
|
|
197
|
+
return f"{query} {' '.join(dict.fromkeys(words))}"
|