wavemind 2.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.
wavemind/core.py ADDED
@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Iterable
8
+
9
+ import numpy as np
10
+
11
+ from .encoders import FieldProjector, HashingTextEncoder, TextVectorEncoder
12
+ from .indexes import NumpyVectorIndex, create_vector_index
13
+ from .storage import MemoryRecord, SQLiteMemoryStore
14
+
15
+
16
+ class WaveField:
17
+ def __init__(
18
+ self,
19
+ width: int = 128,
20
+ height: int = 128,
21
+ layers: int = 6,
22
+ radius: int = 1,
23
+ decay: float = 0.965,
24
+ speed: float = 0.14,
25
+ nonlin: float = 0.04,
26
+ threshold_nl: float = 3e-4,
27
+ stable_threshold: float = 8e-5,
28
+ ):
29
+ self.W = width
30
+ self.H = height
31
+ self.L = layers
32
+ self.radius = radius
33
+ self.decay = decay
34
+ self.speed = speed
35
+ self.nonlin = nonlin
36
+ self.threshold_nl = threshold_nl
37
+ self.stable_threshold = stable_threshold
38
+ self.state = np.zeros((height, width, layers), dtype=np.float32)
39
+
40
+ def feed(self, pattern: np.ndarray, strength: float = 1.0) -> None:
41
+ h = min(self.H, pattern.shape[0])
42
+ w = min(self.W, pattern.shape[1])
43
+ noise = np.random.uniform(0.94, 1.06, (h, w, self.L)).astype(np.float32)
44
+ self.state[:h, :w] += pattern[:h, :w, np.newaxis] * noise * strength
45
+
46
+ def forget(self, pattern: np.ndarray, strength: float = 0.5) -> None:
47
+ h = min(self.H, pattern.shape[0])
48
+ w = min(self.W, pattern.shape[1])
49
+ self.state[:h, :w] -= pattern[:h, :w, np.newaxis] * strength
50
+ np.clip(self.state, -12.0, 12.0, out=self.state)
51
+
52
+ def evolve(self, steps: int = 1) -> None:
53
+ rad = self.radius
54
+ for _ in range(steps):
55
+ state = self.state
56
+ neighbours = np.zeros_like(state)
57
+ count = 0
58
+ for dy in range(-rad, rad + 1):
59
+ for dx in range(-rad, rad + 1):
60
+ if dx == 0 and dy == 0:
61
+ continue
62
+ neighbours += np.roll(np.roll(state, dy, axis=0), dx, axis=1)
63
+ count += 1
64
+ average = neighbours / count
65
+ diff = average - state
66
+ diff = np.where(np.abs(diff) < self.threshold_nl, 0.0, diff)
67
+ diff = diff * self.speed - self.nonlin * (state ** 2) * diff
68
+ self.state = (state + diff) * self.decay
69
+
70
+ def field_resonance(self, pattern: np.ndarray) -> float:
71
+ h = min(self.H, pattern.shape[0])
72
+ w = min(self.W, pattern.shape[1])
73
+ field_mag = np.sum(np.abs(self.state[:h, :w]), axis=2)
74
+ pat = pattern[:h, :w]
75
+ denom = (np.linalg.norm(field_mag) * np.linalg.norm(pat)) + 1e-9
76
+ return float(np.dot(field_mag.flatten(), pat.flatten()) / denom)
77
+
78
+ def energy(self) -> float:
79
+ return float(np.sum(self.state ** 2))
80
+
81
+ def detect_clusters(self) -> list[list[tuple[int, int]]]:
82
+ magnitude = np.sum(np.abs(self.state), axis=2)
83
+ active = magnitude > self.stable_threshold
84
+ visited = np.zeros((self.H, self.W), dtype=bool)
85
+ clusters = []
86
+ ys, xs = np.where(active)
87
+ for y0, x0 in zip(ys.tolist(), xs.tolist()):
88
+ if visited[y0, x0]:
89
+ continue
90
+ cluster = []
91
+ stack = [(x0, y0)]
92
+ visited[y0, x0] = True
93
+ while stack:
94
+ cx, cy = stack.pop()
95
+ cluster.append((cx, cy))
96
+ for dy in (-1, 0, 1):
97
+ for dx in (-1, 0, 1):
98
+ if dx == 0 and dy == 0:
99
+ continue
100
+ nx, ny = cx + dx, cy + dy
101
+ if 0 <= nx < self.W and 0 <= ny < self.H:
102
+ if not visited[ny, nx] and active[ny, nx]:
103
+ visited[ny, nx] = True
104
+ stack.append((nx, ny))
105
+ clusters.append(cluster)
106
+ return clusters
107
+
108
+ def reset(self) -> None:
109
+ self.state[:] = 0.0
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class QueryResult:
114
+ id: int
115
+ text: str
116
+ score: float
117
+ vector_score: float
118
+ field_score: float
119
+ namespace: str
120
+ tags: tuple[str, ...] = ()
121
+ metadata: dict[str, Any] = field(default_factory=dict)
122
+
123
+
124
+ class WaveMind:
125
+ def __init__(
126
+ self,
127
+ db_path: str | Path | None = None,
128
+ width: int = 128,
129
+ height: int = 128,
130
+ layers: int = 6,
131
+ encoder: TextVectorEncoder | None = None,
132
+ index_kind: str = "numpy",
133
+ score_threshold: float = 0.0,
134
+ evolve_on_feed: int = 6,
135
+ vector_weight: float = 0.94,
136
+ field_weight: float = 0.04,
137
+ priority_weight: float = 0.02,
138
+ lexical_weight: float = 0.20,
139
+ short_query_lexical_weight: float = 2.0,
140
+ rerank_k: int = 10,
141
+ field_disable_after: int = 1000,
142
+ persist_access_on_query: bool = False,
143
+ query_feedback_strength: float = 0.0,
144
+ ):
145
+ self.encoder = encoder or HashingTextEncoder(vector_dim=384)
146
+ self.projector = FieldProjector(width, height, self.encoder.vector_dim)
147
+ self.field = WaveField(width=width, height=height, layers=layers)
148
+ self.store = SQLiteMemoryStore(db_path)
149
+ self.index = create_vector_index(index_kind, self.encoder.vector_dim)
150
+ self.score_threshold = float(score_threshold)
151
+ self._evolve_n = int(evolve_on_feed)
152
+ self.vector_weight = float(vector_weight)
153
+ self.field_weight = float(field_weight)
154
+ self.priority_weight = float(priority_weight)
155
+ self.lexical_weight = float(lexical_weight)
156
+ self.short_query_lexical_weight = float(short_query_lexical_weight)
157
+ self.rerank_k = int(rerank_k)
158
+ self.field_disable_after = int(field_disable_after)
159
+ self.persist_access_on_query = bool(persist_access_on_query)
160
+ self.query_feedback_strength = float(query_feedback_strength)
161
+ self._records_by_id: dict[int, MemoryRecord] = {}
162
+ self._namespace_ids: dict[str, set[int]] = {}
163
+ self._token_ids: dict[str, set[int]] = {}
164
+ self._field_magnitude = np.zeros((height, width), dtype=np.float32)
165
+ self._field_magnitude_norm = 0.0
166
+ self.load()
167
+
168
+ def remember(
169
+ self,
170
+ text: str,
171
+ namespace: str = "default",
172
+ tags: Iterable[str] | None = None,
173
+ ttl_seconds: float | None = None,
174
+ metadata: dict[str, Any] | None = None,
175
+ priority: float = 1.0,
176
+ strength: float = 1.0,
177
+ ) -> int:
178
+ vector = self.encoder.encode_vector(text)
179
+ pattern = self.projector.to_pattern(vector)
180
+ expires_at = time.time() + ttl_seconds if ttl_seconds is not None else None
181
+ record = MemoryRecord(
182
+ text=text,
183
+ namespace=namespace,
184
+ tags=tuple(tags or ()),
185
+ metadata=metadata or {},
186
+ vector=vector,
187
+ pattern=pattern,
188
+ expires_at=expires_at,
189
+ priority=priority,
190
+ )
191
+ id = self.store.insert(record)
192
+ record.id = id
193
+ self._cache_record(record)
194
+ self.index.add(id, vector)
195
+ self.field.feed(pattern, strength=strength * priority)
196
+ self.field.evolve(self._evolve_n)
197
+ self._refresh_field_magnitude()
198
+ return id
199
+
200
+ def query(
201
+ self,
202
+ text: str,
203
+ namespace: str = "default",
204
+ top_k: int = 3,
205
+ tags: Iterable[str] | None = None,
206
+ min_score: float | None = None,
207
+ ) -> list[QueryResult]:
208
+ allowed_ids = self._allowed_ids(namespace=namespace, tags=tags)
209
+ if not allowed_ids:
210
+ return []
211
+
212
+ query_vector = self.encoder.encode_vector(text)
213
+
214
+ vector_top_k = max(top_k, self.rerank_k)
215
+ candidates = self.index.search(
216
+ query_vector,
217
+ top_k=vector_top_k,
218
+ allowed_ids=allowed_ids,
219
+ )
220
+
221
+ threshold = self.score_threshold if min_score is None else float(min_score)
222
+ query_tokens = self._tokens(text)
223
+ field_weight = self._effective_field_weight(len(allowed_ids))
224
+ lexical_weight = self._effective_lexical_weight(query_tokens)
225
+ candidate_scores = {candidate.id: candidate.score for candidate in candidates}
226
+ for id in self._lexical_candidate_ids(query_tokens, allowed_ids):
227
+ if id not in candidate_scores:
228
+ record = self._records_by_id[id]
229
+ candidate_scores[id] = float(np.dot(query_vector, record.vector))
230
+
231
+ results: list[QueryResult] = []
232
+ for candidate_id, vector_score in candidate_scores.items():
233
+ record = self._records_by_id[candidate_id]
234
+ field_score = self._field_resonance(record.pattern) if field_weight > 0 else 0.0
235
+ priority_score = min(1.0, max(0.0, record.priority / 10.0))
236
+ lexical_score = self._lexical_match(query_tokens, record.text)
237
+ score = (
238
+ self.vector_weight * vector_score
239
+ + field_weight * field_score
240
+ + self.priority_weight * priority_score
241
+ + lexical_weight * lexical_score
242
+ )
243
+ if score < threshold:
244
+ continue
245
+ results.append(
246
+ QueryResult(
247
+ id=int(record.id),
248
+ text=record.text,
249
+ score=float(score),
250
+ vector_score=float(vector_score),
251
+ field_score=float(field_score),
252
+ namespace=record.namespace,
253
+ tags=record.tags,
254
+ metadata=record.metadata,
255
+ )
256
+ )
257
+
258
+ results.sort(key=lambda item: item.score, reverse=True)
259
+ selected = results[:top_k]
260
+ for result in selected:
261
+ record = self._records_by_id[result.id]
262
+ record.access_count += 1
263
+ record.priority += 0.05
264
+ if self.persist_access_on_query:
265
+ self.store.touch(result.id)
266
+ if self.query_feedback_strength > 0:
267
+ self.field.feed(record.pattern, strength=self.query_feedback_strength)
268
+ if selected and self.query_feedback_strength > 0:
269
+ self.field.evolve(1)
270
+ self._refresh_field_magnitude()
271
+ return selected
272
+
273
+ def forget(
274
+ self,
275
+ id: int | None = None,
276
+ text: str | None = None,
277
+ namespace: str | None = None,
278
+ ) -> int:
279
+ records = self.store.delete(id=id, text=text, namespace=namespace)
280
+ for record in records:
281
+ if record.id is not None:
282
+ self.index.remove(record.id)
283
+ self._uncache_record(record.id)
284
+ self.field.forget(record.pattern, strength=0.7)
285
+ if records:
286
+ self.field.evolve(4)
287
+ self._refresh_field_magnitude()
288
+ return len(records)
289
+
290
+ def save(self, backup_path: str | Path | None = None) -> Path | None:
291
+ self.store.conn.commit()
292
+ if backup_path is not None:
293
+ return self.store.backup(backup_path)
294
+ return None
295
+
296
+ def load(self) -> None:
297
+ records = self.store.list(include_expired=False)
298
+ self._build_cache(records)
299
+ self.index.build(records)
300
+ self.field.reset()
301
+ for record in records:
302
+ self.field.feed(record.pattern, strength=max(0.1, record.priority))
303
+ if records:
304
+ self.field.evolve(self._evolve_n)
305
+ self._refresh_field_magnitude()
306
+
307
+ def purge_expired(self) -> int:
308
+ purged = self.store.purge_expired()
309
+ if purged:
310
+ self.load()
311
+ return purged
312
+
313
+ def consolidate(self, steps: int = 40) -> None:
314
+ self.field.evolve(steps)
315
+ self._refresh_field_magnitude()
316
+
317
+ def stats(self, namespace: str | None = None) -> dict[str, Any]:
318
+ active = self.store.list(namespace=namespace, include_expired=False)
319
+ all_records = self.store.list(namespace=namespace, include_expired=True)
320
+ expired = [record for record in all_records if record.is_expired]
321
+ clusters = self.field.detect_clusters()
322
+ return {
323
+ "active_memories": len(active),
324
+ "expired_memories": len(expired),
325
+ "total_memories": len(all_records),
326
+ "field_energy": round(self.field.energy(), 6),
327
+ "clusters": len(clusters),
328
+ "field_shape": f"{self.field.H}x{self.field.W}x{self.field.L}",
329
+ "index": getattr(self.index, "name", type(self.index).__name__),
330
+ "vector_dim": self.encoder.vector_dim,
331
+ }
332
+
333
+ @property
334
+ def memory(self) -> list[tuple[str, np.ndarray]]:
335
+ return [(record.text, record.pattern) for record in self._records_by_id.values()]
336
+
337
+ def _build_cache(self, records: Iterable[MemoryRecord]) -> None:
338
+ self._records_by_id.clear()
339
+ self._namespace_ids.clear()
340
+ self._token_ids.clear()
341
+ for record in records:
342
+ self._cache_record(record)
343
+
344
+ def _cache_record(self, record: MemoryRecord) -> None:
345
+ if record.id is None:
346
+ return
347
+ id = int(record.id)
348
+ self._records_by_id[id] = record
349
+ self._namespace_ids.setdefault(record.namespace, set()).add(id)
350
+ for token in self._tokens(record.text):
351
+ self._token_ids.setdefault(token, set()).add(id)
352
+
353
+ def _uncache_record(self, id: int) -> None:
354
+ record = self._records_by_id.pop(int(id), None)
355
+ if record is None:
356
+ return
357
+ ids = self._namespace_ids.get(record.namespace)
358
+ if ids is not None:
359
+ ids.discard(int(id))
360
+ if not ids:
361
+ self._namespace_ids.pop(record.namespace, None)
362
+ for token in self._tokens(record.text):
363
+ token_ids = self._token_ids.get(token)
364
+ if token_ids is None:
365
+ continue
366
+ token_ids.discard(int(id))
367
+ if not token_ids:
368
+ self._token_ids.pop(token, None)
369
+
370
+ def _allowed_ids(
371
+ self,
372
+ namespace: str,
373
+ tags: Iterable[str] | None = None,
374
+ ) -> set[int]:
375
+ ids = set(self._namespace_ids.get(namespace, set()))
376
+ required_tags = set(tags or ())
377
+ if not ids:
378
+ return set()
379
+ allowed = set()
380
+ for id in ids:
381
+ record = self._records_by_id[id]
382
+ if record.is_expired:
383
+ continue
384
+ if required_tags and not required_tags.issubset(set(record.tags)):
385
+ continue
386
+ allowed.add(id)
387
+ return allowed
388
+
389
+ def _refresh_field_magnitude(self) -> None:
390
+ self._field_magnitude = np.sum(np.abs(self.field.state), axis=2)
391
+ self._field_magnitude_norm = float(np.linalg.norm(self._field_magnitude))
392
+
393
+ def _field_resonance(self, pattern: np.ndarray) -> float:
394
+ denom = (self._field_magnitude_norm * float(np.linalg.norm(pattern))) + 1e-9
395
+ return float(np.dot(self._field_magnitude.ravel(), pattern.ravel()) / denom)
396
+
397
+ def _effective_field_weight(self, allowed_count: int) -> float:
398
+ if self.field_disable_after > 0 and allowed_count > self.field_disable_after:
399
+ return 0.0
400
+ return self.field_weight
401
+
402
+ def _effective_lexical_weight(self, query_tokens: tuple[str, ...]) -> float:
403
+ if 0 < len(query_tokens) <= 2:
404
+ return self.short_query_lexical_weight
405
+ return self.lexical_weight
406
+
407
+ def _tokens(self, text: str) -> tuple[str, ...]:
408
+ return tuple(
409
+ token.replace("ё", "е")
410
+ for token in re.findall(r"[\w]+", text.lower(), flags=re.UNICODE)
411
+ )
412
+
413
+ def _lexical_match(self, query_tokens: tuple[str, ...], text: str) -> float:
414
+ if not query_tokens:
415
+ return 0.0
416
+ text_tokens = set(self._tokens(text))
417
+ matched = sum(1 for token in query_tokens if token in text_tokens)
418
+ return matched / len(query_tokens)
419
+
420
+ def _lexical_candidate_ids(
421
+ self,
422
+ query_tokens: tuple[str, ...],
423
+ allowed_ids: set[int],
424
+ ) -> set[int]:
425
+ candidate_ids: set[int] = set()
426
+ for token in query_tokens:
427
+ candidate_ids.update(self._token_ids.get(token, set()))
428
+ return candidate_ids & allowed_ids
wavemind/encoders.py ADDED
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ import numpy as np
9
+
10
+
11
+ class TextVectorEncoder(Protocol):
12
+ vector_dim: int
13
+
14
+ def encode_vector(self, text: str) -> np.ndarray:
15
+ ...
16
+
17
+
18
+ def _l2_normalize(vector: np.ndarray) -> np.ndarray:
19
+ vector = np.asarray(vector, dtype=np.float32)
20
+ norm = float(np.linalg.norm(vector))
21
+ if norm <= 1e-12:
22
+ return vector.astype(np.float32)
23
+ return (vector / norm).astype(np.float32)
24
+
25
+
26
+ @dataclass
27
+ class HashingTextEncoder:
28
+ vector_dim: int = 384
29
+ token_weight: float = 4.0
30
+ char_ngram_weight: float = 0.10
31
+
32
+ def encode_vector(self, text: str) -> np.ndarray:
33
+ text = text.lower().strip()
34
+ vector = np.zeros(self.vector_dim, dtype=np.float32)
35
+ if not text:
36
+ return vector
37
+
38
+ tokens = re.findall(r"[\w]+", text, flags=re.UNICODE)
39
+ for token in tokens:
40
+ self._add_feature(vector, f"tok:{token}", self.token_weight)
41
+
42
+ compact = re.sub(r"\s+", " ", text)
43
+ for size in (2, 3):
44
+ for i in range(max(0, len(compact) - size + 1)):
45
+ self._add_feature(
46
+ vector,
47
+ f"ch{size}:{compact[i:i + size]}",
48
+ self.char_ngram_weight,
49
+ )
50
+
51
+ return _l2_normalize(vector)
52
+
53
+ def _add_feature(self, vector: np.ndarray, feature: str, weight: float) -> None:
54
+ digest = hashlib.blake2b(feature.encode("utf-8"), digest_size=16).digest()
55
+ bucket = int.from_bytes(digest[:8], "little") % self.vector_dim
56
+ sign = 1.0 if digest[8] & 1 else -1.0
57
+ vector[bucket] += sign * weight
58
+
59
+
60
+ class SentenceTransformerTextEncoder:
61
+ def __init__(
62
+ self,
63
+ model_name: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
64
+ model=None,
65
+ ):
66
+ if model is None:
67
+ try:
68
+ from sentence_transformers import SentenceTransformer
69
+ except ImportError as exc:
70
+ raise ImportError(
71
+ "Install sentence-transformers to use SentenceTransformerTextEncoder"
72
+ ) from exc
73
+ model = SentenceTransformer(model_name)
74
+
75
+ self.model_name = model_name
76
+ self.model = model
77
+ if hasattr(model, "get_embedding_dimension"):
78
+ self.vector_dim = int(model.get_embedding_dimension())
79
+ elif hasattr(model, "get_sentence_embedding_dimension"):
80
+ self.vector_dim = int(model.get_sentence_embedding_dimension())
81
+ else:
82
+ self.vector_dim = 768
83
+
84
+ def encode_vector(self, text: str) -> np.ndarray:
85
+ encoded = self.model.encode([text], normalize_embeddings=True)
86
+ vector = np.asarray(encoded[0], dtype=np.float32)
87
+ return _l2_normalize(vector)
88
+
89
+
90
+ class FieldProjector:
91
+ def __init__(self, width: int, height: int, vector_dim: int, seed: int = 1729):
92
+ self.width = int(width)
93
+ self.height = int(height)
94
+ self.vector_dim = int(vector_dim)
95
+ rng = np.random.default_rng(seed + self.width * 31 + self.height * 17 + self.vector_dim)
96
+ self._matrix = rng.normal(
97
+ loc=0.0,
98
+ scale=1.0 / max(1, self.vector_dim) ** 0.5,
99
+ size=(self.height * self.width, self.vector_dim),
100
+ ).astype(np.float32)
101
+
102
+ def to_pattern(self, vector: np.ndarray) -> np.ndarray:
103
+ vector = _l2_normalize(vector)
104
+ projected = self._matrix @ vector
105
+ projected = np.maximum(projected, 0.0)
106
+ if not np.any(projected):
107
+ projected = np.abs(self._matrix @ vector)
108
+ pattern = projected.reshape(self.height, self.width).astype(np.float32)
109
+ return _l2_normalize(pattern)
110
+
111
+
112
+ TextEncoder = HashingTextEncoder
113
+
114
+
115
+ def create_text_encoder(
116
+ kind: str = "hash",
117
+ vector_dim: int = 384,
118
+ model_name: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
119
+ ) -> TextVectorEncoder:
120
+ kind = (kind or "hash").lower()
121
+ if kind == "hash":
122
+ return HashingTextEncoder(vector_dim=vector_dim)
123
+ if kind in {"sentence", "sentence-transformers", "transformer"}:
124
+ return SentenceTransformerTextEncoder(model_name=model_name)
125
+ raise ValueError(
126
+ f"Unknown encoder kind: {kind}. Choose an explicit encoder: hash or sentence."
127
+ )
wavemind/importers.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+
9
+ def segment_text(text: str, max_chars: int = 1000, overlap: int = 120) -> list[str]:
10
+ text = text.replace("\r\n", "\n").strip()
11
+ if not text:
12
+ return []
13
+ paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
14
+ chunks: list[str] = []
15
+ for paragraph in paragraphs:
16
+ if len(paragraph) <= max_chars:
17
+ chunks.append(paragraph)
18
+ continue
19
+ start = 0
20
+ while start < len(paragraph):
21
+ end = min(len(paragraph), start + max_chars)
22
+ chunk = paragraph[start:end].strip()
23
+ if chunk:
24
+ chunks.append(chunk)
25
+ if end == len(paragraph):
26
+ break
27
+ start = max(0, end - overlap)
28
+ return chunks
29
+
30
+
31
+ def import_txt(
32
+ path: str | Path,
33
+ mind,
34
+ namespace: str = "default",
35
+ tags: Iterable[str] | None = None,
36
+ max_chars: int = 1000,
37
+ overlap: int = 120,
38
+ ) -> list[int]:
39
+ path = Path(path)
40
+ text = path.read_text(encoding="utf-8")
41
+ ids = []
42
+ for chunk in segment_text(text, max_chars=max_chars, overlap=overlap):
43
+ ids.append(
44
+ mind.remember(
45
+ chunk,
46
+ namespace=namespace,
47
+ tags=tags or (),
48
+ metadata={"source": str(path), "format": "txt"},
49
+ )
50
+ )
51
+ return ids
52
+
53
+
54
+ def import_json(
55
+ path: str | Path,
56
+ mind,
57
+ namespace: str = "default",
58
+ tags: Iterable[str] | None = None,
59
+ ) -> list[int]:
60
+ path = Path(path)
61
+ data = json.loads(path.read_text(encoding="utf-8"))
62
+ if isinstance(data, dict):
63
+ items = data.get("items", [data])
64
+ else:
65
+ items = data
66
+
67
+ ids = []
68
+ for item in items:
69
+ if isinstance(item, str):
70
+ text = item
71
+ item_tags = set(tags or ())
72
+ metadata = {"source": str(path), "format": "json"}
73
+ elif isinstance(item, dict):
74
+ text = str(item.get("text", "")).strip()
75
+ item_tags = set(tags or ()) | set(item.get("tags", []))
76
+ metadata = {
77
+ key: value
78
+ for key, value in item.items()
79
+ if key not in {"text", "tags"}
80
+ }
81
+ metadata.update({"source": str(path), "format": "json"})
82
+ else:
83
+ continue
84
+ if not text:
85
+ continue
86
+ ids.append(mind.remember(text, namespace=namespace, tags=sorted(item_tags), metadata=metadata))
87
+ return ids
88
+
89
+
90
+ def import_pdf(
91
+ path: str | Path,
92
+ mind,
93
+ namespace: str = "default",
94
+ tags: Iterable[str] | None = None,
95
+ max_chars: int = 1000,
96
+ overlap: int = 120,
97
+ ) -> list[int]:
98
+ try:
99
+ from pypdf import PdfReader
100
+ except ImportError as exc:
101
+ raise ImportError("Install pypdf to import PDF files") from exc
102
+
103
+ path = Path(path)
104
+ reader = PdfReader(str(path))
105
+ text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
106
+ ids = []
107
+ for chunk in segment_text(text, max_chars=max_chars, overlap=overlap):
108
+ ids.append(
109
+ mind.remember(
110
+ chunk,
111
+ namespace=namespace,
112
+ tags=tags or (),
113
+ metadata={"source": str(path), "format": "pdf"},
114
+ )
115
+ )
116
+ return ids
117
+
118
+
119
+ def import_path(
120
+ path: str | Path,
121
+ mind,
122
+ namespace: str = "default",
123
+ tags: Iterable[str] | None = None,
124
+ max_chars: int = 1000,
125
+ overlap: int = 120,
126
+ ) -> list[int]:
127
+ path = Path(path)
128
+ suffix = path.suffix.lower()
129
+ if suffix == ".txt":
130
+ return import_txt(path, mind, namespace=namespace, tags=tags, max_chars=max_chars, overlap=overlap)
131
+ if suffix == ".json":
132
+ return import_json(path, mind, namespace=namespace, tags=tags)
133
+ if suffix == ".pdf":
134
+ return import_pdf(path, mind, namespace=namespace, tags=tags, max_chars=max_chars, overlap=overlap)
135
+ raise ValueError(f"Unsupported import format: {suffix}")
136
+