know-do-graph 0.1.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.
- agents/__init__.py +0 -0
- agents/extraction_agent/__init__.py +0 -0
- agents/extraction_agent/agent.py +170 -0
- agents/graph_agent/__init__.py +5 -0
- agents/graph_agent/agent.py +373 -0
- agents/graph_agent/tools.py +2106 -0
- agents/maintenance_agent/__init__.py +0 -0
- agents/maintenance_agent/agent.py +283 -0
- agents/orchestrator/__init__.py +0 -0
- agents/orchestrator/agent.py +217 -0
- agents/review_agent/__init__.py +0 -0
- agents/review_agent/agent.py +188 -0
- agents/review_agent/tools.py +472 -0
- api/__init__.py +0 -0
- api/main.py +136 -0
- api/routes/__init__.py +0 -0
- api/routes/agent.py +81 -0
- api/routes/entries.py +411 -0
- api/routes/graph.py +132 -0
- api/routes/mem.py +179 -0
- api/routes/remote.py +815 -0
- api/routes/remote_sync.py +230 -0
- api/routes/retrieve.py +88 -0
- core/__init__.py +0 -0
- core/app_state.py +9 -0
- core/events.py +84 -0
- core/extraction/__init__.py +0 -0
- core/extraction/wikilink_parser.py +48 -0
- core/graph/__init__.py +0 -0
- core/graph/graph.py +204 -0
- core/memory/__init__.py +0 -0
- core/memory/memgraph.py +458 -0
- core/resources/starter.db +0 -0
- core/retrieval/__init__.py +0 -0
- core/retrieval/embedder.py +122 -0
- core/retrieval/fusion.py +52 -0
- core/retrieval/progressive.py +399 -0
- core/retrieval/retrieval.py +346 -0
- core/retrieval/vector_store.py +91 -0
- core/schemas/__init__.py +0 -0
- core/schemas/edge.py +46 -0
- core/schemas/entry.py +388 -0
- core/storage/__init__.py +0 -0
- core/storage/database.py +104 -0
- core/storage/models.py +66 -0
- core/storage/repository.py +243 -0
- core/sync/__init__.py +20 -0
- core/sync/autolink.py +301 -0
- core/sync/db_merge.py +297 -0
- core/sync/db_watcher.py +84 -0
- core/sync/remote_sync.py +345 -0
- examples/__init__.py +0 -0
- examples/example_entries.py +206 -0
- examples/pymatgen_interface_examples.py +811 -0
- frontend/dist/assets/index-BLfo7ZZu.css +1 -0
- frontend/dist/assets/index-G-mYbZ9R.js +83 -0
- frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
- frontend/dist/index.html +92 -0
- know_do_graph-0.1.0.dist-info/METADATA +765 -0
- know_do_graph-0.1.0.dist-info/RECORD +63 -0
- know_do_graph-0.1.0.dist-info/WHEEL +4 -0
- know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
- main.py +944 -0
core/memory/memgraph.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Mem-Graph: lightweight, mutable session memory traces.
|
|
2
|
+
|
|
3
|
+
Mem-Graph entries are shallow, episodic notes captured during agent
|
|
4
|
+
interactions. Over time, stable patterns may be promoted into full
|
|
5
|
+
Know-Do Graph entries.
|
|
6
|
+
|
|
7
|
+
Storage is flat JSON files per session, kept under data/memory/.
|
|
8
|
+
|
|
9
|
+
Connecting external agent frameworks
|
|
10
|
+
--------------------------------------
|
|
11
|
+
Any agent framework can write to MemGraph as long as it can produce one
|
|
12
|
+
of the following shapes. Use the matching ``ingest_*`` helper, or call
|
|
13
|
+
``add()`` directly with a plain string.
|
|
14
|
+
|
|
15
|
+
**1. Plain text / single observation**
|
|
16
|
+
|
|
17
|
+
mg = MemGraph("my-session")
|
|
18
|
+
mg.add("user asked about MACE relaxation; answered with [[ASE Relaxation]]",
|
|
19
|
+
tags=["qa"], success=True)
|
|
20
|
+
|
|
21
|
+
**2. OpenAI-style chat messages** – a list of ``{"role": ..., "content": ...}`` dicts.
|
|
22
|
+
Roles are concatenated into a readable transcript.
|
|
23
|
+
|
|
24
|
+
mg.ingest_openai_messages(openai_response["messages"], tags=["openai"])
|
|
25
|
+
|
|
26
|
+
**3. LangChain / generic message objects** – any object with ``.content`` and
|
|
27
|
+
optionally ``.type`` attributes (HumanMessage, AIMessage, SystemMessage …).
|
|
28
|
+
|
|
29
|
+
mg.ingest_langchain_messages(chain.memory.chat_memory.messages)
|
|
30
|
+
|
|
31
|
+
**4. AutoGen / multi-agent conversation list** – list of dicts with at minimum
|
|
32
|
+
``"name"`` (or ``"role"``) and ``"content"`` keys.
|
|
33
|
+
|
|
34
|
+
mg.ingest_autogen_messages(groupchat.messages)
|
|
35
|
+
|
|
36
|
+
**5. Raw JSON file** – a path to a file containing one of the above schemas:
|
|
37
|
+
- a JSON array → treated as a message list (OpenAI / AutoGen format)
|
|
38
|
+
- a JSON object → its ``messages`` or ``history`` key is extracted; otherwise
|
|
39
|
+
the whole object is serialised as a single trace
|
|
40
|
+
|
|
41
|
+
mg.ingest_file(Path("session_dump.json"))
|
|
42
|
+
|
|
43
|
+
**6. Raw text file** – split into chunks or stored as one entry.
|
|
44
|
+
|
|
45
|
+
mg.ingest_text_file(Path("agent_log.txt"), chunk_by="paragraph")
|
|
46
|
+
|
|
47
|
+
The resulting MemEntry objects are identical regardless of source and can all
|
|
48
|
+
be listed, queried, and promoted into full Know-Do Graph entries.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import json
|
|
54
|
+
import uuid
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
from enum import Enum
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import Any, Optional
|
|
59
|
+
|
|
60
|
+
from pydantic import BaseModel, Field
|
|
61
|
+
|
|
62
|
+
_MEMORY_DIR = Path(__file__).parent.parent.parent / "data" / "memory"
|
|
63
|
+
_MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MemSourceFormat(str, Enum):
|
|
67
|
+
"""Describes where / how a MemEntry was ingested."""
|
|
68
|
+
manual = "manual"
|
|
69
|
+
openai_messages = "openai_messages"
|
|
70
|
+
langchain_messages = "langchain_messages"
|
|
71
|
+
autogen_messages = "autogen_messages"
|
|
72
|
+
raw_text = "raw_text"
|
|
73
|
+
json_file = "json_file"
|
|
74
|
+
text_file = "text_file"
|
|
75
|
+
api = "api"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class MemEntry(BaseModel):
|
|
79
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
80
|
+
session_id: str = "default"
|
|
81
|
+
content: str
|
|
82
|
+
tags: list[str] = Field(default_factory=list)
|
|
83
|
+
source_entry_ids: list[str] = Field(default_factory=list)
|
|
84
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
85
|
+
success: Optional[bool] = None
|
|
86
|
+
promoted: bool = False
|
|
87
|
+
promotion_target_id: Optional[str] = None
|
|
88
|
+
source_format: MemSourceFormat = MemSourceFormat.manual
|
|
89
|
+
raw_source: Optional[dict] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MemGraph:
|
|
93
|
+
"""Session-scoped memory graph persisted as a JSON file.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
session_id:
|
|
98
|
+
Logical name for the session. Determines the storage filename
|
|
99
|
+
(``data/memory/<session_id>.json``). Use a stable identifier when
|
|
100
|
+
you want to resume or extend a session across process restarts.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, session_id: str = "default") -> None:
|
|
104
|
+
self.session_id = session_id
|
|
105
|
+
self._entries: dict[str, MemEntry] = {}
|
|
106
|
+
self._load()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def _path(self) -> Path:
|
|
110
|
+
return _MEMORY_DIR / f"{self.session_id}.json"
|
|
111
|
+
|
|
112
|
+
def _load(self) -> None:
|
|
113
|
+
if self._path.exists():
|
|
114
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
115
|
+
self._entries = {k: MemEntry(**v) for k, v in raw.items()}
|
|
116
|
+
|
|
117
|
+
def _save(self) -> None:
|
|
118
|
+
self._path.write_text(
|
|
119
|
+
json.dumps(
|
|
120
|
+
{k: v.model_dump(mode="json") for k, v in self._entries.items()},
|
|
121
|
+
indent=2,
|
|
122
|
+
default=str,
|
|
123
|
+
),
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def _make_entry(
|
|
128
|
+
self,
|
|
129
|
+
content: str,
|
|
130
|
+
tags: Optional[list[str]] = None,
|
|
131
|
+
source_entry_ids: Optional[list[str]] = None,
|
|
132
|
+
success: Optional[bool] = None,
|
|
133
|
+
source_format: MemSourceFormat = MemSourceFormat.manual,
|
|
134
|
+
raw_source: Optional[dict] = None,
|
|
135
|
+
) -> MemEntry:
|
|
136
|
+
entry = MemEntry(
|
|
137
|
+
session_id=self.session_id,
|
|
138
|
+
content=content,
|
|
139
|
+
tags=tags or [],
|
|
140
|
+
source_entry_ids=source_entry_ids or [],
|
|
141
|
+
success=success,
|
|
142
|
+
source_format=source_format,
|
|
143
|
+
raw_source=raw_source,
|
|
144
|
+
)
|
|
145
|
+
self._entries[entry.id] = entry
|
|
146
|
+
return entry
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
# Core write API
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def add(
|
|
153
|
+
self,
|
|
154
|
+
content: str,
|
|
155
|
+
tags: Optional[list[str]] = None,
|
|
156
|
+
source_entry_ids: Optional[list[str]] = None,
|
|
157
|
+
success: Optional[bool] = None,
|
|
158
|
+
) -> MemEntry:
|
|
159
|
+
"""Record a single free-text observation or note."""
|
|
160
|
+
entry = self._make_entry(
|
|
161
|
+
content=content,
|
|
162
|
+
tags=tags,
|
|
163
|
+
source_entry_ids=source_entry_ids,
|
|
164
|
+
success=success,
|
|
165
|
+
source_format=MemSourceFormat.manual,
|
|
166
|
+
)
|
|
167
|
+
self._save()
|
|
168
|
+
return entry
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Framework ingestion helpers
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def ingest_openai_messages(
|
|
175
|
+
self,
|
|
176
|
+
messages: list[dict[str, Any]],
|
|
177
|
+
tags: Optional[list[str]] = None,
|
|
178
|
+
as_single_trace: bool = True,
|
|
179
|
+
) -> list[MemEntry]:
|
|
180
|
+
"""Ingest an OpenAI-style messages list.
|
|
181
|
+
|
|
182
|
+
Parameters
|
|
183
|
+
----------
|
|
184
|
+
messages:
|
|
185
|
+
``[{"role": "user"|"assistant"|"system", "content": "..."}]``
|
|
186
|
+
as_single_trace:
|
|
187
|
+
If True (default) the whole conversation is stored as one
|
|
188
|
+
MemEntry transcript. If False, each message becomes its own entry.
|
|
189
|
+
"""
|
|
190
|
+
tags = (tags or []) + ["openai"]
|
|
191
|
+
created: list[MemEntry] = []
|
|
192
|
+
|
|
193
|
+
if as_single_trace:
|
|
194
|
+
lines = [
|
|
195
|
+
f"[{m.get('role', 'unknown')}] {_extract_content(m)}"
|
|
196
|
+
for m in messages
|
|
197
|
+
if _extract_content(m)
|
|
198
|
+
]
|
|
199
|
+
entry = self._make_entry(
|
|
200
|
+
content="\n".join(lines),
|
|
201
|
+
tags=tags,
|
|
202
|
+
source_format=MemSourceFormat.openai_messages,
|
|
203
|
+
raw_source={"messages": messages},
|
|
204
|
+
)
|
|
205
|
+
created.append(entry)
|
|
206
|
+
else:
|
|
207
|
+
for m in messages:
|
|
208
|
+
content = _extract_content(m)
|
|
209
|
+
if not content:
|
|
210
|
+
continue
|
|
211
|
+
role_tag = m.get("role", "unknown")
|
|
212
|
+
entry = self._make_entry(
|
|
213
|
+
content=content,
|
|
214
|
+
tags=tags + [role_tag],
|
|
215
|
+
source_format=MemSourceFormat.openai_messages,
|
|
216
|
+
raw_source=m,
|
|
217
|
+
)
|
|
218
|
+
created.append(entry)
|
|
219
|
+
|
|
220
|
+
self._save()
|
|
221
|
+
return created
|
|
222
|
+
|
|
223
|
+
def ingest_langchain_messages(
|
|
224
|
+
self,
|
|
225
|
+
messages: list[Any],
|
|
226
|
+
tags: Optional[list[str]] = None,
|
|
227
|
+
as_single_trace: bool = True,
|
|
228
|
+
) -> list[MemEntry]:
|
|
229
|
+
"""Ingest LangChain message objects (HumanMessage, AIMessage, etc.).
|
|
230
|
+
|
|
231
|
+
Accepts any object that has a ``.content`` attribute and optionally
|
|
232
|
+
a ``.type`` attribute (e.g. ``"human"``, ``"ai"``, ``"system"``).
|
|
233
|
+
Plain dicts with ``"content"`` keys are also accepted.
|
|
234
|
+
"""
|
|
235
|
+
tags = (tags or []) + ["langchain"]
|
|
236
|
+
dicts = [_langchain_to_dict(m) for m in messages]
|
|
237
|
+
created: list[MemEntry] = []
|
|
238
|
+
|
|
239
|
+
if as_single_trace:
|
|
240
|
+
lines = [
|
|
241
|
+
f"[{d['role']}] {d['content']}" for d in dicts if d["content"]
|
|
242
|
+
]
|
|
243
|
+
entry = self._make_entry(
|
|
244
|
+
content="\n".join(lines),
|
|
245
|
+
tags=tags,
|
|
246
|
+
source_format=MemSourceFormat.langchain_messages,
|
|
247
|
+
raw_source={"messages": dicts},
|
|
248
|
+
)
|
|
249
|
+
created.append(entry)
|
|
250
|
+
else:
|
|
251
|
+
for d in dicts:
|
|
252
|
+
if not d["content"]:
|
|
253
|
+
continue
|
|
254
|
+
entry = self._make_entry(
|
|
255
|
+
content=d["content"],
|
|
256
|
+
tags=tags + [d["role"]],
|
|
257
|
+
source_format=MemSourceFormat.langchain_messages,
|
|
258
|
+
raw_source=d,
|
|
259
|
+
)
|
|
260
|
+
created.append(entry)
|
|
261
|
+
|
|
262
|
+
self._save()
|
|
263
|
+
return created
|
|
264
|
+
|
|
265
|
+
def ingest_autogen_messages(
|
|
266
|
+
self,
|
|
267
|
+
messages: list[dict[str, Any]],
|
|
268
|
+
tags: Optional[list[str]] = None,
|
|
269
|
+
as_single_trace: bool = True,
|
|
270
|
+
) -> list[MemEntry]:
|
|
271
|
+
"""Ingest AutoGen / multi-agent conversation records.
|
|
272
|
+
|
|
273
|
+
Expects dicts with at minimum a ``"content"`` key and optionally
|
|
274
|
+
``"name"`` or ``"role"`` identifying the speaker.
|
|
275
|
+
"""
|
|
276
|
+
tags = (tags or []) + ["autogen"]
|
|
277
|
+
created: list[MemEntry] = []
|
|
278
|
+
|
|
279
|
+
if as_single_trace:
|
|
280
|
+
lines = []
|
|
281
|
+
for m in messages:
|
|
282
|
+
speaker = m.get("name") or m.get("role", "agent")
|
|
283
|
+
content = _extract_content(m)
|
|
284
|
+
if content:
|
|
285
|
+
lines.append(f"[{speaker}] {content}")
|
|
286
|
+
entry = self._make_entry(
|
|
287
|
+
content="\n".join(lines),
|
|
288
|
+
tags=tags,
|
|
289
|
+
source_format=MemSourceFormat.autogen_messages,
|
|
290
|
+
raw_source={"messages": messages},
|
|
291
|
+
)
|
|
292
|
+
created.append(entry)
|
|
293
|
+
else:
|
|
294
|
+
for m in messages:
|
|
295
|
+
content = _extract_content(m)
|
|
296
|
+
if not content:
|
|
297
|
+
continue
|
|
298
|
+
speaker = m.get("name") or m.get("role", "agent")
|
|
299
|
+
entry = self._make_entry(
|
|
300
|
+
content=content,
|
|
301
|
+
tags=tags + [speaker],
|
|
302
|
+
source_format=MemSourceFormat.autogen_messages,
|
|
303
|
+
raw_source=m,
|
|
304
|
+
)
|
|
305
|
+
created.append(entry)
|
|
306
|
+
|
|
307
|
+
self._save()
|
|
308
|
+
return created
|
|
309
|
+
|
|
310
|
+
def ingest_file(
|
|
311
|
+
self,
|
|
312
|
+
path: Path,
|
|
313
|
+
tags: Optional[list[str]] = None,
|
|
314
|
+
as_single_trace: bool = True,
|
|
315
|
+
) -> list[MemEntry]:
|
|
316
|
+
"""Ingest a JSON session dump.
|
|
317
|
+
|
|
318
|
+
Accepted shapes
|
|
319
|
+
---------------
|
|
320
|
+
- JSON array of message dicts → treated as OpenAI/AutoGen format
|
|
321
|
+
- JSON object with ``"messages"`` or ``"history"`` key → that list is extracted
|
|
322
|
+
- Any other JSON object → serialised as a single trace
|
|
323
|
+
"""
|
|
324
|
+
path = Path(path)
|
|
325
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
326
|
+
file_tags = (tags or []) + [f"file:{path.name}"]
|
|
327
|
+
|
|
328
|
+
if isinstance(raw, list):
|
|
329
|
+
return self.ingest_openai_messages(raw, tags=file_tags, as_single_trace=as_single_trace)
|
|
330
|
+
|
|
331
|
+
if isinstance(raw, dict):
|
|
332
|
+
for key in ("messages", "history", "conversation", "turns"):
|
|
333
|
+
if key in raw and isinstance(raw[key], list):
|
|
334
|
+
return self.ingest_openai_messages(
|
|
335
|
+
raw[key], tags=file_tags, as_single_trace=as_single_trace
|
|
336
|
+
)
|
|
337
|
+
# Fallback: serialise entire object as one trace
|
|
338
|
+
entry = self._make_entry(
|
|
339
|
+
content=json.dumps(raw, indent=2, default=str),
|
|
340
|
+
tags=file_tags,
|
|
341
|
+
source_format=MemSourceFormat.json_file,
|
|
342
|
+
raw_source={"file": str(path), "data": raw},
|
|
343
|
+
)
|
|
344
|
+
self._save()
|
|
345
|
+
return [entry]
|
|
346
|
+
|
|
347
|
+
# Unexpected shape — store as raw text
|
|
348
|
+
entry = self._make_entry(
|
|
349
|
+
content=str(raw),
|
|
350
|
+
tags=file_tags,
|
|
351
|
+
source_format=MemSourceFormat.json_file,
|
|
352
|
+
)
|
|
353
|
+
self._save()
|
|
354
|
+
return [entry]
|
|
355
|
+
|
|
356
|
+
def ingest_text_file(
|
|
357
|
+
self,
|
|
358
|
+
path: Path,
|
|
359
|
+
tags: Optional[list[str]] = None,
|
|
360
|
+
chunk_by: str = "none",
|
|
361
|
+
) -> list[MemEntry]:
|
|
362
|
+
"""Ingest a plain-text file.
|
|
363
|
+
|
|
364
|
+
Parameters
|
|
365
|
+
----------
|
|
366
|
+
chunk_by:
|
|
367
|
+
``"none"`` — store as one entry (default).
|
|
368
|
+
``"line"`` — one entry per non-empty line.
|
|
369
|
+
``"paragraph"`` — split on blank lines; one entry per paragraph.
|
|
370
|
+
"""
|
|
371
|
+
path = Path(path)
|
|
372
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
373
|
+
file_tags = (tags or []) + [f"file:{path.name}"]
|
|
374
|
+
created: list[MemEntry] = []
|
|
375
|
+
|
|
376
|
+
chunks: list[str] = []
|
|
377
|
+
if chunk_by == "line":
|
|
378
|
+
chunks = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
|
379
|
+
elif chunk_by == "paragraph":
|
|
380
|
+
chunks = [
|
|
381
|
+
p.strip()
|
|
382
|
+
for p in text.split("\n\n")
|
|
383
|
+
if p.strip()
|
|
384
|
+
]
|
|
385
|
+
else:
|
|
386
|
+
chunks = [text]
|
|
387
|
+
|
|
388
|
+
for chunk in chunks:
|
|
389
|
+
entry = self._make_entry(
|
|
390
|
+
content=chunk,
|
|
391
|
+
tags=file_tags,
|
|
392
|
+
source_format=MemSourceFormat.text_file,
|
|
393
|
+
raw_source={"file": str(path)},
|
|
394
|
+
)
|
|
395
|
+
created.append(entry)
|
|
396
|
+
|
|
397
|
+
self._save()
|
|
398
|
+
return created
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# Read / management API
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def list(self) -> list[MemEntry]:
|
|
405
|
+
return list(self._entries.values())
|
|
406
|
+
|
|
407
|
+
def get(self, mem_id: str) -> Optional[MemEntry]:
|
|
408
|
+
return self._entries.get(mem_id)
|
|
409
|
+
|
|
410
|
+
def mark_promoted(self, mem_id: str, target_id: str) -> None:
|
|
411
|
+
if mem_id in self._entries:
|
|
412
|
+
self._entries[mem_id].promoted = True
|
|
413
|
+
self._entries[mem_id].promotion_target_id = target_id
|
|
414
|
+
self._save()
|
|
415
|
+
|
|
416
|
+
def delete(self, mem_id: str) -> bool:
|
|
417
|
+
if mem_id not in self._entries:
|
|
418
|
+
return False
|
|
419
|
+
del self._entries[mem_id]
|
|
420
|
+
self._save()
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def list_sessions() -> list[str]:
|
|
425
|
+
return [p.stem for p in _MEMORY_DIR.glob("*.json")]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Internal helpers
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def _extract_content(m: Any) -> str:
|
|
433
|
+
"""Pull text content out of a message dict or object."""
|
|
434
|
+
if isinstance(m, dict):
|
|
435
|
+
content = m.get("content", "")
|
|
436
|
+
if isinstance(content, list):
|
|
437
|
+
# OpenAI vision-style: content is a list of parts
|
|
438
|
+
parts = [
|
|
439
|
+
p.get("text", "") if isinstance(p, dict) else str(p)
|
|
440
|
+
for p in content
|
|
441
|
+
]
|
|
442
|
+
return " ".join(p for p in parts if p).strip()
|
|
443
|
+
return str(content).strip()
|
|
444
|
+
if hasattr(m, "content"):
|
|
445
|
+
return str(m.content).strip()
|
|
446
|
+
return str(m).strip()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _langchain_to_dict(m: Any) -> dict[str, str]:
|
|
450
|
+
"""Normalise a LangChain message object to a plain dict."""
|
|
451
|
+
if isinstance(m, dict):
|
|
452
|
+
return {
|
|
453
|
+
"role": m.get("type", m.get("role", "unknown")),
|
|
454
|
+
"content": str(m.get("content", "")),
|
|
455
|
+
}
|
|
456
|
+
role = getattr(m, "type", None) or getattr(m, "role", "unknown")
|
|
457
|
+
content = getattr(m, "content", str(m))
|
|
458
|
+
return {"role": str(role), "content": str(content)}
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Embedding service for hybrid retrieval.
|
|
2
|
+
|
|
3
|
+
Pluggable via the ``Embedder`` protocol. Default backend is a local
|
|
4
|
+
sentence-transformers model (MiniLM, 384 dim, CPU). Override the model via
|
|
5
|
+
the ``KDG_EMBED_MODEL`` env var.
|
|
6
|
+
|
|
7
|
+
Failures (missing dependency, model download error, etc.) are logged once
|
|
8
|
+
and the embedder reports ``available=False`` so retrieval falls back to
|
|
9
|
+
keyword search rather than raising.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import Optional, Protocol
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_embedding_text(
|
|
23
|
+
title: str,
|
|
24
|
+
aliases: list[str],
|
|
25
|
+
tags: list[str],
|
|
26
|
+
content: str,
|
|
27
|
+
content_chars: int = 2000,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Canonical text representation used to embed an entry."""
|
|
30
|
+
parts = [title.strip()]
|
|
31
|
+
if aliases:
|
|
32
|
+
parts.append(" | ".join(a.strip() for a in aliases if a.strip()))
|
|
33
|
+
if tags:
|
|
34
|
+
parts.append("tags: " + ", ".join(t.strip() for t in tags if t.strip()))
|
|
35
|
+
body = (content or "").strip()
|
|
36
|
+
if body:
|
|
37
|
+
parts.append("")
|
|
38
|
+
parts.append(body[:content_chars])
|
|
39
|
+
return "\n".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def text_hash(text: str) -> str:
|
|
43
|
+
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Embedder(Protocol):
|
|
47
|
+
dim: int
|
|
48
|
+
available: bool
|
|
49
|
+
|
|
50
|
+
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _NullEmbedder:
|
|
54
|
+
"""Stand-in used when sentence-transformers is not installed."""
|
|
55
|
+
|
|
56
|
+
dim = 0
|
|
57
|
+
available = False
|
|
58
|
+
|
|
59
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
60
|
+
return [[] for _ in texts]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SentenceTransformerEmbedder:
|
|
64
|
+
"""Local CPU embedder using sentence-transformers."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, model_name: Optional[str] = None) -> None:
|
|
67
|
+
self.model_name = model_name or os.environ.get(
|
|
68
|
+
"KDG_EMBED_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
|
|
69
|
+
)
|
|
70
|
+
self._model = None
|
|
71
|
+
self._dim: Optional[int] = None
|
|
72
|
+
self.available = True # set to False on first failed load
|
|
73
|
+
|
|
74
|
+
def _load(self) -> None:
|
|
75
|
+
if self._model is not None or not self.available:
|
|
76
|
+
return
|
|
77
|
+
try:
|
|
78
|
+
from sentence_transformers import SentenceTransformer # type: ignore
|
|
79
|
+
except ImportError:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"sentence-transformers not installed; hybrid retrieval disabled. "
|
|
82
|
+
"Install with: pip install 'know-do-graph[embeddings]'"
|
|
83
|
+
)
|
|
84
|
+
self.available = False
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
self._model = SentenceTransformer(self.model_name)
|
|
88
|
+
self._dim = int(self._model.get_sentence_embedding_dimension())
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
logger.warning("Failed to load embedding model %s: %s", self.model_name, exc)
|
|
91
|
+
self.available = False
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def dim(self) -> int:
|
|
95
|
+
self._load()
|
|
96
|
+
return self._dim or 0
|
|
97
|
+
|
|
98
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
99
|
+
self._load()
|
|
100
|
+
if not self.available or self._model is None:
|
|
101
|
+
return [[] for _ in texts]
|
|
102
|
+
vecs = self._model.encode(
|
|
103
|
+
texts,
|
|
104
|
+
normalize_embeddings=True,
|
|
105
|
+
convert_to_numpy=True,
|
|
106
|
+
show_progress_bar=False,
|
|
107
|
+
)
|
|
108
|
+
return [v.tolist() for v in vecs]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_default: Optional[Embedder] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_default_embedder() -> Embedder:
|
|
115
|
+
"""Process-wide singleton embedder."""
|
|
116
|
+
global _default
|
|
117
|
+
if _default is None:
|
|
118
|
+
candidate = SentenceTransformerEmbedder()
|
|
119
|
+
# Trigger load eagerly so we know whether it works; downgrade to null if not.
|
|
120
|
+
_ = candidate.dim
|
|
121
|
+
_default = candidate if candidate.available else _NullEmbedder()
|
|
122
|
+
return _default
|
core/retrieval/fusion.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Ranking helpers: Reciprocal Rank Fusion and trust-score multipliers.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free so retrieval works without numpy.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
from typing import Iterable
|
|
10
|
+
|
|
11
|
+
from core.schemas.entry import VerificationStatus
|
|
12
|
+
|
|
13
|
+
# Multiplier applied to fused RRF scores based on verification status.
|
|
14
|
+
# Trusted entries float up, broken/deprecated ones sink — but nothing is hidden.
|
|
15
|
+
_TRUST_MULTIPLIER: dict[str, float] = {
|
|
16
|
+
VerificationStatus.community_tested.value: 1.30,
|
|
17
|
+
VerificationStatus.peer_reviewed.value: 1.15,
|
|
18
|
+
VerificationStatus.self_tested.value: 1.00,
|
|
19
|
+
VerificationStatus.unverified.value: 0.90,
|
|
20
|
+
VerificationStatus.bugged.value: 0.50,
|
|
21
|
+
VerificationStatus.deprecated.value: 0.30,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reciprocal_rank_fusion(
|
|
26
|
+
ranked_lists: Iterable[list[str]],
|
|
27
|
+
k: int = 60,
|
|
28
|
+
) -> dict[str, float]:
|
|
29
|
+
"""Standard RRF: each list contributes 1/(k + rank) per id (1-indexed)."""
|
|
30
|
+
scores: dict[str, float] = {}
|
|
31
|
+
for ranked in ranked_lists:
|
|
32
|
+
for rank, entry_id in enumerate(ranked, start=1):
|
|
33
|
+
scores[entry_id] = scores.get(entry_id, 0.0) + 1.0 / (k + rank)
|
|
34
|
+
return scores
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def trust_multiplier(verification_status: str, trust_score_override: float | None = None) -> float:
|
|
38
|
+
"""Pick the multiplier from explicit override (if set) or verification status."""
|
|
39
|
+
if trust_score_override is not None:
|
|
40
|
+
# Treat user-set trust_score as a direct multiplier, clamped to a sane range.
|
|
41
|
+
return max(0.1, min(2.0, float(trust_score_override)))
|
|
42
|
+
return _TRUST_MULTIPLIER.get(verification_status, 1.0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def usage_bump(usage_count: int) -> float:
|
|
46
|
+
"""Small log-scaled multiplier rewarding entries that have actually been used.
|
|
47
|
+
|
|
48
|
+
1 use → ×1.02, 10 uses → ×1.06, 100 uses → ×1.10, capped at ×1.15.
|
|
49
|
+
"""
|
|
50
|
+
if usage_count <= 0:
|
|
51
|
+
return 1.0
|
|
52
|
+
return min(1.15, 1.0 + 0.02 * math.log10(usage_count + 1) * 2)
|