n3memorycore-free 1.3.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.
- n3memorycore/__init__.py +7 -0
- n3memorycore/core/__init__.py +0 -0
- n3memorycore/core/database.py +308 -0
- n3memorycore/core/processor.py +361 -0
- n3memorycore/n3mc_hook.py +57 -0
- n3memorycore/n3mc_stop_hook.py +59 -0
- n3memorycore/n3memory.py +998 -0
- n3memorycore/paths.py +46 -0
- n3memorycore_free-1.3.1.dist-info/METADATA +296 -0
- n3memorycore_free-1.3.1.dist-info/RECORD +15 -0
- n3memorycore_free-1.3.1.dist-info/WHEEL +5 -0
- n3memorycore_free-1.3.1.dist-info/entry_points.txt +4 -0
- n3memorycore_free-1.3.1.dist-info/licenses/LICENSE +190 -0
- n3memorycore_free-1.3.1.dist-info/licenses/NOTICE +4 -0
- n3memorycore_free-1.3.1.dist-info/top_level.txt +1 -0
n3memorycore/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import struct
|
|
3
|
+
import re
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, List, Tuple
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
_FTS_PUNCT_RE = re.compile(r'[()[\]{}<>,.!?;:\-—–…\'\"\`~@#$%^&*+=|/\\]')
|
|
10
|
+
_FTS_MAX_TERMS = 30
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def strip_fts_punctuation(text: str) -> str:
|
|
14
|
+
# Substitute with space, not empty: dashes between words ("Alpha-9") should
|
|
15
|
+
# split into ["Alpha", "9"], not concatenate into "Alpha9".
|
|
16
|
+
cleaned = _FTS_PUNCT_RE.sub(' ', text)
|
|
17
|
+
return re.sub(r'\s+', ' ', cleaned).strip()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _quote_fts_query(text: str) -> str:
|
|
21
|
+
stripped = strip_fts_punctuation(text)
|
|
22
|
+
terms = stripped.split()[:_FTS_MAX_TERMS]
|
|
23
|
+
return ' '.join(f'"{t}"' for t in terms)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serialize_vector(vec: list) -> bytes:
|
|
27
|
+
return struct.pack(f'{len(vec)}f', *vec)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def deserialize_vector(data: bytes) -> list:
|
|
31
|
+
n = len(data) // 4
|
|
32
|
+
return list(struct.unpack(f'{n}f', data))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_vec_extension(conn: sqlite3.Connection) -> None:
|
|
36
|
+
try:
|
|
37
|
+
conn.enable_load_extension(True)
|
|
38
|
+
try:
|
|
39
|
+
import sqlite_vec
|
|
40
|
+
sqlite_vec.load(conn)
|
|
41
|
+
except Exception as e1:
|
|
42
|
+
msg = str(e1).lower()
|
|
43
|
+
if 'already' in msg or 'duplicate' in msg:
|
|
44
|
+
return
|
|
45
|
+
# Fallback: direct load
|
|
46
|
+
try:
|
|
47
|
+
conn.load_extension('vec0')
|
|
48
|
+
except Exception as e2:
|
|
49
|
+
msg2 = str(e2).lower()
|
|
50
|
+
if 'already' in msg2 or 'duplicate' in msg2:
|
|
51
|
+
return
|
|
52
|
+
raise e2
|
|
53
|
+
except Exception as e:
|
|
54
|
+
msg = str(e).lower()
|
|
55
|
+
if 'already' in msg or 'duplicate' in msg:
|
|
56
|
+
return
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_connection(db_path: str) -> sqlite3.Connection:
|
|
61
|
+
conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
62
|
+
try:
|
|
63
|
+
_load_vec_extension(conn)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
conn.close()
|
|
66
|
+
raise sqlite3.DatabaseError(
|
|
67
|
+
f"Failed to load sqlite-vec extension: {e}. "
|
|
68
|
+
"Recovery: pip install sqlite-vec"
|
|
69
|
+
) from e
|
|
70
|
+
conn.execute("PRAGMA synchronous = FULL")
|
|
71
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
72
|
+
conn.row_factory = sqlite3.Row
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def init_db(conn: sqlite3.Connection) -> None:
|
|
77
|
+
conn.execute("""
|
|
78
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
content TEXT NOT NULL,
|
|
81
|
+
timestamp TEXT NOT NULL,
|
|
82
|
+
owner_id TEXT NOT NULL,
|
|
83
|
+
local_id TEXT,
|
|
84
|
+
agent_name TEXT,
|
|
85
|
+
session_id TEXT,
|
|
86
|
+
turn_id TEXT
|
|
87
|
+
)
|
|
88
|
+
""")
|
|
89
|
+
conn.execute("""
|
|
90
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
|
91
|
+
content,
|
|
92
|
+
tokenize='porter unicode61'
|
|
93
|
+
)
|
|
94
|
+
""")
|
|
95
|
+
conn.execute("""
|
|
96
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
|
|
97
|
+
embedding float[768]
|
|
98
|
+
)
|
|
99
|
+
""")
|
|
100
|
+
# Defer the turn_id index to migrate_schema so an old table without the
|
|
101
|
+
# column does not blow up here.
|
|
102
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(memories)").fetchall()}
|
|
103
|
+
if 'turn_id' in cols:
|
|
104
|
+
conn.execute(
|
|
105
|
+
"CREATE INDEX IF NOT EXISTS idx_memories_turn_id ON memories(turn_id)"
|
|
106
|
+
)
|
|
107
|
+
conn.commit()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def migrate_schema(conn: sqlite3.Connection) -> None:
|
|
111
|
+
cursor = conn.execute("PRAGMA table_info(memories)")
|
|
112
|
+
existing_cols = {row[1] for row in cursor.fetchall()}
|
|
113
|
+
|
|
114
|
+
for col, typedef in [
|
|
115
|
+
("local_id", "TEXT"),
|
|
116
|
+
("agent_name", "TEXT"),
|
|
117
|
+
("session_id", "TEXT"),
|
|
118
|
+
("turn_id", "TEXT"),
|
|
119
|
+
]:
|
|
120
|
+
if col not in existing_cols:
|
|
121
|
+
try:
|
|
122
|
+
conn.execute(f"ALTER TABLE memories ADD COLUMN {col} {typedef}")
|
|
123
|
+
conn.commit()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
cursor = conn.execute(
|
|
128
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_memories_turn_id'"
|
|
129
|
+
)
|
|
130
|
+
if not cursor.fetchone():
|
|
131
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_memories_turn_id ON memories(turn_id)")
|
|
132
|
+
conn.commit()
|
|
133
|
+
|
|
134
|
+
# Migrate FTS tokenizer if trigram
|
|
135
|
+
cursor = conn.execute(
|
|
136
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='memories_fts'"
|
|
137
|
+
)
|
|
138
|
+
row = cursor.fetchone()
|
|
139
|
+
if row and row[0] and 'trigram' in row[0].lower():
|
|
140
|
+
cursor2 = conn.execute("SELECT rowid, content FROM memories_fts")
|
|
141
|
+
existing_fts = cursor2.fetchall()
|
|
142
|
+
conn.execute("DROP TABLE IF EXISTS memories_fts")
|
|
143
|
+
conn.execute("""
|
|
144
|
+
CREATE VIRTUAL TABLE memories_fts USING fts5(
|
|
145
|
+
content,
|
|
146
|
+
tokenize='porter unicode61'
|
|
147
|
+
)
|
|
148
|
+
""")
|
|
149
|
+
for r in existing_fts:
|
|
150
|
+
stripped = strip_fts_punctuation(r[1])
|
|
151
|
+
conn.execute(
|
|
152
|
+
"INSERT INTO memories_fts(rowid, content) VALUES (?, ?)",
|
|
153
|
+
(r[0], stripped)
|
|
154
|
+
)
|
|
155
|
+
conn.commit()
|
|
156
|
+
logger.info("Migrated FTS tokenizer from trigram to porter unicode61")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def insert_memory(
|
|
160
|
+
conn: sqlite3.Connection,
|
|
161
|
+
id: str,
|
|
162
|
+
content: str,
|
|
163
|
+
timestamp: str,
|
|
164
|
+
owner_id: str,
|
|
165
|
+
embedding: Optional[list] = None,
|
|
166
|
+
local_id: Optional[str] = None,
|
|
167
|
+
agent_name: Optional[str] = None,
|
|
168
|
+
session_id: Optional[str] = None,
|
|
169
|
+
turn_id: Optional[str] = None,
|
|
170
|
+
) -> int:
|
|
171
|
+
conn.execute(
|
|
172
|
+
"""INSERT INTO memories
|
|
173
|
+
(id, content, timestamp, owner_id, local_id, agent_name, session_id, turn_id)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
175
|
+
(id, content, timestamp, owner_id, local_id, agent_name, session_id, turn_id),
|
|
176
|
+
)
|
|
177
|
+
rowid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
178
|
+
stripped = strip_fts_punctuation(content)
|
|
179
|
+
conn.execute(
|
|
180
|
+
"INSERT INTO memories_fts(rowid, content) VALUES (?, ?)", (rowid, stripped)
|
|
181
|
+
)
|
|
182
|
+
if embedding is not None:
|
|
183
|
+
conn.execute(
|
|
184
|
+
"INSERT INTO memories_vec(rowid, embedding) VALUES (?, ?)",
|
|
185
|
+
(rowid, serialize_vector(embedding)),
|
|
186
|
+
)
|
|
187
|
+
conn.commit()
|
|
188
|
+
return rowid
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def search_vector(
|
|
192
|
+
conn: sqlite3.Connection, embedding: list, k: int = 50
|
|
193
|
+
) -> List[sqlite3.Row]:
|
|
194
|
+
vec_bytes = serialize_vector(embedding)
|
|
195
|
+
try:
|
|
196
|
+
cursor = conn.execute(
|
|
197
|
+
"""SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
|
|
198
|
+
m.agent_name, m.session_id, m.turn_id, mv.distance, m.rowid
|
|
199
|
+
FROM memories_vec mv
|
|
200
|
+
JOIN memories m ON mv.rowid = m.rowid
|
|
201
|
+
WHERE mv.embedding MATCH ?
|
|
202
|
+
AND k = ?
|
|
203
|
+
ORDER BY mv.distance""",
|
|
204
|
+
(vec_bytes, k),
|
|
205
|
+
)
|
|
206
|
+
return cursor.fetchall()
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.warning(f"Vector search failed: {e}")
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def search_fts(
|
|
213
|
+
conn: sqlite3.Connection, query: str, limit: int = 50
|
|
214
|
+
) -> List[sqlite3.Row]:
|
|
215
|
+
if len(query.strip()) < 2:
|
|
216
|
+
return []
|
|
217
|
+
fts_query = _quote_fts_query(query)
|
|
218
|
+
if not fts_query.strip():
|
|
219
|
+
return []
|
|
220
|
+
try:
|
|
221
|
+
cursor = conn.execute(
|
|
222
|
+
"""SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
|
|
223
|
+
m.agent_name, m.session_id, m.turn_id,
|
|
224
|
+
bm25(memories_fts) AS bm25_score, m.rowid
|
|
225
|
+
FROM memories_fts
|
|
226
|
+
JOIN memories m ON memories_fts.rowid = m.rowid
|
|
227
|
+
WHERE memories_fts MATCH ?
|
|
228
|
+
ORDER BY bm25_score
|
|
229
|
+
LIMIT ?""",
|
|
230
|
+
(fts_query, limit),
|
|
231
|
+
)
|
|
232
|
+
return cursor.fetchall()
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.warning(f"FTS search failed: {e}")
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def get_all_memories(conn: sqlite3.Connection) -> List[sqlite3.Row]:
|
|
239
|
+
cursor = conn.execute(
|
|
240
|
+
"""SELECT id, content, timestamp, owner_id, local_id, agent_name,
|
|
241
|
+
session_id, turn_id, rowid
|
|
242
|
+
FROM memories
|
|
243
|
+
ORDER BY rowid"""
|
|
244
|
+
)
|
|
245
|
+
return cursor.fetchall()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def delete_memory(conn: sqlite3.Connection, memory_id: str) -> bool:
|
|
249
|
+
row = conn.execute(
|
|
250
|
+
"SELECT rowid FROM memories WHERE id = ?", (memory_id,)
|
|
251
|
+
).fetchone()
|
|
252
|
+
if not row:
|
|
253
|
+
return False
|
|
254
|
+
rowid = row[0]
|
|
255
|
+
try:
|
|
256
|
+
conn.execute("DELETE FROM memories_fts WHERE rowid = ?", (rowid,))
|
|
257
|
+
conn.execute("DELETE FROM memories_vec WHERE rowid = ?", (rowid,))
|
|
258
|
+
conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
|
|
259
|
+
conn.commit()
|
|
260
|
+
return True
|
|
261
|
+
except Exception:
|
|
262
|
+
conn.rollback()
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def count_memories(conn: sqlite3.Connection) -> int:
|
|
267
|
+
return conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def check_exact_duplicate(conn: sqlite3.Connection, content: str) -> bool:
|
|
271
|
+
row = conn.execute(
|
|
272
|
+
"SELECT 1 FROM memories WHERE content = ? LIMIT 1", (content,)
|
|
273
|
+
).fetchone()
|
|
274
|
+
return row is not None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def find_unindexed_memories(
|
|
278
|
+
conn: sqlite3.Connection, limit: int = 200, offset: int = 0
|
|
279
|
+
) -> List[sqlite3.Row]:
|
|
280
|
+
cursor = conn.execute(
|
|
281
|
+
"""SELECT m.id, m.content, m.timestamp, m.owner_id, m.local_id,
|
|
282
|
+
m.agent_name, m.session_id, m.turn_id, m.rowid,
|
|
283
|
+
mv.rowid AS vec_rowid,
|
|
284
|
+
mf.rowid AS fts_rowid
|
|
285
|
+
FROM memories m
|
|
286
|
+
LEFT JOIN memories_vec mv ON m.rowid = mv.rowid
|
|
287
|
+
LEFT JOIN memories_fts mf ON m.rowid = mf.rowid
|
|
288
|
+
WHERE mv.rowid IS NULL OR mf.rowid IS NULL
|
|
289
|
+
LIMIT ? OFFSET ?""",
|
|
290
|
+
(limit, offset),
|
|
291
|
+
)
|
|
292
|
+
return cursor.fetchall()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_memories_by_turn_id(
|
|
296
|
+
conn: sqlite3.Connection, turn_id: str
|
|
297
|
+
) -> List[sqlite3.Row]:
|
|
298
|
+
cursor = conn.execute(
|
|
299
|
+
"""SELECT id, content, timestamp, owner_id, local_id, agent_name,
|
|
300
|
+
session_id, turn_id, rowid
|
|
301
|
+
FROM memories
|
|
302
|
+
WHERE turn_id = ?
|
|
303
|
+
ORDER BY
|
|
304
|
+
CASE WHEN content LIKE '[user%' THEN 0 ELSE 1 END,
|
|
305
|
+
rowid""",
|
|
306
|
+
(turn_id,),
|
|
307
|
+
)
|
|
308
|
+
return cursor.fetchall()
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import math
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .database import (
|
|
9
|
+
get_connection,
|
|
10
|
+
init_db,
|
|
11
|
+
insert_memory,
|
|
12
|
+
search_vector,
|
|
13
|
+
search_fts,
|
|
14
|
+
get_all_memories,
|
|
15
|
+
delete_memory,
|
|
16
|
+
count_memories,
|
|
17
|
+
check_exact_duplicate,
|
|
18
|
+
find_unindexed_memories,
|
|
19
|
+
serialize_vector,
|
|
20
|
+
get_memories_by_turn_id,
|
|
21
|
+
strip_fts_punctuation,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_CODE_BLOCK_RE = re.compile(r'```[\s\S]*?```')
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Lone-surrogate sanitization (Windows / cp932 data-loss guard)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Subprocess stdin pipes on Windows can deliver UTF-8 bytes that python's
|
|
32
|
+
# decoder maps to lone UTF-16 surrogate halves (U+D800..U+DFFF). Such strings
|
|
33
|
+
# round-trip through json.loads but blow up at sqlite3.execute() with
|
|
34
|
+
# UnicodeEncodeError, silently dropping the entire write — a direct violation
|
|
35
|
+
# of the complete-preservation contract. We strip these halves before any
|
|
36
|
+
# value is bound to SQLite.
|
|
37
|
+
_LONE_SURROGATE_RE = re.compile(r'[\ud800-\udfff]')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sanitize_surrogates(text):
|
|
41
|
+
"""Strip lone UTF-16 surrogate halves from a string (or pass-through).
|
|
42
|
+
|
|
43
|
+
Recursively cleans dicts / lists too so audit-log JSON payloads with
|
|
44
|
+
surrogates buried inside multimodal content do not break json.dumps.
|
|
45
|
+
"""
|
|
46
|
+
if text is None:
|
|
47
|
+
return text
|
|
48
|
+
if isinstance(text, str):
|
|
49
|
+
return _LONE_SURROGATE_RE.sub('', text)
|
|
50
|
+
if isinstance(text, list):
|
|
51
|
+
return [sanitize_surrogates(x) for x in text]
|
|
52
|
+
if isinstance(text, dict):
|
|
53
|
+
return {k: sanitize_surrogates(v) for k, v in text.items()}
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
DEFAULT_EMBED_MODEL = 'intfloat/multilingual-e5-base'
|
|
57
|
+
|
|
58
|
+
_model = None
|
|
59
|
+
_model_name = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_model(name: Optional[str] = None):
|
|
63
|
+
"""Lazy-load and cache the embedding model.
|
|
64
|
+
|
|
65
|
+
On the first call (or when `name` differs from the cached model),
|
|
66
|
+
the model is selected from (in priority order):
|
|
67
|
+
1. `name` argument
|
|
68
|
+
2. `$N3MC_EMBED_MODEL` environment variable
|
|
69
|
+
3. `DEFAULT_EMBED_MODEL` (multilingual baseline)
|
|
70
|
+
|
|
71
|
+
Subsequent calls with `name=None` return the cached model without
|
|
72
|
+
re-resolving — so the model loaded by the server lifespan (with
|
|
73
|
+
`embed_model` from config.json) wins, and `embed_passage` /
|
|
74
|
+
`embed_query` reuse it transparently.
|
|
75
|
+
|
|
76
|
+
Callers that want to switch to a language-specialised model should
|
|
77
|
+
set `embed_model` in `config.json` and run `--repair` once after
|
|
78
|
+
the first save under the new model (see spec §5 vector re-index
|
|
79
|
+
migration).
|
|
80
|
+
"""
|
|
81
|
+
global _model, _model_name
|
|
82
|
+
if name is None and _model is not None:
|
|
83
|
+
return _model
|
|
84
|
+
target = name or os.environ.get('N3MC_EMBED_MODEL') or DEFAULT_EMBED_MODEL
|
|
85
|
+
if _model is None or _model_name != target:
|
|
86
|
+
from sentence_transformers import SentenceTransformer
|
|
87
|
+
_model = SentenceTransformer(target)
|
|
88
|
+
_model_name = target
|
|
89
|
+
return _model
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def embed_passage(text: str) -> list:
|
|
93
|
+
model = get_model()
|
|
94
|
+
prefixed = "passage: " + text
|
|
95
|
+
vec = model.encode(prefixed, normalize_embeddings=True)
|
|
96
|
+
return vec.tolist()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def embed_query(text: str) -> list:
|
|
100
|
+
model = get_model()
|
|
101
|
+
prefixed = "query: " + text
|
|
102
|
+
vec = model.encode(prefixed, normalize_embeddings=True)
|
|
103
|
+
return vec.tolist()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def purify_text(text: str) -> str:
|
|
107
|
+
"""Replace closed fenced code blocks with [code omitted] AND strip lone
|
|
108
|
+
surrogates as a defense-in-depth layer (spec §5).
|
|
109
|
+
|
|
110
|
+
Inline backtick spans and unclosed fences are preserved verbatim.
|
|
111
|
+
"""
|
|
112
|
+
if not text:
|
|
113
|
+
return text
|
|
114
|
+
cleaned = sanitize_surrogates(text)
|
|
115
|
+
return _CODE_BLOCK_RE.sub('[code omitted]', cleaned)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def chunk_text(text: str, max_chars: int = 400, overlap: int = 40) -> list:
|
|
119
|
+
if not text or not text.strip():
|
|
120
|
+
return []
|
|
121
|
+
if len(text) <= max_chars:
|
|
122
|
+
return [text]
|
|
123
|
+
|
|
124
|
+
# Paragraph split
|
|
125
|
+
paragraphs = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
|
|
126
|
+
if len(paragraphs) > 1:
|
|
127
|
+
chunks = _merge_chunks(paragraphs, max_chars, overlap, sep='\n\n')
|
|
128
|
+
if chunks:
|
|
129
|
+
return chunks
|
|
130
|
+
|
|
131
|
+
# Sentence split
|
|
132
|
+
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
133
|
+
if len(sentences) > 1:
|
|
134
|
+
chunks = _merge_chunks(sentences, max_chars, overlap, sep=' ')
|
|
135
|
+
if chunks:
|
|
136
|
+
return chunks
|
|
137
|
+
|
|
138
|
+
# Hard window fallback
|
|
139
|
+
return _hard_window(text, max_chars, overlap)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _merge_chunks(parts: list, max_chars: int, overlap: int, sep: str) -> list:
|
|
143
|
+
chunks = []
|
|
144
|
+
current = ""
|
|
145
|
+
for part in parts:
|
|
146
|
+
if len(part) > max_chars:
|
|
147
|
+
if current:
|
|
148
|
+
chunks.append(current)
|
|
149
|
+
current = ""
|
|
150
|
+
sub = _hard_window(part, max_chars, overlap)
|
|
151
|
+
chunks.extend(sub)
|
|
152
|
+
elif current and len(current) + len(sep) + len(part) > max_chars:
|
|
153
|
+
chunks.append(current)
|
|
154
|
+
# Overlap: keep tail of previous chunk
|
|
155
|
+
tail = current[-overlap:] if overlap else ""
|
|
156
|
+
current = (tail + sep + part) if tail else part
|
|
157
|
+
else:
|
|
158
|
+
current = (current + sep + part) if current else part
|
|
159
|
+
if current:
|
|
160
|
+
chunks.append(current)
|
|
161
|
+
return chunks
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _hard_window(text: str, max_chars: int, overlap: int) -> list:
|
|
165
|
+
chunks = []
|
|
166
|
+
start = 0
|
|
167
|
+
length = len(text)
|
|
168
|
+
while start < length:
|
|
169
|
+
end = min(start + max_chars, length)
|
|
170
|
+
chunks.append(text[start:end])
|
|
171
|
+
if end >= length:
|
|
172
|
+
break
|
|
173
|
+
start = end - overlap
|
|
174
|
+
if start <= 0:
|
|
175
|
+
start = end
|
|
176
|
+
return chunks
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def add_chunk_prefixes(chunks: list, role: str) -> list:
|
|
180
|
+
if not chunks:
|
|
181
|
+
return []
|
|
182
|
+
n = len(chunks)
|
|
183
|
+
if n == 1:
|
|
184
|
+
return [f"[{role}] {chunks[0]}"]
|
|
185
|
+
return [f"[{role} {i + 1}/{n}] {chunks[i]}" for i in range(n)]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cosine_sim_from_l2(l2_distance: float) -> float:
|
|
189
|
+
return max(0.0, 1.0 - (l2_distance ** 2) / 2.0)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def time_decay(timestamp_str: str, half_life_days: float) -> float:
|
|
193
|
+
try:
|
|
194
|
+
ts = datetime.fromisoformat(timestamp_str)
|
|
195
|
+
if ts.tzinfo is None:
|
|
196
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
197
|
+
now = datetime.now(timezone.utc)
|
|
198
|
+
days_elapsed = (now - ts).total_seconds() / 86400.0
|
|
199
|
+
return 2.0 ** (-days_elapsed / half_life_days)
|
|
200
|
+
except Exception:
|
|
201
|
+
return 1.0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def keyword_relevance(bm25_score: float, max_abs_bm25: float, bm25_min_threshold: float) -> float:
|
|
205
|
+
abs_score = abs(bm25_score)
|
|
206
|
+
if abs_score < bm25_min_threshold:
|
|
207
|
+
return 0.0
|
|
208
|
+
return abs_score / max(1.0, max_abs_bm25)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def hybrid_search(
|
|
212
|
+
db_path: str,
|
|
213
|
+
query: str,
|
|
214
|
+
config: dict,
|
|
215
|
+
current_session_id: Optional[str] = None,
|
|
216
|
+
) -> dict:
|
|
217
|
+
half_life = config.get('half_life_days', 90)
|
|
218
|
+
bm25_min = config.get('bm25_min_threshold', 0.1)
|
|
219
|
+
limit = config.get('search_result_limit', 20)
|
|
220
|
+
min_score = config.get('min_score', 0.2)
|
|
221
|
+
max_query = config.get('search_query_max_chars', 2000)
|
|
222
|
+
|
|
223
|
+
query = query[:max_query]
|
|
224
|
+
|
|
225
|
+
conn = get_connection(db_path)
|
|
226
|
+
try:
|
|
227
|
+
# Vector search
|
|
228
|
+
vec_results = {}
|
|
229
|
+
if query.strip():
|
|
230
|
+
try:
|
|
231
|
+
q_vec = embed_query(query)
|
|
232
|
+
rows = search_vector(conn, q_vec, k=limit * 3)
|
|
233
|
+
for row in rows:
|
|
234
|
+
rid = row['id']
|
|
235
|
+
dist = row['distance']
|
|
236
|
+
cs = cosine_sim_from_l2(dist)
|
|
237
|
+
vec_results[rid] = {
|
|
238
|
+
'id': rid,
|
|
239
|
+
'content': row['content'],
|
|
240
|
+
'timestamp': row['timestamp'],
|
|
241
|
+
'session_id': row['session_id'],
|
|
242
|
+
'turn_id': row['turn_id'],
|
|
243
|
+
'agent_name': row['agent_name'],
|
|
244
|
+
'cos_sim': cs,
|
|
245
|
+
'bm25_score': None,
|
|
246
|
+
}
|
|
247
|
+
except Exception as e:
|
|
248
|
+
logger.warning(f"Vector search error: {e}")
|
|
249
|
+
|
|
250
|
+
# FTS search
|
|
251
|
+
fts_results = {}
|
|
252
|
+
if query.strip():
|
|
253
|
+
try:
|
|
254
|
+
rows = search_fts(conn, query, limit=limit * 3)
|
|
255
|
+
max_abs = max((abs(r['bm25_score']) for r in rows), default=0.0)
|
|
256
|
+
for row in rows:
|
|
257
|
+
rid = row['id']
|
|
258
|
+
fts_results[rid] = {
|
|
259
|
+
'id': rid,
|
|
260
|
+
'content': row['content'],
|
|
261
|
+
'timestamp': row['timestamp'],
|
|
262
|
+
'session_id': row['session_id'],
|
|
263
|
+
'turn_id': row['turn_id'],
|
|
264
|
+
'agent_name': row['agent_name'],
|
|
265
|
+
'bm25_score': row['bm25_score'],
|
|
266
|
+
'max_abs_bm25': max_abs,
|
|
267
|
+
'cos_sim': None,
|
|
268
|
+
}
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.warning(f"FTS search error: {e}")
|
|
271
|
+
|
|
272
|
+
# Combine
|
|
273
|
+
all_ids = set(vec_results.keys()) | set(fts_results.keys())
|
|
274
|
+
scored = []
|
|
275
|
+
fts_max_abs = 0.0
|
|
276
|
+
if fts_results:
|
|
277
|
+
fts_max_abs = max(abs(v['bm25_score']) for v in fts_results.values())
|
|
278
|
+
|
|
279
|
+
for rid in all_ids:
|
|
280
|
+
vec_r = vec_results.get(rid)
|
|
281
|
+
fts_r = fts_results.get(rid)
|
|
282
|
+
|
|
283
|
+
meta = vec_r or fts_r
|
|
284
|
+
cs = vec_r['cos_sim'] if vec_r else 0.0
|
|
285
|
+
bm25 = fts_r['bm25_score'] if fts_r else 0.0
|
|
286
|
+
kr = keyword_relevance(bm25, fts_max_abs, bm25_min)
|
|
287
|
+
|
|
288
|
+
ts = meta['timestamp']
|
|
289
|
+
decay = time_decay(ts, half_life)
|
|
290
|
+
|
|
291
|
+
sess = meta.get('session_id')
|
|
292
|
+
b_session = 1.0 if (current_session_id and sess == current_session_id) else 0.6
|
|
293
|
+
|
|
294
|
+
score = (cs * 0.7 + kr * 0.3) * decay * b_session
|
|
295
|
+
|
|
296
|
+
scored.append({
|
|
297
|
+
'id': rid,
|
|
298
|
+
'content': meta['content'],
|
|
299
|
+
'timestamp': ts,
|
|
300
|
+
'session_id': sess,
|
|
301
|
+
'turn_id': meta.get('turn_id'),
|
|
302
|
+
'agent_name': meta.get('agent_name'),
|
|
303
|
+
'score': score,
|
|
304
|
+
'cos_sim': cs,
|
|
305
|
+
'keyword_relevance': kr,
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
scored.sort(key=lambda x: x['score'], reverse=True)
|
|
309
|
+
filtered = [r for r in scored if r['score'] >= min_score]
|
|
310
|
+
results = filtered[:limit]
|
|
311
|
+
|
|
312
|
+
# Q-A pair collection
|
|
313
|
+
pairs = {}
|
|
314
|
+
seen_turn_ids = set()
|
|
315
|
+
for r in results:
|
|
316
|
+
tid = r.get('turn_id')
|
|
317
|
+
if tid and tid not in seen_turn_ids:
|
|
318
|
+
seen_turn_ids.add(tid)
|
|
319
|
+
siblings = get_memories_by_turn_id(conn, tid)
|
|
320
|
+
if siblings:
|
|
321
|
+
pairs[tid] = [
|
|
322
|
+
{
|
|
323
|
+
'id': s['id'],
|
|
324
|
+
'content': s['content'],
|
|
325
|
+
'timestamp': s['timestamp'],
|
|
326
|
+
}
|
|
327
|
+
for s in siblings
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
return {'results': results, 'pairs': pairs}
|
|
331
|
+
finally:
|
|
332
|
+
conn.close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def render_memory_context(search_result: dict, query: str) -> str:
|
|
336
|
+
results = search_result.get('results', [])
|
|
337
|
+
pairs = search_result.get('pairs', {})
|
|
338
|
+
|
|
339
|
+
lines = [f"# Recalled Memory Context\n検索クエリ: {query}\n"]
|
|
340
|
+
|
|
341
|
+
if not results:
|
|
342
|
+
lines.append("_No relevant memories found._\n")
|
|
343
|
+
else:
|
|
344
|
+
lines.append("## Top matches (use these to answer the question)\n")
|
|
345
|
+
for i, r in enumerate(results, 1):
|
|
346
|
+
score = r['score']
|
|
347
|
+
content = r['content']
|
|
348
|
+
lines.append(f"### [{i}] score={score:.4f}")
|
|
349
|
+
lines.append(content)
|
|
350
|
+
lines.append("")
|
|
351
|
+
|
|
352
|
+
if pairs:
|
|
353
|
+
lines.append("\n## Previous matching Q-A exchanges (supplementary context)\n")
|
|
354
|
+
for tid, siblings in pairs.items():
|
|
355
|
+
lines.append(f"**Turn:** `{tid}`\n")
|
|
356
|
+
for s in siblings:
|
|
357
|
+
lines.append(s.get('content', '[content missing]'))
|
|
358
|
+
lines.append("")
|
|
359
|
+
lines.append("---")
|
|
360
|
+
|
|
361
|
+
return '\n'.join(lines)
|