swafra 0.1.0__tar.gz
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.
- swafra-0.1.0/PKG-INFO +47 -0
- swafra-0.1.0/README.md +19 -0
- swafra-0.1.0/pyproject.toml +3 -0
- swafra-0.1.0/setup.cfg +4 -0
- swafra-0.1.0/setup.py +32 -0
- swafra-0.1.0/swafra/__init__.py +3 -0
- swafra-0.1.0/swafra/engine.py +882 -0
- swafra-0.1.0/swafra.egg-info/PKG-INFO +47 -0
- swafra-0.1.0/swafra.egg-info/SOURCES.txt +11 -0
- swafra-0.1.0/swafra.egg-info/dependency_links.txt +1 -0
- swafra-0.1.0/swafra.egg-info/entry_points.txt +2 -0
- swafra-0.1.0/swafra.egg-info/requires.txt +4 -0
- swafra-0.1.0/swafra.egg-info/top_level.txt +1 -0
swafra-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swafra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory
|
|
5
|
+
Author: kunal12203
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,knowledge-graph,leiden,semantic-memory,claude
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: fastembed>=0.4.0
|
|
16
|
+
Requires-Dist: igraph>=0.11.0
|
|
17
|
+
Requires-Dist: leidenalg>=0.10.0
|
|
18
|
+
Requires-Dist: numpy>=1.26.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: keywords
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
# swafra
|
|
30
|
+
|
|
31
|
+
MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install swafra
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
swafra
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
swafra-0.1.0/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# swafra
|
|
2
|
+
|
|
3
|
+
MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install swafra
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
swafra
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
swafra-0.1.0/setup.cfg
ADDED
swafra-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="swafra",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
description="MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory",
|
|
7
|
+
long_description=open("README.md").read(),
|
|
8
|
+
long_description_content_type="text/markdown",
|
|
9
|
+
author="kunal12203",
|
|
10
|
+
license="MIT",
|
|
11
|
+
packages=find_packages(),
|
|
12
|
+
python_requires=">=3.10",
|
|
13
|
+
install_requires=[
|
|
14
|
+
"fastembed>=0.4.0",
|
|
15
|
+
"igraph>=0.11.0",
|
|
16
|
+
"leidenalg>=0.10.0",
|
|
17
|
+
"numpy>=1.26.0",
|
|
18
|
+
],
|
|
19
|
+
entry_points={
|
|
20
|
+
"console_scripts": [
|
|
21
|
+
"swafra=swafra.engine:main",
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
keywords=["mcp", "knowledge-graph", "leiden", "semantic-memory", "claude"],
|
|
25
|
+
classifiers=[
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Programming Language :: Python :: 3",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
],
|
|
32
|
+
)
|
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
"""scimap MCP engine — the Python subprocess that handles embeddings, chunking,
|
|
2
|
+
and knowledge graph operations.
|
|
3
|
+
|
|
4
|
+
v2: Improved retrieval with BM25 + vector hybrid, turn-level conversation chunking,
|
|
5
|
+
temporal indexing, and entity-aware search.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from collections import Counter, defaultdict
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s")
|
|
21
|
+
log = logging.getLogger("scimap.engine")
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Storage
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
_DATA_DIR = Path(os.getenv("SCIMAP_DATA_DIR", os.path.expanduser("~/.scimap")))
|
|
27
|
+
_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
_CHUNKS_FILE = _DATA_DIR / "chunks.json"
|
|
30
|
+
_EDGES_FILE = _DATA_DIR / "edges.json"
|
|
31
|
+
_SOURCES_FILE = _DATA_DIR / "sources.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_json(path: Path) -> list | dict:
|
|
35
|
+
if path.exists():
|
|
36
|
+
with open(path) as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_json(path: Path, data):
|
|
42
|
+
with open(path, "w") as f:
|
|
43
|
+
json.dump(data, f, default=str)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# BM25 (pure Python, no deps)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
_STOP_WORDS = frozenset({
|
|
50
|
+
"i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your",
|
|
51
|
+
"yours", "yourself", "yourselves", "he", "him", "his", "himself", "she", "her",
|
|
52
|
+
"hers", "herself", "it", "its", "itself", "they", "them", "their", "theirs",
|
|
53
|
+
"themselves", "what", "which", "who", "whom", "this", "that", "these", "those",
|
|
54
|
+
"am", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
|
|
55
|
+
"having", "do", "does", "did", "doing", "a", "an", "the", "and", "but", "if",
|
|
56
|
+
"or", "because", "as", "until", "while", "of", "at", "by", "for", "with",
|
|
57
|
+
"about", "against", "between", "through", "during", "before", "after", "above",
|
|
58
|
+
"below", "to", "from", "up", "down", "in", "out", "on", "off", "over", "under",
|
|
59
|
+
"again", "further", "then", "once", "here", "there", "when", "where", "why",
|
|
60
|
+
"how", "all", "both", "each", "few", "more", "most", "other", "some", "such",
|
|
61
|
+
"no", "nor", "not", "only", "own", "same", "so", "than", "too", "very", "s",
|
|
62
|
+
"t", "can", "will", "just", "don", "should", "now", "d", "ll", "m", "o", "re",
|
|
63
|
+
"ve", "y", "ain", "aren", "couldn", "didn", "doesn", "hadn", "hasn", "haven",
|
|
64
|
+
"isn", "ma", "mightn", "mustn", "needn", "shan", "shouldn", "wasn", "weren",
|
|
65
|
+
"won", "wouldn",
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
_WORD_RE = re.compile(r"[a-z0-9]+(?:'[a-z]+)?")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _stem(word: str) -> str:
|
|
72
|
+
"""Minimal suffix-stripping stemmer. Handles common English inflections."""
|
|
73
|
+
if len(word) <= 3:
|
|
74
|
+
return word
|
|
75
|
+
# Handle common suffixes (order matters — longest first)
|
|
76
|
+
suffixes = [
|
|
77
|
+
("ational", "ate"), ("tional", "tion"), ("encies", "ence"),
|
|
78
|
+
("ancies", "ance"), ("izers", "ize"), ("ously", "ous"),
|
|
79
|
+
("ively", "ive"), ("ments", "ment"), ("ities", ""),
|
|
80
|
+
("ness", ""), ("ings", ""), ("ment", ""), ("ence", ""),
|
|
81
|
+
("ance", ""), ("ible", ""), ("able", ""), ("tion", ""),
|
|
82
|
+
("ling", ""), ("ally", ""), ("ized", "ize"), ("ised", "ise"),
|
|
83
|
+
("ful", ""), ("ing", ""), ("ers", ""), ("ies", "y"),
|
|
84
|
+
("ess", ""), ("est", ""), ("ous", ""), ("ive", ""),
|
|
85
|
+
("ize", ""), ("ise", ""), ("ion", ""), ("ed", ""),
|
|
86
|
+
("er", ""), ("ly", ""), ("es", ""), ("'s", ""), ("s", ""),
|
|
87
|
+
]
|
|
88
|
+
for suffix, replacement in suffixes:
|
|
89
|
+
if word.endswith(suffix) and len(word) - len(suffix) >= 2:
|
|
90
|
+
return word[:-len(suffix)] + replacement
|
|
91
|
+
return word
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _tokenize(text: str) -> list[str]:
|
|
95
|
+
"""Tokenize, remove stop words, and stem."""
|
|
96
|
+
return [_stem(w) for w in _WORD_RE.findall(text.lower()) if w not in _STOP_WORDS and len(w) > 1]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _tokenize_raw(text: str) -> list[str]:
|
|
100
|
+
"""Tokenize without stemming (for entity matching)."""
|
|
101
|
+
return [w for w in _WORD_RE.findall(text.lower()) if w not in _STOP_WORDS and len(w) > 1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BM25Index:
|
|
105
|
+
"""In-memory BM25 index. Rebuilt per-question (since we reset between questions in bench)."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, k1: float = 1.5, b: float = 0.75):
|
|
108
|
+
self.k1 = k1
|
|
109
|
+
self.b = b
|
|
110
|
+
self.doc_freqs: Counter = Counter()
|
|
111
|
+
self.doc_lens: list[int] = []
|
|
112
|
+
self.doc_tokens: list[list[str]] = []
|
|
113
|
+
self.avg_dl: float = 0.0
|
|
114
|
+
self.n_docs: int = 0
|
|
115
|
+
|
|
116
|
+
def add(self, tokens: list[str]):
|
|
117
|
+
self.doc_tokens.append(tokens)
|
|
118
|
+
self.doc_lens.append(len(tokens))
|
|
119
|
+
self.n_docs += 1
|
|
120
|
+
seen = set(tokens)
|
|
121
|
+
for t in seen:
|
|
122
|
+
self.doc_freqs[t] += 1
|
|
123
|
+
self.avg_dl = sum(self.doc_lens) / max(1, self.n_docs)
|
|
124
|
+
|
|
125
|
+
def score(self, query_tokens: list[str], doc_idx: int) -> float:
|
|
126
|
+
doc_toks = self.doc_tokens[doc_idx]
|
|
127
|
+
dl = self.doc_lens[doc_idx]
|
|
128
|
+
tf_map = Counter(doc_toks)
|
|
129
|
+
score = 0.0
|
|
130
|
+
for qt in query_tokens:
|
|
131
|
+
if qt not in tf_map:
|
|
132
|
+
continue
|
|
133
|
+
tf = tf_map[qt]
|
|
134
|
+
df = self.doc_freqs.get(qt, 0)
|
|
135
|
+
idf = math.log((self.n_docs - df + 0.5) / (df + 0.5) + 1.0)
|
|
136
|
+
numerator = tf * (self.k1 + 1)
|
|
137
|
+
denominator = tf + self.k1 * (1 - self.b + self.b * dl / max(1, self.avg_dl))
|
|
138
|
+
score += idf * numerator / denominator
|
|
139
|
+
return score
|
|
140
|
+
|
|
141
|
+
def search(self, query: str, k: int = 10) -> list[tuple[int, float]]:
|
|
142
|
+
query_tokens = _tokenize(query)
|
|
143
|
+
scores = [(i, self.score(query_tokens, i)) for i in range(self.n_docs)]
|
|
144
|
+
scores.sort(key=lambda x: x[1], reverse=True)
|
|
145
|
+
return [(i, s) for i, s in scores[:k] if s > 0]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Embedding (vector similarity — hash-based fallback or fastembed)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
_EMBED_MODEL = None
|
|
152
|
+
_EMBED_DIM = 384
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_embedder():
|
|
156
|
+
global _EMBED_MODEL, _EMBED_DIM
|
|
157
|
+
if _EMBED_MODEL is not None:
|
|
158
|
+
return _EMBED_MODEL
|
|
159
|
+
try:
|
|
160
|
+
from fastembed import TextEmbedding
|
|
161
|
+
model_name = os.getenv("SCIMAP_EMBED_MODEL", "BAAI/bge-small-en-v1.5")
|
|
162
|
+
_EMBED_MODEL = TextEmbedding(model_name=model_name)
|
|
163
|
+
test = list(_EMBED_MODEL.embed(["test"]))[0]
|
|
164
|
+
_EMBED_DIM = len(test)
|
|
165
|
+
log.info("fastembed loaded: model=%s, dim=%d", model_name, _EMBED_DIM)
|
|
166
|
+
return _EMBED_MODEL
|
|
167
|
+
except Exception as e:
|
|
168
|
+
log.warning("fastembed unavailable (%s), using local fallback", e)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _local_vector(text: str) -> list[float]:
|
|
176
|
+
vec = [0.0] * _EMBED_DIM
|
|
177
|
+
toks = _TOKEN_RE.findall((text or "").lower())
|
|
178
|
+
if not toks:
|
|
179
|
+
vec[0] = 1.0
|
|
180
|
+
return vec
|
|
181
|
+
for tok in toks:
|
|
182
|
+
padded = f"#{tok}#"
|
|
183
|
+
ngrams = [padded[i:i+3] for i in range(max(1, len(padded) - 2))]
|
|
184
|
+
for feat in (tok, *ngrams):
|
|
185
|
+
h = hashlib.sha1(feat.encode()).digest()
|
|
186
|
+
bucket = int.from_bytes(h[:4], "big") % _EMBED_DIM
|
|
187
|
+
sign = 1.0 if (h[4] & 1) else -1.0
|
|
188
|
+
vec[bucket] += sign
|
|
189
|
+
norm = math.sqrt(sum(v * v for v in vec))
|
|
190
|
+
if norm == 0:
|
|
191
|
+
vec[0] = 1.0
|
|
192
|
+
return vec
|
|
193
|
+
return [v / norm for v in vec]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def embed(texts: list[str]) -> list[list[float]]:
|
|
197
|
+
model = _get_embedder()
|
|
198
|
+
if model is not None:
|
|
199
|
+
embeddings = list(model.embed(texts))
|
|
200
|
+
return [emb.tolist() for emb in embeddings]
|
|
201
|
+
return [_local_vector(t) for t in texts]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cosine_sim(a: list[float], b: list[float]) -> float:
|
|
205
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
206
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
207
|
+
nb = math.sqrt(sum(x * x for x in b))
|
|
208
|
+
if na == 0 or nb == 0:
|
|
209
|
+
return 0.0
|
|
210
|
+
return dot / (na * nb)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Date/temporal extraction
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
_DATE_PATTERNS = [
|
|
217
|
+
# "March 15th", "April 3rd", "June 10"
|
|
218
|
+
re.compile(r'\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})(?:st|nd|rd|th)?\b', re.I),
|
|
219
|
+
# "03/15/2023", "2023-03-15"
|
|
220
|
+
re.compile(r'\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b'),
|
|
221
|
+
re.compile(r'\b(\d{1,2})[-/](\d{1,2})[-/](\d{4})\b'),
|
|
222
|
+
# Relative: "last week", "two weeks ago", "3 months"
|
|
223
|
+
re.compile(r'\b(\d+|one|two|three|four|five|six|seven|eight|nine|ten)\s+(days?|weeks?|months?|years?)\s*(ago|before|after|later)?\b', re.I),
|
|
224
|
+
# Days of week
|
|
225
|
+
re.compile(r'\b(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b', re.I),
|
|
226
|
+
# Times
|
|
227
|
+
re.compile(r'\b(\d{1,2}):(\d{2})\s*(AM|PM|am|pm)?\b'),
|
|
228
|
+
re.compile(r'\b(\d{1,2})\s*(AM|PM|am|pm)\b'),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _extract_dates(text: str) -> list[str]:
|
|
233
|
+
"""Extract date/time mentions from text."""
|
|
234
|
+
dates = []
|
|
235
|
+
for pat in _DATE_PATTERNS:
|
|
236
|
+
for m in pat.finditer(text):
|
|
237
|
+
dates.append(m.group(0).lower())
|
|
238
|
+
return dates
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Entity extraction (names, proper nouns, key terms)
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
_ENTITY_RE = re.compile(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b")
|
|
245
|
+
_NAME_STOPWORDS = frozenset({
|
|
246
|
+
"The", "This", "That", "These", "Those", "It", "Its", "They", "Their",
|
|
247
|
+
"We", "Our", "He", "She", "His", "Her", "You", "Your", "My", "In",
|
|
248
|
+
"On", "At", "By", "For", "With", "From", "To", "And", "But", "Or",
|
|
249
|
+
"If", "When", "Where", "What", "How", "Why", "Who", "Which", "Each",
|
|
250
|
+
"Every", "Some", "Any", "All", "Most", "Many", "Few", "No", "Not",
|
|
251
|
+
"Also", "However", "Therefore", "Furthermore", "Moreover", "Thus",
|
|
252
|
+
"Here", "There", "Now", "Then", "After", "Before", "While", "Since",
|
|
253
|
+
"Sure", "Yes", "No", "Well", "Great", "Good", "Thanks", "Thank",
|
|
254
|
+
"Hi", "Hello", "Hey", "Okay", "Ok", "Right", "Let", "Can", "Could",
|
|
255
|
+
"Would", "Should", "May", "Might", "Will", "Shall", "Do", "Does",
|
|
256
|
+
"Did", "Have", "Has", "Had", "Are", "Is", "Was", "Were", "Be",
|
|
257
|
+
"Been", "Being", "First", "Last", "Next", "New", "Old", "Just",
|
|
258
|
+
"Really", "Actually", "Definitely", "Absolutely", "Recently",
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _extract_entities(text: str) -> list[str]:
|
|
263
|
+
"""Extract proper nouns and key entities."""
|
|
264
|
+
entities = []
|
|
265
|
+
for m in _ENTITY_RE.finditer(text):
|
|
266
|
+
ent = m.group(1)
|
|
267
|
+
if ent not in _NAME_STOPWORDS and len(ent) > 1:
|
|
268
|
+
entities.append(ent.lower())
|
|
269
|
+
# Also extract quoted terms
|
|
270
|
+
for m in re.finditer(r"['\"]([^'\"]{2,40})['\"]", text):
|
|
271
|
+
entities.append(m.group(1).lower())
|
|
272
|
+
return list(set(entities))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# Preference/opinion extraction
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
_PREF_PATTERNS = [
|
|
279
|
+
re.compile(r"\b(?:i|we)\s+(?:prefer|like|love|enjoy|hate|dislike|want|need|use|chose|picked|switched to|started using|recommend|always|usually|typically)\s+(.{3,80}?)(?:\.|,|!|\?|$)", re.I),
|
|
280
|
+
re.compile(r"\b(?:my|our)\s+(?:favorite|preferred|go-to|usual|regular|default)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
|
|
281
|
+
re.compile(r"\b(?:i'm|i am)\s+(?:a fan of|into|interested in|passionate about|obsessed with|addicted to)\s+(.{3,50}?)(?:\.|,|!|\?|$)", re.I),
|
|
282
|
+
re.compile(r"\b(?:i|we)\s+(?:always|usually|typically|normally|generally)\s+(?:go with|choose|pick|get|order|buy|use|watch|listen to|read|play|eat|drink|wear)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
|
|
283
|
+
re.compile(r"\b(?:i've been|i have been|i started)\s+(?:using|reading|watching|playing|eating|drinking|wearing|going to|listening to)\s+(.{3,60}?)(?:\.|,|!|\?|$)", re.I),
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _extract_preferences(text: str) -> list[str]:
|
|
288
|
+
"""Extract preference/opinion statements."""
|
|
289
|
+
prefs = []
|
|
290
|
+
for pat in _PREF_PATTERNS:
|
|
291
|
+
for m in pat.finditer(text):
|
|
292
|
+
prefs.append(m.group(0).lower().strip())
|
|
293
|
+
return prefs
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Conversation-aware chunking
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
def chunk_conversation(text: str, source_title: str = "") -> list[dict]:
|
|
300
|
+
"""Chunk a conversation into meaningful units.
|
|
301
|
+
|
|
302
|
+
Strategy:
|
|
303
|
+
- If the text looks like a conversation (has "User:" / "Assistant:" markers),
|
|
304
|
+
chunk by exchange (user message + assistant response = 1 chunk).
|
|
305
|
+
- Additionally create a "facts" chunk that extracts key facts, dates, entities,
|
|
306
|
+
and preferences from the entire conversation.
|
|
307
|
+
- For non-conversation text, use paragraph-level chunking.
|
|
308
|
+
"""
|
|
309
|
+
chunks = []
|
|
310
|
+
chunk_idx = 0
|
|
311
|
+
|
|
312
|
+
# Detect if this is a conversation
|
|
313
|
+
is_conversation = bool(re.search(r'^(User|Assistant|Human|AI):', text, re.M))
|
|
314
|
+
|
|
315
|
+
if is_conversation:
|
|
316
|
+
# Split into turns
|
|
317
|
+
turn_pattern = re.compile(r'^(User|Assistant|Human|AI):\s*', re.M)
|
|
318
|
+
parts = turn_pattern.split(text)
|
|
319
|
+
# parts = ['', 'User', content, 'Assistant', content, ...]
|
|
320
|
+
|
|
321
|
+
current_exchange = []
|
|
322
|
+
exchanges = []
|
|
323
|
+
i = 1 # skip empty first part
|
|
324
|
+
while i < len(parts) - 1:
|
|
325
|
+
role = parts[i].lower()
|
|
326
|
+
content = parts[i + 1].strip()
|
|
327
|
+
current_exchange.append(f"{role}: {content}")
|
|
328
|
+
# End exchange after assistant response
|
|
329
|
+
if role in ("assistant", "ai") and current_exchange:
|
|
330
|
+
exchanges.append("\n".join(current_exchange))
|
|
331
|
+
current_exchange = []
|
|
332
|
+
i += 2
|
|
333
|
+
if current_exchange:
|
|
334
|
+
exchanges.append("\n".join(current_exchange))
|
|
335
|
+
|
|
336
|
+
# Create chunks from exchanges (group 2-3 exchanges per chunk for context)
|
|
337
|
+
group_size = 2
|
|
338
|
+
for gi in range(0, len(exchanges), group_size):
|
|
339
|
+
group = exchanges[gi:gi + group_size]
|
|
340
|
+
content = "\n\n".join(group)
|
|
341
|
+
words = content.split()
|
|
342
|
+
entities = _extract_entities(content)
|
|
343
|
+
dates = _extract_dates(content)
|
|
344
|
+
prefs = _extract_preferences(content)
|
|
345
|
+
|
|
346
|
+
chunks.append({
|
|
347
|
+
"content": content,
|
|
348
|
+
"token_count": len(words),
|
|
349
|
+
"span": [0, len(words)],
|
|
350
|
+
"chunk_index": chunk_idx,
|
|
351
|
+
"community_id": chunk_idx,
|
|
352
|
+
"entities": entities,
|
|
353
|
+
"dates": dates,
|
|
354
|
+
"preferences": prefs,
|
|
355
|
+
"type": "exchange",
|
|
356
|
+
})
|
|
357
|
+
chunk_idx += 1
|
|
358
|
+
|
|
359
|
+
# Create a "facts summary" chunk with all extracted metadata
|
|
360
|
+
all_entities = []
|
|
361
|
+
all_dates = []
|
|
362
|
+
all_prefs = []
|
|
363
|
+
for c in chunks:
|
|
364
|
+
all_entities.extend(c["entities"])
|
|
365
|
+
all_dates.extend(c["dates"])
|
|
366
|
+
all_prefs.extend(c["preferences"])
|
|
367
|
+
|
|
368
|
+
if all_entities or all_dates or all_prefs:
|
|
369
|
+
facts_parts = []
|
|
370
|
+
if all_entities:
|
|
371
|
+
facts_parts.append("Entities mentioned: " + ", ".join(set(all_entities)))
|
|
372
|
+
if all_dates:
|
|
373
|
+
facts_parts.append("Dates/times: " + ", ".join(set(all_dates)))
|
|
374
|
+
if all_prefs:
|
|
375
|
+
facts_parts.append("Preferences: " + "; ".join(set(all_prefs)))
|
|
376
|
+
facts_content = f"[Session facts for {source_title}] " + ". ".join(facts_parts)
|
|
377
|
+
chunks.append({
|
|
378
|
+
"content": facts_content,
|
|
379
|
+
"token_count": len(facts_content.split()),
|
|
380
|
+
"span": [0, 0],
|
|
381
|
+
"chunk_index": chunk_idx,
|
|
382
|
+
"community_id": chunk_idx,
|
|
383
|
+
"entities": list(set(all_entities)),
|
|
384
|
+
"dates": list(set(all_dates)),
|
|
385
|
+
"preferences": list(set(all_prefs)),
|
|
386
|
+
"type": "facts",
|
|
387
|
+
})
|
|
388
|
+
chunk_idx += 1
|
|
389
|
+
else:
|
|
390
|
+
# Non-conversation: paragraph-level chunking
|
|
391
|
+
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
392
|
+
# Group small paragraphs
|
|
393
|
+
current = []
|
|
394
|
+
current_words = 0
|
|
395
|
+
for para in paragraphs:
|
|
396
|
+
pw = len(para.split())
|
|
397
|
+
if current_words + pw > 256 and current:
|
|
398
|
+
content = "\n\n".join(current)
|
|
399
|
+
chunks.append({
|
|
400
|
+
"content": content,
|
|
401
|
+
"token_count": current_words,
|
|
402
|
+
"span": [0, current_words],
|
|
403
|
+
"chunk_index": chunk_idx,
|
|
404
|
+
"community_id": chunk_idx,
|
|
405
|
+
"entities": _extract_entities(content),
|
|
406
|
+
"dates": _extract_dates(content),
|
|
407
|
+
"preferences": _extract_preferences(content),
|
|
408
|
+
"type": "paragraph",
|
|
409
|
+
})
|
|
410
|
+
chunk_idx += 1
|
|
411
|
+
current = []
|
|
412
|
+
current_words = 0
|
|
413
|
+
current.append(para)
|
|
414
|
+
current_words += pw
|
|
415
|
+
if current:
|
|
416
|
+
content = "\n\n".join(current)
|
|
417
|
+
chunks.append({
|
|
418
|
+
"content": content,
|
|
419
|
+
"token_count": current_words,
|
|
420
|
+
"span": [0, current_words],
|
|
421
|
+
"chunk_index": chunk_idx,
|
|
422
|
+
"community_id": chunk_idx,
|
|
423
|
+
"entities": _extract_entities(content),
|
|
424
|
+
"dates": _extract_dates(content),
|
|
425
|
+
"preferences": _extract_preferences(content),
|
|
426
|
+
"type": "paragraph",
|
|
427
|
+
})
|
|
428
|
+
chunk_idx += 1
|
|
429
|
+
|
|
430
|
+
return chunks
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ---------------------------------------------------------------------------
|
|
434
|
+
# Leiden chunker (when deps available)
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
_SENT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z])|(?<=\n)\s*(?=\S)|(?<=\.)\s*\n')
|
|
437
|
+
_MIN_SENTENCES = 8
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def leiden_chunk(text: str) -> list[dict]:
|
|
441
|
+
"""Leiden community-detection chunking. Falls back to conversation chunking."""
|
|
442
|
+
try:
|
|
443
|
+
import igraph as ig
|
|
444
|
+
import leidenalg
|
|
445
|
+
import numpy as np
|
|
446
|
+
except ImportError:
|
|
447
|
+
return [] # Signal that Leiden is unavailable
|
|
448
|
+
|
|
449
|
+
sentences = _SENT_RE.split(text.strip())
|
|
450
|
+
sentences = [s.strip() for s in sentences if s and s.strip()]
|
|
451
|
+
if len(sentences) < _MIN_SENTENCES:
|
|
452
|
+
return []
|
|
453
|
+
|
|
454
|
+
# Build sentence nodes with embeddings
|
|
455
|
+
embeddings = embed(sentences)
|
|
456
|
+
emb_array = np.array(embeddings, dtype=np.float32)
|
|
457
|
+
norms = np.linalg.norm(emb_array, axis=1, keepdims=True)
|
|
458
|
+
norms = np.where(norms == 0, 1, norms)
|
|
459
|
+
emb_array = emb_array / norms
|
|
460
|
+
sim_matrix = (emb_array @ emb_array.T).tolist()
|
|
461
|
+
|
|
462
|
+
n = len(sentences)
|
|
463
|
+
g = ig.Graph(n=n, directed=False)
|
|
464
|
+
edges, weights = [], []
|
|
465
|
+
|
|
466
|
+
for i in range(n):
|
|
467
|
+
for j in range(i + 1, n):
|
|
468
|
+
sim = sim_matrix[i][j]
|
|
469
|
+
dist = abs(i - j)
|
|
470
|
+
if dist <= 3:
|
|
471
|
+
sim += 0.2 * (1 - dist / 4)
|
|
472
|
+
if sim >= 0.3:
|
|
473
|
+
edges.append((i, j))
|
|
474
|
+
weights.append(sim)
|
|
475
|
+
|
|
476
|
+
if not edges:
|
|
477
|
+
return []
|
|
478
|
+
|
|
479
|
+
g.add_edges(edges)
|
|
480
|
+
g.es["weight"] = weights
|
|
481
|
+
|
|
482
|
+
total_words = sum(len(s.split()) for s in sentences)
|
|
483
|
+
expected = max(1, total_words // 256)
|
|
484
|
+
resolution = max(0.5, min(3.0, expected / max(1, n / 10)))
|
|
485
|
+
|
|
486
|
+
partition = leidenalg.find_partition(
|
|
487
|
+
g, leidenalg.RBConfigurationVertexPartition,
|
|
488
|
+
weights="weight", resolution_parameter=resolution,
|
|
489
|
+
n_iterations=-1, seed=42)
|
|
490
|
+
|
|
491
|
+
chunks = []
|
|
492
|
+
for ci, comm in enumerate(partition):
|
|
493
|
+
if not comm:
|
|
494
|
+
continue
|
|
495
|
+
comm_sorted = sorted(comm)
|
|
496
|
+
content = " ".join(sentences[i] for i in comm_sorted)
|
|
497
|
+
wc = sum(len(sentences[i].split()) for i in comm_sorted)
|
|
498
|
+
chunks.append({
|
|
499
|
+
"content": content,
|
|
500
|
+
"token_count": wc,
|
|
501
|
+
"span": [0, wc],
|
|
502
|
+
"chunk_index": ci,
|
|
503
|
+
"community_id": ci,
|
|
504
|
+
"entities": _extract_entities(content),
|
|
505
|
+
"dates": _extract_dates(content),
|
|
506
|
+
"preferences": _extract_preferences(content),
|
|
507
|
+
"type": "leiden",
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
return chunks
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
# ---------------------------------------------------------------------------
|
|
514
|
+
# Knowledge graph operations
|
|
515
|
+
# ---------------------------------------------------------------------------
|
|
516
|
+
def _gen_id(content: str) -> str:
|
|
517
|
+
return hashlib.sha256(content.encode()).hexdigest()[:16]
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def add_knowledge(text: str, source_title: str = "untitled") -> dict:
|
|
521
|
+
"""Ingest text: chunk, embed, build graph edges, persist."""
|
|
522
|
+
chunks_store = _load_json(_CHUNKS_FILE) or []
|
|
523
|
+
edges_store = _load_json(_EDGES_FILE) or []
|
|
524
|
+
sources_store = _load_json(_SOURCES_FILE) or []
|
|
525
|
+
|
|
526
|
+
source_id = _gen_id(f"{source_title}:{text[:100]}")
|
|
527
|
+
|
|
528
|
+
# Remove existing data for this source (idempotent re-index)
|
|
529
|
+
chunks_store = [c for c in chunks_store if c.get("source_id") != source_id]
|
|
530
|
+
edges_store = [e for e in edges_store if e.get("source_id") != source_id]
|
|
531
|
+
|
|
532
|
+
# Try Leiden first, fall back to conversation-aware chunking
|
|
533
|
+
pieces = leiden_chunk(text)
|
|
534
|
+
if not pieces:
|
|
535
|
+
pieces = chunk_conversation(text, source_title)
|
|
536
|
+
|
|
537
|
+
if not pieces:
|
|
538
|
+
return {"source_id": source_id, "chunks": 0, "edges": 0}
|
|
539
|
+
|
|
540
|
+
# Embed
|
|
541
|
+
vectors = embed([p["content"] for p in pieces])
|
|
542
|
+
chunk_ids = []
|
|
543
|
+
for piece, vec in zip(pieces, vectors):
|
|
544
|
+
cid = _gen_id(f"{source_id}:{piece['chunk_index']}")
|
|
545
|
+
chunk_ids.append(cid)
|
|
546
|
+
chunks_store.append({
|
|
547
|
+
"id": cid,
|
|
548
|
+
"source_id": source_id,
|
|
549
|
+
"source_title": source_title,
|
|
550
|
+
"content": piece["content"],
|
|
551
|
+
"embedding": vec,
|
|
552
|
+
"token_count": piece["token_count"],
|
|
553
|
+
"chunk_index": piece["chunk_index"],
|
|
554
|
+
"community_id": piece["community_id"],
|
|
555
|
+
"entities": piece.get("entities", []),
|
|
556
|
+
"dates": piece.get("dates", []),
|
|
557
|
+
"preferences": piece.get("preferences", []),
|
|
558
|
+
"type": piece.get("type", "unknown"),
|
|
559
|
+
"span": piece["span"],
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
# Build edges
|
|
563
|
+
new_edges = []
|
|
564
|
+
for i in range(len(chunk_ids) - 1):
|
|
565
|
+
new_edges.append({"source_id": source_id, "from": chunk_ids[i], "to": chunk_ids[i+1], "type": "next", "weight": 1.0})
|
|
566
|
+
new_edges.append({"source_id": source_id, "from": chunk_ids[i+1], "to": chunk_ids[i], "type": "prev", "weight": 1.0})
|
|
567
|
+
|
|
568
|
+
# Similarity edges
|
|
569
|
+
for i in range(len(vectors)):
|
|
570
|
+
sims = []
|
|
571
|
+
for j in range(len(vectors)):
|
|
572
|
+
if i == j or abs(i - j) == 1:
|
|
573
|
+
continue
|
|
574
|
+
s = cosine_sim(vectors[i], vectors[j])
|
|
575
|
+
if s >= 0.7:
|
|
576
|
+
sims.append((j, s))
|
|
577
|
+
sims.sort(key=lambda x: x[1], reverse=True)
|
|
578
|
+
for j, score in sims[:5]:
|
|
579
|
+
new_edges.append({"source_id": source_id, "from": chunk_ids[i], "to": chunk_ids[j], "type": "similar", "weight": score})
|
|
580
|
+
|
|
581
|
+
# Entity co-occurrence edges (chunks sharing entities)
|
|
582
|
+
entity_to_chunks: dict[str, list[str]] = defaultdict(list)
|
|
583
|
+
for cid, piece in zip(chunk_ids, pieces):
|
|
584
|
+
for ent in piece.get("entities", []):
|
|
585
|
+
entity_to_chunks[ent].append(cid)
|
|
586
|
+
for ent, cids in entity_to_chunks.items():
|
|
587
|
+
if len(cids) > 1 and len(cids) <= 10:
|
|
588
|
+
for i in range(len(cids)):
|
|
589
|
+
for j in range(i + 1, len(cids)):
|
|
590
|
+
new_edges.append({"source_id": source_id, "from": cids[i], "to": cids[j], "type": "entity", "weight": 0.6})
|
|
591
|
+
|
|
592
|
+
edges_store.extend(new_edges)
|
|
593
|
+
sources_store = [s for s in sources_store if s["id"] != source_id]
|
|
594
|
+
sources_store.append({"id": source_id, "title": source_title, "chunks": len(pieces)})
|
|
595
|
+
|
|
596
|
+
_save_json(_CHUNKS_FILE, chunks_store)
|
|
597
|
+
_save_json(_EDGES_FILE, edges_store)
|
|
598
|
+
_save_json(_SOURCES_FILE, sources_store)
|
|
599
|
+
|
|
600
|
+
return {"source_id": source_id, "chunks": len(pieces), "edges": len(new_edges)}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def search_knowledge(query: str, k: int = 8) -> list[dict]:
|
|
604
|
+
"""Hybrid search: BM25 + vector + entity/date matching."""
|
|
605
|
+
chunks_store = _load_json(_CHUNKS_FILE) or []
|
|
606
|
+
if not chunks_store:
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
# Build BM25 index
|
|
610
|
+
bm25 = BM25Index()
|
|
611
|
+
for c in chunks_store:
|
|
612
|
+
bm25.add(_tokenize(c["content"]))
|
|
613
|
+
|
|
614
|
+
# BM25 scores
|
|
615
|
+
bm25_results = bm25.search(query, k=k * 3)
|
|
616
|
+
bm25_scores = {i: s for i, s in bm25_results}
|
|
617
|
+
|
|
618
|
+
# Vector scores
|
|
619
|
+
qvec = embed([query])[0]
|
|
620
|
+
vec_scores = {}
|
|
621
|
+
for i, c in enumerate(chunks_store):
|
|
622
|
+
vec_scores[i] = cosine_sim(qvec, c["embedding"])
|
|
623
|
+
|
|
624
|
+
# Entity/date bonus: if query mentions an entity/date, boost chunks containing it
|
|
625
|
+
query_entities = _extract_entities(query)
|
|
626
|
+
# Also extract quoted terms from query
|
|
627
|
+
for m in re.finditer(r"['\"]([^'\"]{2,40})['\"]", query):
|
|
628
|
+
query_entities.append(m.group(1).lower())
|
|
629
|
+
query_dates = _extract_dates(query)
|
|
630
|
+
query_lower = query.lower()
|
|
631
|
+
|
|
632
|
+
# Also extract key nouns from query that might be entity matches
|
|
633
|
+
query_keywords = [w for w in _tokenize(query) if len(w) > 3]
|
|
634
|
+
|
|
635
|
+
entity_scores = {}
|
|
636
|
+
for i, c in enumerate(chunks_store):
|
|
637
|
+
bonus = 0.0
|
|
638
|
+
chunk_entities = c.get("entities", [])
|
|
639
|
+
chunk_dates = c.get("dates", [])
|
|
640
|
+
chunk_prefs = c.get("preferences", [])
|
|
641
|
+
chunk_content_lower = c["content"].lower()
|
|
642
|
+
|
|
643
|
+
# Entity overlap
|
|
644
|
+
for qe in query_entities:
|
|
645
|
+
if qe in chunk_entities or qe in chunk_content_lower:
|
|
646
|
+
bonus += 0.3
|
|
647
|
+
|
|
648
|
+
# Date overlap
|
|
649
|
+
for qd in query_dates:
|
|
650
|
+
if qd in chunk_dates or qd in chunk_content_lower:
|
|
651
|
+
bonus += 0.2
|
|
652
|
+
|
|
653
|
+
# Preference match: if query asks about preferences and chunk has them
|
|
654
|
+
is_pref_query = any(w in query_lower for w in ("prefer", "favorite", "like", "go-to", "usually", "recommend", "what do i", "what's my", "what is my"))
|
|
655
|
+
if is_pref_query:
|
|
656
|
+
if chunk_prefs:
|
|
657
|
+
bonus += 0.35
|
|
658
|
+
# Extra: check if the preference content matches query keywords
|
|
659
|
+
for pref in chunk_prefs:
|
|
660
|
+
for kw in query_keywords:
|
|
661
|
+
if kw in pref:
|
|
662
|
+
bonus += 0.2
|
|
663
|
+
# Boost chunks containing user statements (preferences come from user, not assistant)
|
|
664
|
+
if "user:" in chunk_content_lower:
|
|
665
|
+
bonus += 0.1
|
|
666
|
+
|
|
667
|
+
# Keyword presence bonus (exact term matching beyond BM25)
|
|
668
|
+
for kw in query_keywords:
|
|
669
|
+
if kw in chunk_content_lower:
|
|
670
|
+
bonus += 0.05
|
|
671
|
+
|
|
672
|
+
entity_scores[i] = bonus
|
|
673
|
+
|
|
674
|
+
# Character n-gram overlap (fuzzy matching — catches morphological variants)
|
|
675
|
+
def _char_ngrams(text: str, n: int = 3) -> set[str]:
|
|
676
|
+
text = text.lower()
|
|
677
|
+
return {text[i:i+n] for i in range(len(text) - n + 1)}
|
|
678
|
+
|
|
679
|
+
query_ngrams = _char_ngrams(query)
|
|
680
|
+
ngram_scores = {}
|
|
681
|
+
for i, c in enumerate(chunks_store):
|
|
682
|
+
chunk_ngrams = _char_ngrams(c["content"])
|
|
683
|
+
if query_ngrams and chunk_ngrams:
|
|
684
|
+
overlap = len(query_ngrams & chunk_ngrams)
|
|
685
|
+
ngram_scores[i] = overlap / len(query_ngrams)
|
|
686
|
+
else:
|
|
687
|
+
ngram_scores[i] = 0.0
|
|
688
|
+
|
|
689
|
+
# Fuse scores: weighted combination of 4 signals
|
|
690
|
+
max_bm25 = max(bm25_scores.values()) if bm25_scores else 1.0
|
|
691
|
+
max_vec = max(vec_scores.values()) if vec_scores else 1.0
|
|
692
|
+
max_ngram = max(ngram_scores.values()) if ngram_scores else 1.0
|
|
693
|
+
|
|
694
|
+
fused = {}
|
|
695
|
+
for i in range(len(chunks_store)):
|
|
696
|
+
bm25_norm = bm25_scores.get(i, 0) / max(max_bm25, 0.001)
|
|
697
|
+
vec_norm = vec_scores.get(i, 0) / max(max_vec, 0.001)
|
|
698
|
+
ent_score = entity_scores.get(i, 0)
|
|
699
|
+
ngram_norm = ngram_scores.get(i, 0) / max(max_ngram, 0.001)
|
|
700
|
+
fused[i] = 0.4 * bm25_norm + 0.15 * vec_norm + 0.25 * ent_score + 0.2 * ngram_norm
|
|
701
|
+
|
|
702
|
+
# Sort and return top-k
|
|
703
|
+
ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)
|
|
704
|
+
|
|
705
|
+
results = []
|
|
706
|
+
seen_content = set()
|
|
707
|
+
for i, score in ranked:
|
|
708
|
+
if score <= 0:
|
|
709
|
+
continue
|
|
710
|
+
c = chunks_store[i]
|
|
711
|
+
# Dedup by content similarity
|
|
712
|
+
content_key = c["content"][:100].lower()
|
|
713
|
+
if content_key in seen_content:
|
|
714
|
+
continue
|
|
715
|
+
seen_content.add(content_key)
|
|
716
|
+
results.append({
|
|
717
|
+
"chunk_id": c["id"],
|
|
718
|
+
"content": c["content"],
|
|
719
|
+
"source_title": c["source_title"],
|
|
720
|
+
"score": round(score, 4),
|
|
721
|
+
"community_id": c["community_id"],
|
|
722
|
+
"entities": c.get("entities", []),
|
|
723
|
+
"type": c.get("type", "unknown"),
|
|
724
|
+
})
|
|
725
|
+
if len(results) >= k:
|
|
726
|
+
break
|
|
727
|
+
|
|
728
|
+
return results
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def graph_walk(chunk_id: str, hops: int = 2, k: int = 10) -> list[dict]:
|
|
732
|
+
"""BFS graph traversal from a starting chunk."""
|
|
733
|
+
chunks_store = _load_json(_CHUNKS_FILE) or []
|
|
734
|
+
edges_store = _load_json(_EDGES_FILE) or []
|
|
735
|
+
|
|
736
|
+
chunk_map = {c["id"]: c for c in chunks_store}
|
|
737
|
+
if chunk_id not in chunk_map:
|
|
738
|
+
return []
|
|
739
|
+
|
|
740
|
+
visited = {chunk_id}
|
|
741
|
+
frontier = [(chunk_id, 0, 1.0, "origin")]
|
|
742
|
+
results = []
|
|
743
|
+
|
|
744
|
+
while frontier:
|
|
745
|
+
current, depth, path_weight, edge_type = frontier.pop(0)
|
|
746
|
+
if current != chunk_id:
|
|
747
|
+
c = chunk_map.get(current)
|
|
748
|
+
if c:
|
|
749
|
+
results.append({
|
|
750
|
+
"chunk_id": current,
|
|
751
|
+
"content": c["content"],
|
|
752
|
+
"source_title": c["source_title"],
|
|
753
|
+
"distance": depth,
|
|
754
|
+
"path_type": edge_type,
|
|
755
|
+
"score": round(path_weight, 4),
|
|
756
|
+
"community_id": c["community_id"],
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
if depth < hops:
|
|
760
|
+
for e in edges_store:
|
|
761
|
+
if e["from"] == current and e["to"] not in visited:
|
|
762
|
+
visited.add(e["to"])
|
|
763
|
+
frontier.append((e["to"], depth + 1, path_weight * e["weight"], e["type"]))
|
|
764
|
+
|
|
765
|
+
results.sort(key=lambda x: x["score"], reverse=True)
|
|
766
|
+
return results[:k]
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def get_context(query: str, k: int = 5, hops: int = 1, min_source_pct: float = 0.75) -> list[dict]:
|
|
770
|
+
"""Combined search + graph walk. Returns best chunk per source until coverage target met.
|
|
771
|
+
|
|
772
|
+
Retrieves all scored chunks, then picks best chunk from each source (session).
|
|
773
|
+
Keeps adding sources until min_source_pct of total sources are covered.
|
|
774
|
+
"""
|
|
775
|
+
sources_store = _load_json(_SOURCES_FILE) or []
|
|
776
|
+
total_sources = len(sources_store)
|
|
777
|
+
target_sources = max(k, int(total_sources * min_source_pct)) if total_sources > 0 else k
|
|
778
|
+
|
|
779
|
+
# Score ALL chunks
|
|
780
|
+
chunks_store = _load_json(_CHUNKS_FILE) or []
|
|
781
|
+
all_hits = search_knowledge(query, k=len(chunks_store))
|
|
782
|
+
if not all_hits:
|
|
783
|
+
return []
|
|
784
|
+
|
|
785
|
+
# Graph walk from top hits
|
|
786
|
+
seen_ids = {h["chunk_id"] for h in all_hits}
|
|
787
|
+
for top_hit in all_hits[:3]:
|
|
788
|
+
walked = graph_walk(top_hit["chunk_id"], hops=hops, k=k)
|
|
789
|
+
for w in walked:
|
|
790
|
+
if w["chunk_id"] not in seen_ids:
|
|
791
|
+
w["score"] = w["score"] * 0.5
|
|
792
|
+
all_hits.append(w)
|
|
793
|
+
seen_ids.add(w["chunk_id"])
|
|
794
|
+
|
|
795
|
+
# Source-diverse: best chunk per source
|
|
796
|
+
source_best: dict[str, dict] = {}
|
|
797
|
+
for h in all_hits:
|
|
798
|
+
src = h.get("source_title", "")
|
|
799
|
+
if src not in source_best or h["score"] > source_best[src]["score"]:
|
|
800
|
+
source_best[src] = h
|
|
801
|
+
|
|
802
|
+
sources_ranked = sorted(source_best.keys(), key=lambda s: source_best[s]["score"], reverse=True)
|
|
803
|
+
|
|
804
|
+
selected = []
|
|
805
|
+
for src in sources_ranked:
|
|
806
|
+
if len(selected) >= target_sources:
|
|
807
|
+
break
|
|
808
|
+
selected.append(source_best[src])
|
|
809
|
+
|
|
810
|
+
return selected
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def list_sources() -> list[dict]:
|
|
814
|
+
return _load_json(_SOURCES_FILE) or []
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def delete_source(source_id: str) -> dict:
|
|
818
|
+
chunks_store = _load_json(_CHUNKS_FILE) or []
|
|
819
|
+
edges_store = _load_json(_EDGES_FILE) or []
|
|
820
|
+
sources_store = _load_json(_SOURCES_FILE) or []
|
|
821
|
+
|
|
822
|
+
before = len(chunks_store)
|
|
823
|
+
chunks_store = [c for c in chunks_store if c.get("source_id") != source_id]
|
|
824
|
+
edges_store = [e for e in edges_store if e.get("source_id") != source_id]
|
|
825
|
+
sources_store = [s for s in sources_store if s.get("id") != source_id]
|
|
826
|
+
|
|
827
|
+
_save_json(_CHUNKS_FILE, chunks_store)
|
|
828
|
+
_save_json(_EDGES_FILE, edges_store)
|
|
829
|
+
_save_json(_SOURCES_FILE, sources_store)
|
|
830
|
+
|
|
831
|
+
return {"deleted_chunks": before - len(chunks_store)}
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
# ---------------------------------------------------------------------------
|
|
835
|
+
# JSON-line RPC protocol
|
|
836
|
+
# ---------------------------------------------------------------------------
|
|
837
|
+
METHODS = {
|
|
838
|
+
"add_knowledge": lambda p: add_knowledge(p["text"], p.get("title", "untitled")),
|
|
839
|
+
"search": lambda p: search_knowledge(p["query"], p.get("k", 8)),
|
|
840
|
+
"graph_walk": lambda p: graph_walk(p["chunk_id"], p.get("hops", 2), p.get("k", 10)),
|
|
841
|
+
"get_context": lambda p: get_context(p["query"], p.get("k", 5), p.get("hops", 1), p.get("min_source_pct", 0.15)),
|
|
842
|
+
"list_sources": lambda p: list_sources(),
|
|
843
|
+
"delete_source": lambda p: delete_source(p["source_id"]),
|
|
844
|
+
"ping": lambda p: {"status": "ok"},
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def main():
|
|
849
|
+
log.info("scimap engine started (data_dir=%s)", _DATA_DIR)
|
|
850
|
+
_get_embedder()
|
|
851
|
+
|
|
852
|
+
for line in sys.stdin:
|
|
853
|
+
line = line.strip()
|
|
854
|
+
if not line:
|
|
855
|
+
continue
|
|
856
|
+
try:
|
|
857
|
+
req = json.loads(line)
|
|
858
|
+
except json.JSONDecodeError as e:
|
|
859
|
+
sys.stdout.write(json.dumps({"id": None, "error": f"invalid JSON: {e}"}) + "\n")
|
|
860
|
+
sys.stdout.flush()
|
|
861
|
+
continue
|
|
862
|
+
|
|
863
|
+
req_id = req.get("id")
|
|
864
|
+
method = req.get("method", "")
|
|
865
|
+
params = req.get("params", {})
|
|
866
|
+
|
|
867
|
+
if method not in METHODS:
|
|
868
|
+
resp = {"id": req_id, "error": f"unknown method: {method}"}
|
|
869
|
+
else:
|
|
870
|
+
try:
|
|
871
|
+
result = METHODS[method](params)
|
|
872
|
+
resp = {"id": req_id, "result": result}
|
|
873
|
+
except Exception as e:
|
|
874
|
+
log.exception("method %s failed", method)
|
|
875
|
+
resp = {"id": req_id, "error": str(e)}
|
|
876
|
+
|
|
877
|
+
sys.stdout.write(json.dumps(resp, default=str) + "\n")
|
|
878
|
+
sys.stdout.flush()
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
if __name__ == "__main__":
|
|
882
|
+
main()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swafra
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server engine for swafra — Leiden-chunked, graph-linked semantic memory
|
|
5
|
+
Author: kunal12203
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,knowledge-graph,leiden,semantic-memory,claude
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: fastembed>=0.4.0
|
|
16
|
+
Requires-Dist: igraph>=0.11.0
|
|
17
|
+
Requires-Dist: leidenalg>=0.10.0
|
|
18
|
+
Requires-Dist: numpy>=1.26.0
|
|
19
|
+
Dynamic: author
|
|
20
|
+
Dynamic: classifier
|
|
21
|
+
Dynamic: description
|
|
22
|
+
Dynamic: description-content-type
|
|
23
|
+
Dynamic: keywords
|
|
24
|
+
Dynamic: license
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
# swafra
|
|
30
|
+
|
|
31
|
+
MCP server for knowledge graphs — Leiden-chunked, graph-linked semantic memory for Claude AI and ChatGPT.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install swafra
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
swafra
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
swafra/__init__.py
|
|
5
|
+
swafra/engine.py
|
|
6
|
+
swafra.egg-info/PKG-INFO
|
|
7
|
+
swafra.egg-info/SOURCES.txt
|
|
8
|
+
swafra.egg-info/dependency_links.txt
|
|
9
|
+
swafra.egg-info/entry_points.txt
|
|
10
|
+
swafra.egg-info/requires.txt
|
|
11
|
+
swafra.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swafra
|