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/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from .core import QueryResult, WaveField, WaveMind
2
+ from .encoders import (
3
+ FieldProjector,
4
+ HashingTextEncoder,
5
+ SentenceTransformerTextEncoder,
6
+ TextEncoder,
7
+ create_text_encoder,
8
+ )
9
+ from .storage import MemoryRecord, SQLiteMemoryStore
10
+
11
+ __all__ = [
12
+ "FieldProjector",
13
+ "HashingTextEncoder",
14
+ "MemoryRecord",
15
+ "QueryResult",
16
+ "SentenceTransformerTextEncoder",
17
+ "SQLiteMemoryStore",
18
+ "TextEncoder",
19
+ "WaveField",
20
+ "WaveMind",
21
+ "create_text_encoder",
22
+ ]
wavemind/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ raise SystemExit(main())
5
+
wavemind/api.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import Body, FastAPI, Query
9
+ from pydantic import BaseModel, Field
10
+
11
+ from .core import WaveMind
12
+ from .encoders import create_text_encoder
13
+ from .importers import import_path
14
+
15
+
16
+ logger = logging.getLogger("wavemind.api")
17
+
18
+
19
+ class RememberRequest(BaseModel):
20
+ text: str
21
+ namespace: str = "default"
22
+ tags: list[str] = Field(default_factory=list)
23
+ ttl_seconds: float | None = None
24
+ metadata: dict[str, Any] = Field(default_factory=dict)
25
+ priority: float = 1.0
26
+
27
+
28
+ class RememberResponse(BaseModel):
29
+ id: int
30
+
31
+
32
+ class QueryRequest(BaseModel):
33
+ text: str
34
+ namespace: str = "default"
35
+ top_k: int = 3
36
+ tags: list[str] = Field(default_factory=list)
37
+ min_score: float | None = None
38
+
39
+
40
+ class QueryResultResponse(BaseModel):
41
+ id: int
42
+ text: str
43
+ score: float
44
+ vector_score: float
45
+ field_score: float
46
+ namespace: str
47
+ tags: list[str]
48
+ metadata: dict[str, Any]
49
+
50
+
51
+ class QueryResponse(BaseModel):
52
+ results: list[QueryResultResponse]
53
+
54
+
55
+ class ForgetRequest(BaseModel):
56
+ id: int | None = None
57
+ text: str | None = None
58
+ namespace: str | None = None
59
+
60
+
61
+ class ForgetResponse(BaseModel):
62
+ deleted: int
63
+
64
+
65
+ class ImportRequest(BaseModel):
66
+ path: str
67
+ namespace: str = "default"
68
+ tags: list[str] = Field(default_factory=list)
69
+ max_chars: int = 1000
70
+ overlap: int = 120
71
+
72
+
73
+ class ImportResponse(BaseModel):
74
+ ids: list[int]
75
+
76
+
77
+ def build_default_mind() -> WaveMind:
78
+ db_path = (
79
+ Path(os.environ["WAVEMIND_DB"])
80
+ if "WAVEMIND_DB" in os.environ
81
+ else Path.cwd() / "wavemind.sqlite3"
82
+ )
83
+ index_kind = os.environ.get("WAVEMIND_INDEX", "numpy")
84
+ encoder_kind = os.environ.get("WAVEMIND_ENCODER", "hash").lower()
85
+ score_threshold = float(os.environ.get("WAVEMIND_SCORE_THRESHOLD", "0.0"))
86
+ encoder = create_text_encoder(
87
+ kind=encoder_kind,
88
+ vector_dim=int(os.environ.get("WAVEMIND_VECTOR_DIM", "384")),
89
+ model_name=os.environ.get(
90
+ "WAVEMIND_MODEL",
91
+ "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
92
+ ),
93
+ )
94
+ return WaveMind(
95
+ db_path=db_path,
96
+ encoder=encoder,
97
+ index_kind=index_kind,
98
+ score_threshold=score_threshold,
99
+ )
100
+
101
+
102
+ def create_app(mind: WaveMind | None = None) -> FastAPI:
103
+ logging.basicConfig(level=os.environ.get("WAVEMIND_LOG_LEVEL", "INFO"))
104
+ app = FastAPI(title="WaveMind", version="2.0.0")
105
+ app.state.mind = mind or build_default_mind()
106
+
107
+ @app.post("/remember", response_model=RememberResponse)
108
+ def remember(request: RememberRequest) -> RememberResponse:
109
+ id = app.state.mind.remember(
110
+ request.text,
111
+ namespace=request.namespace,
112
+ tags=request.tags,
113
+ ttl_seconds=request.ttl_seconds,
114
+ metadata=request.metadata,
115
+ priority=request.priority,
116
+ )
117
+ logger.info("remembered id=%s namespace=%s", id, request.namespace)
118
+ return RememberResponse(id=id)
119
+
120
+ @app.post("/query", response_model=QueryResponse)
121
+ def query(request: QueryRequest) -> QueryResponse:
122
+ results = app.state.mind.query(
123
+ request.text,
124
+ namespace=request.namespace,
125
+ top_k=request.top_k,
126
+ tags=request.tags,
127
+ min_score=request.min_score,
128
+ )
129
+ return QueryResponse(
130
+ results=[
131
+ QueryResultResponse(
132
+ id=result.id,
133
+ text=result.text,
134
+ score=result.score,
135
+ vector_score=result.vector_score,
136
+ field_score=result.field_score,
137
+ namespace=result.namespace,
138
+ tags=list(result.tags),
139
+ metadata=result.metadata,
140
+ )
141
+ for result in results
142
+ ]
143
+ )
144
+
145
+ @app.delete("/forget", response_model=ForgetResponse)
146
+ def forget(
147
+ request: ForgetRequest | None = Body(default=None),
148
+ id: int | None = Query(default=None),
149
+ text: str | None = Query(default=None),
150
+ namespace: str | None = Query(default=None),
151
+ ) -> ForgetResponse:
152
+ payload = request or ForgetRequest(id=id, text=text, namespace=namespace)
153
+ deleted = app.state.mind.forget(
154
+ id=payload.id,
155
+ text=payload.text,
156
+ namespace=payload.namespace,
157
+ )
158
+ logger.info("forgot deleted=%s namespace=%s", deleted, payload.namespace)
159
+ return ForgetResponse(deleted=deleted)
160
+
161
+ @app.get("/stats")
162
+ def stats(namespace: str | None = None):
163
+ return app.state.mind.stats(namespace=namespace)
164
+
165
+ @app.post("/import", response_model=ImportResponse)
166
+ def batch_import(request: ImportRequest) -> ImportResponse:
167
+ ids = import_path(
168
+ request.path,
169
+ app.state.mind,
170
+ namespace=request.namespace,
171
+ tags=request.tags,
172
+ max_chars=request.max_chars,
173
+ overlap=request.overlap,
174
+ )
175
+ return ImportResponse(ids=ids)
176
+
177
+ return app
wavemind/benchmark.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import statistics
4
+ import time
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class BenchmarkCase:
10
+ query: str
11
+ expected_text: str
12
+ namespace: str = "default"
13
+ tags: tuple[str, ...] = ()
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class BenchmarkReport:
18
+ precision_at_k: float
19
+ recall_at_k: float
20
+ avg_latency_ms: float
21
+ p95_latency_ms: float
22
+ capacity: int
23
+ cases: int
24
+ misses: list[str] = field(default_factory=list)
25
+
26
+
27
+ def run_benchmark(mind, cases: list[BenchmarkCase], k: int = 3) -> BenchmarkReport:
28
+ if not cases:
29
+ return BenchmarkReport(0.0, 0.0, 0.0, 0.0, mind.stats()["active_memories"], 0, [])
30
+
31
+ hits = 0
32
+ precision_terms = []
33
+ latencies = []
34
+ misses = []
35
+ for case in cases:
36
+ started = time.perf_counter()
37
+ results = mind.query(case.query, namespace=case.namespace, tags=case.tags, top_k=k)
38
+ latencies.append((time.perf_counter() - started) * 1000.0)
39
+ texts = [result.text for result in results]
40
+ if case.expected_text in texts:
41
+ hits += 1
42
+ precision_terms.append(1.0 / max(1, len(texts)))
43
+ else:
44
+ precision_terms.append(0.0)
45
+ misses.append(case.query)
46
+
47
+ sorted_latencies = sorted(latencies)
48
+ p95_index = min(len(sorted_latencies) - 1, int(len(sorted_latencies) * 0.95))
49
+ return BenchmarkReport(
50
+ precision_at_k=sum(precision_terms) / len(precision_terms),
51
+ recall_at_k=hits / len(cases),
52
+ avg_latency_ms=statistics.mean(latencies),
53
+ p95_latency_ms=sorted_latencies[p95_index],
54
+ capacity=mind.stats()["active_memories"],
55
+ cases=len(cases),
56
+ misses=misses,
57
+ )
58
+
59
+
60
+ def synthetic_cases(namespace: str = "bench") -> list[tuple[str, str]]:
61
+ return [
62
+ ("кошка", "кошка сидит на подоконнике"),
63
+ ("собака", "собака лает во дворе"),
64
+ ("market breakout", "market breakout above resistance"),
65
+ ("agent recall", "agent memory recall improves answers"),
66
+ ]
67
+
wavemind/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .benchmark import BenchmarkCase, run_benchmark, synthetic_cases
9
+ from .core import WaveMind
10
+ from .encoders import create_text_encoder
11
+ from .importers import import_path
12
+
13
+
14
+ def configure_stdio() -> None:
15
+ for stream in (sys.stdout, sys.stderr):
16
+ if hasattr(stream, "reconfigure"):
17
+ try:
18
+ stream.reconfigure(encoding="utf-8", errors="replace")
19
+ except (OSError, ValueError):
20
+ pass
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="wavemind",
26
+ description="WaveMind persistent dynamic memory engine",
27
+ )
28
+ parser.add_argument("--db", default=None, help="SQLite database path")
29
+ parser.add_argument("--index", default="numpy", choices=["numpy", "faiss", "annoy"])
30
+ parser.add_argument("--encoder", default="hash", choices=["hash", "sentence"])
31
+ parser.add_argument(
32
+ "--model",
33
+ default="sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
34
+ help="sentence-transformers model name when --encoder sentence is used",
35
+ )
36
+ parser.add_argument("--score-threshold", type=float, default=0.0)
37
+ parser.add_argument("--width", type=int, default=128)
38
+ parser.add_argument("--height", type=int, default=128)
39
+ parser.add_argument("--layers", type=int, default=6)
40
+
41
+ sub = parser.add_subparsers(dest="command")
42
+
43
+ remember = sub.add_parser("remember", help="Store a memory")
44
+ remember.add_argument("text")
45
+ remember.add_argument("--namespace", default="default")
46
+ remember.add_argument("--tag", action="append", default=[])
47
+ remember.add_argument("--ttl-seconds", type=float)
48
+ remember.add_argument("--priority", type=float, default=1.0)
49
+
50
+ query = sub.add_parser("query", help="Query memories")
51
+ query.add_argument("text")
52
+ query.add_argument("--namespace", default="default")
53
+ query.add_argument("--tag", action="append", default=[])
54
+ query.add_argument("--top-k", type=int, default=3)
55
+ query.add_argument("--min-score", type=float)
56
+ query.add_argument("--json", action="store_true")
57
+
58
+ forget = sub.add_parser("forget", help="Delete a memory")
59
+ forget.add_argument("--id", type=int)
60
+ forget.add_argument("--text")
61
+ forget.add_argument("--namespace")
62
+
63
+ stats = sub.add_parser("stats", help="Show memory stats")
64
+ stats.add_argument("--namespace")
65
+
66
+ imp = sub.add_parser("import", help="Import txt/pdf/json")
67
+ imp.add_argument("path")
68
+ imp.add_argument("--namespace", default="default")
69
+ imp.add_argument("--tag", action="append", default=[])
70
+ imp.add_argument("--max-chars", type=int, default=1000)
71
+ imp.add_argument("--overlap", type=int, default=120)
72
+
73
+ backup = sub.add_parser("backup", help="Backup SQLite database")
74
+ backup.add_argument("--out", required=True)
75
+
76
+ bench = sub.add_parser("benchmark", help="Run a synthetic recall benchmark")
77
+ bench.add_argument("--namespace", default="bench")
78
+ bench.add_argument("--top-k", type=int, default=1)
79
+
80
+ serve = sub.add_parser("serve", help="Run FastAPI daemon")
81
+ serve.add_argument("--host", default="0.0.0.0")
82
+ serve.add_argument("--port", type=int, default=8000)
83
+
84
+ sub.add_parser("test", help="Run pytest suite")
85
+ return parser
86
+
87
+
88
+ def make_mind(args) -> WaveMind:
89
+ encoder = create_text_encoder(kind=args.encoder, vector_dim=384, model_name=args.model)
90
+ db_path = Path(args.db) if args.db else Path.cwd() / "wavemind.sqlite3"
91
+ return WaveMind(
92
+ db_path=db_path,
93
+ width=args.width,
94
+ height=args.height,
95
+ layers=args.layers,
96
+ encoder=encoder,
97
+ index_kind=args.index,
98
+ score_threshold=args.score_threshold,
99
+ )
100
+
101
+
102
+ def print_stats(stats: dict) -> None:
103
+ for key, value in stats.items():
104
+ print(f"{key}: {value}")
105
+
106
+
107
+ def run_interactive(args) -> int:
108
+ mind = make_mind(args)
109
+ print("WaveMind v2 interactive CLI. Type help or exit.")
110
+ while True:
111
+ try:
112
+ line = input("> ").strip()
113
+ except (EOFError, KeyboardInterrupt):
114
+ print("\nexit")
115
+ return 0
116
+ if not line:
117
+ continue
118
+ if line in {"exit", "quit"}:
119
+ print("exit")
120
+ return 0
121
+ if line == "help":
122
+ print("remember <text> | query <text> | query5 <text> | stats | list | exit")
123
+ continue
124
+ command, _, rest = line.partition(" ")
125
+ if command == "remember" and rest:
126
+ id = mind.remember(rest)
127
+ print(f"remembered id={id}")
128
+ elif command == "query" and rest:
129
+ for result in mind.query(rest, top_k=3):
130
+ print(f"{result.score:.4f} id={result.id} {result.text}")
131
+ elif command == "query5" and rest:
132
+ for result in mind.query(rest, top_k=5):
133
+ print(f"{result.score:.4f} id={result.id} {result.text}")
134
+ elif command == "stats":
135
+ print_stats(mind.stats())
136
+ elif command == "list":
137
+ for record in mind.store.list(include_expired=False):
138
+ print(f"{record.id}: [{record.namespace}] {record.text}")
139
+ else:
140
+ print("unknown command")
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ configure_stdio()
145
+ parser = build_parser()
146
+ args = parser.parse_args(argv)
147
+ if args.command is None:
148
+ if argv is not None:
149
+ parser.print_help()
150
+ return 0
151
+ return run_interactive(args)
152
+
153
+ if args.command == "test":
154
+ import pytest
155
+
156
+ return int(pytest.main(["-q"]))
157
+
158
+ if args.command == "serve":
159
+ import uvicorn
160
+
161
+ from .api import create_app
162
+
163
+ uvicorn.run(create_app(mind=make_mind(args)), host=args.host, port=args.port)
164
+ return 0
165
+
166
+ mind = make_mind(args)
167
+ if args.command == "remember":
168
+ id = mind.remember(
169
+ args.text,
170
+ namespace=args.namespace,
171
+ tags=args.tag,
172
+ ttl_seconds=args.ttl_seconds,
173
+ priority=args.priority,
174
+ )
175
+ print(f"remembered id={id}")
176
+ return 0
177
+
178
+ if args.command == "query":
179
+ results = mind.query(
180
+ args.text,
181
+ namespace=args.namespace,
182
+ tags=args.tag,
183
+ top_k=args.top_k,
184
+ min_score=args.min_score,
185
+ )
186
+ if args.json:
187
+ print(json.dumps([result.__dict__ for result in results], ensure_ascii=False, indent=2))
188
+ else:
189
+ for result in results:
190
+ print(
191
+ f"{result.score:.4f} "
192
+ f"vector={result.vector_score:.4f} "
193
+ f"field={result.field_score:.4f} "
194
+ f"id={result.id} {result.text}"
195
+ )
196
+ return 0
197
+
198
+ if args.command == "forget":
199
+ print(f"deleted={mind.forget(id=args.id, text=args.text, namespace=args.namespace)}")
200
+ return 0
201
+
202
+ if args.command == "stats":
203
+ print_stats(mind.stats(namespace=args.namespace))
204
+ return 0
205
+
206
+ if args.command == "import":
207
+ ids = import_path(
208
+ args.path,
209
+ mind,
210
+ namespace=args.namespace,
211
+ tags=args.tag,
212
+ max_chars=args.max_chars,
213
+ overlap=args.overlap,
214
+ )
215
+ print(f"imported={len(ids)} ids={','.join(str(id) for id in ids)}")
216
+ return 0
217
+
218
+ if args.command == "backup":
219
+ path = mind.save(args.out)
220
+ print(f"backup: {path}")
221
+ return 0
222
+
223
+ if args.command == "benchmark":
224
+ existing = {
225
+ record.text
226
+ for record in mind.store.list(namespace=args.namespace, include_expired=False)
227
+ }
228
+ for query, text in synthetic_cases(namespace=args.namespace):
229
+ if text not in existing:
230
+ mind.remember(text, namespace=args.namespace)
231
+ existing.add(text)
232
+ cases = [
233
+ BenchmarkCase(query=query, expected_text=text, namespace=args.namespace)
234
+ for query, text in synthetic_cases(namespace=args.namespace)
235
+ ]
236
+ report = run_benchmark(mind, cases, k=args.top_k)
237
+ print(json.dumps(report.__dict__, ensure_ascii=False, indent=2))
238
+ return 0
239
+
240
+ parser.print_help()
241
+ return 2
242
+
243
+
244
+ if __name__ == "__main__":
245
+ raise SystemExit(main())