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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import or_
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
|
|
8
|
+
from core.graph.graph import KnowDoGraph
|
|
9
|
+
from core.retrieval import vector_store
|
|
10
|
+
from core.retrieval.embedder import build_embedding_text, get_default_embedder
|
|
11
|
+
from core.retrieval.fusion import reciprocal_rank_fusion, trust_multiplier, usage_bump
|
|
12
|
+
from core.schemas.edge import Edge, EdgeRelation
|
|
13
|
+
from core.schemas.entry import Entry, EntryType
|
|
14
|
+
from core.storage.models import EdgeModel, EntryModel
|
|
15
|
+
|
|
16
|
+
_STOP_WORDS = {
|
|
17
|
+
"a", "an", "the", "of", "in", "on", "at", "to", "for", "and", "or", "is", "are", "be",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Bidirectional synonym groups — each list is a set of interchangeable terms.
|
|
21
|
+
# When any member appears as a query token the others are added automatically.
|
|
22
|
+
_SYNONYM_GROUPS: list[list[str]] = [
|
|
23
|
+
["cnt", "nanotube", "carbon-nanotube", "carbon nanotube"],
|
|
24
|
+
["cnt", "carbon tube", "tube"],
|
|
25
|
+
["filled tube", "filled cnt", "filled nanotube", "confined"],
|
|
26
|
+
["ase", "atomic simulation environment"],
|
|
27
|
+
["slab", "surface slab", "surface"],
|
|
28
|
+
["interface", "heterostructure", "film substrate"],
|
|
29
|
+
["supercell", "super cell", "expansion"],
|
|
30
|
+
["lattice matching", "zsl", "coherent interface"],
|
|
31
|
+
["methane", "ch4"],
|
|
32
|
+
["dft", "density functional theory"],
|
|
33
|
+
["md", "molecular dynamics"],
|
|
34
|
+
["mlip", "mace", "machine learning potential", "interatomic potential"],
|
|
35
|
+
["crystal", "bulk crystal", "bulk structure"],
|
|
36
|
+
["nanoparticle", "nano particle", "nanostructure"],
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Build a fast lookup: token → set of synonym tokens (excluding itself)
|
|
40
|
+
_SYNONYM_MAP: dict[str, set[str]] = {}
|
|
41
|
+
for _group in _SYNONYM_GROUPS:
|
|
42
|
+
for _term in _group:
|
|
43
|
+
others = {t for t in _group if t != _term}
|
|
44
|
+
_SYNONYM_MAP.setdefault(_term, set()).update(others)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _expand_tokens(tokens: list[str]) -> list[str]:
|
|
48
|
+
"""Return *tokens* plus any synonyms, deduped, preserving order."""
|
|
49
|
+
seen: set[str] = set(tokens)
|
|
50
|
+
expanded = list(tokens)
|
|
51
|
+
for tok in tokens:
|
|
52
|
+
for syn in _SYNONYM_MAP.get(tok, ()):
|
|
53
|
+
if syn not in seen:
|
|
54
|
+
seen.add(syn)
|
|
55
|
+
expanded.append(syn)
|
|
56
|
+
return expanded
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RetrievalEngine:
|
|
60
|
+
"""Search and graph-traversal interface over the persisted graph."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, db: Session, graph: KnowDoGraph) -> None:
|
|
63
|
+
self._db = db
|
|
64
|
+
self._graph = graph
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------
|
|
67
|
+
# Entry lookups
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def get_entry_by_id(self, entry_id: str) -> Optional[Entry]:
|
|
71
|
+
row = self._db.get(EntryModel, entry_id)
|
|
72
|
+
return Entry(**row.to_dict()) if row else None
|
|
73
|
+
|
|
74
|
+
def get_entry_by_slug(self, slug: str) -> Optional[Entry]:
|
|
75
|
+
row = self._db.query(EntryModel).filter_by(slug=slug).first()
|
|
76
|
+
return Entry(**row.to_dict()) if row else None
|
|
77
|
+
|
|
78
|
+
def get_entry_by_alias(self, alias: str) -> Optional[Entry]:
|
|
79
|
+
"""Return the first entry whose aliases list contains *alias* (case-insensitive)."""
|
|
80
|
+
alias_lower = alias.lower()
|
|
81
|
+
rows = (
|
|
82
|
+
self._db.query(EntryModel)
|
|
83
|
+
.filter(EntryModel.aliases.ilike(f"%{alias_lower}%"))
|
|
84
|
+
.all()
|
|
85
|
+
)
|
|
86
|
+
for row in rows:
|
|
87
|
+
entry = Entry(**row.to_dict())
|
|
88
|
+
if any(a.lower() == alias_lower for a in entry.aliases):
|
|
89
|
+
return entry
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def resolve_identifier(self, identifier: str) -> Optional[Entry]:
|
|
93
|
+
"""Try ID → slug → alias in order and return the first match."""
|
|
94
|
+
return (
|
|
95
|
+
self.get_entry_by_id(identifier)
|
|
96
|
+
or self.get_entry_by_slug(identifier)
|
|
97
|
+
or self.get_entry_by_alias(identifier)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def list_entries(self, limit: int = 50, offset: int = 0) -> list[Entry]:
|
|
101
|
+
rows = self._db.query(EntryModel).offset(offset).limit(limit).all()
|
|
102
|
+
return [Entry(**row.to_dict()) for row in rows]
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Hybrid search
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def search_entries(
|
|
109
|
+
self,
|
|
110
|
+
query: Optional[str] = None,
|
|
111
|
+
tags: Optional[list[str]] = None,
|
|
112
|
+
entry_type: Optional[EntryType] = None,
|
|
113
|
+
limit: int = 20,
|
|
114
|
+
mode: str = "hybrid",
|
|
115
|
+
) -> list[Entry]:
|
|
116
|
+
return [e for _, e in self._search_impl(query, tags, entry_type, limit, mode)]
|
|
117
|
+
|
|
118
|
+
def search_entries_scored(
|
|
119
|
+
self,
|
|
120
|
+
query: Optional[str] = None,
|
|
121
|
+
tags: Optional[list[str]] = None,
|
|
122
|
+
entry_type: Optional[EntryType] = None,
|
|
123
|
+
limit: int = 20,
|
|
124
|
+
mode: str = "hybrid",
|
|
125
|
+
) -> list[tuple[Entry, float]]:
|
|
126
|
+
"""Like search_entries but returns (entry, score) with scores normalized 0.0–1.0."""
|
|
127
|
+
raw = self._search_impl(query, tags, entry_type, limit, mode)
|
|
128
|
+
if not raw:
|
|
129
|
+
return []
|
|
130
|
+
max_score = max(s for s, _ in raw) or 1.0
|
|
131
|
+
return [(e, s / max_score) for s, e in raw]
|
|
132
|
+
|
|
133
|
+
def _search_impl(
|
|
134
|
+
self,
|
|
135
|
+
query: Optional[str],
|
|
136
|
+
tags: Optional[list[str]],
|
|
137
|
+
entry_type: Optional[EntryType],
|
|
138
|
+
limit: int,
|
|
139
|
+
mode: str = "hybrid",
|
|
140
|
+
) -> list[tuple[Entry, float]]:
|
|
141
|
+
"""Retrieval with three modes:
|
|
142
|
+
- "hybrid": keyword + vector ANN fused with RRF (default)
|
|
143
|
+
- "semantic": vector-only ANN (best for conceptually similar queries)
|
|
144
|
+
- "keyword": keyword-only (best for exact title/tag/acronym lookups)
|
|
145
|
+
|
|
146
|
+
Falls back gracefully:
|
|
147
|
+
- No query → filter-only listing, scores all 0.
|
|
148
|
+
- No embedder / no vec index → pure keyword path regardless of mode.
|
|
149
|
+
"""
|
|
150
|
+
if not query:
|
|
151
|
+
return [(0.0, e) for e in self._filter_only(tags=tags, entry_type=entry_type, limit=limit)]
|
|
152
|
+
|
|
153
|
+
entries_by_id: dict[str, Entry] = {}
|
|
154
|
+
|
|
155
|
+
# Channel A — keyword scorer (skipped in semantic mode).
|
|
156
|
+
keyword_ranked: list[str] = []
|
|
157
|
+
if mode != "semantic":
|
|
158
|
+
keyword_hits = self._keyword_search(
|
|
159
|
+
query=query, tags=tags, entry_type=entry_type, limit=200
|
|
160
|
+
)
|
|
161
|
+
keyword_ranked = [eid for eid, _ in keyword_hits]
|
|
162
|
+
entries_by_id.update({eid: e for eid, _, e in self._with_entries(keyword_hits)})
|
|
163
|
+
|
|
164
|
+
# Channel B — vector ANN (skipped in keyword mode).
|
|
165
|
+
vector_ranked: list[str] = []
|
|
166
|
+
embedder = get_default_embedder()
|
|
167
|
+
if mode != "keyword" and embedder.available:
|
|
168
|
+
qvec = embedder.embed([query])[0]
|
|
169
|
+
for eid, _dist in vector_store.knn(self._db, qvec, k=50):
|
|
170
|
+
if eid in entries_by_id:
|
|
171
|
+
vector_ranked.append(eid)
|
|
172
|
+
continue
|
|
173
|
+
row = self._db.get(EntryModel, eid)
|
|
174
|
+
if row is None:
|
|
175
|
+
continue
|
|
176
|
+
d = row.to_dict()
|
|
177
|
+
if entry_type and d.get("entry_type") != entry_type.value:
|
|
178
|
+
continue
|
|
179
|
+
if tags and not any(t in d["tags"] for t in tags):
|
|
180
|
+
continue
|
|
181
|
+
entries_by_id[eid] = Entry(**d)
|
|
182
|
+
vector_ranked.append(eid)
|
|
183
|
+
|
|
184
|
+
# Fuse + rerank.
|
|
185
|
+
fused = reciprocal_rank_fusion([keyword_ranked, vector_ranked])
|
|
186
|
+
if not fused:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
scored: list[tuple[float, Entry]] = []
|
|
190
|
+
for eid, base in fused.items():
|
|
191
|
+
entry = entries_by_id.get(eid)
|
|
192
|
+
if entry is None:
|
|
193
|
+
continue
|
|
194
|
+
meta = entry.metadata
|
|
195
|
+
mult = trust_multiplier(
|
|
196
|
+
meta.verification_status.value,
|
|
197
|
+
trust_score_override=meta.trust_score,
|
|
198
|
+
) * usage_bump(meta.usage_count or 0)
|
|
199
|
+
scored.append((base * mult, entry))
|
|
200
|
+
|
|
201
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
202
|
+
return scored[:limit]
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Channel implementations
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _filter_only(
|
|
209
|
+
self,
|
|
210
|
+
tags: Optional[list[str]],
|
|
211
|
+
entry_type: Optional[EntryType],
|
|
212
|
+
limit: int,
|
|
213
|
+
) -> list[Entry]:
|
|
214
|
+
q = self._db.query(EntryModel)
|
|
215
|
+
if entry_type:
|
|
216
|
+
q = q.filter(EntryModel.entry_type == entry_type.value)
|
|
217
|
+
rows = q.limit(500).all()
|
|
218
|
+
out: list[Entry] = []
|
|
219
|
+
for row in rows:
|
|
220
|
+
d = row.to_dict()
|
|
221
|
+
if tags and not any(t in d["tags"] for t in tags):
|
|
222
|
+
continue
|
|
223
|
+
out.append(Entry(**d))
|
|
224
|
+
if len(out) >= limit:
|
|
225
|
+
break
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
def _keyword_search(
|
|
229
|
+
self,
|
|
230
|
+
query: str,
|
|
231
|
+
tags: Optional[list[str]],
|
|
232
|
+
entry_type: Optional[EntryType],
|
|
233
|
+
limit: int,
|
|
234
|
+
) -> list[tuple[str, int]]:
|
|
235
|
+
"""Returns ``[(entry_id, raw_score), ...]`` sorted by descending score.
|
|
236
|
+
|
|
237
|
+
Same scoring formula as before — title 10 / alias 5 / tag 3 / content 1.
|
|
238
|
+
"""
|
|
239
|
+
q = self._db.query(EntryModel)
|
|
240
|
+
if entry_type:
|
|
241
|
+
q = q.filter(EntryModel.entry_type == entry_type.value)
|
|
242
|
+
|
|
243
|
+
tokens = [
|
|
244
|
+
t for t in query.lower().split()
|
|
245
|
+
if len(t) > 2 and t not in _STOP_WORDS
|
|
246
|
+
]
|
|
247
|
+
if not tokens:
|
|
248
|
+
tokens = [query.lower()]
|
|
249
|
+
|
|
250
|
+
# Expand tokens with domain synonyms so e.g. "CNT" also finds "nanotube"
|
|
251
|
+
tokens = _expand_tokens(tokens)
|
|
252
|
+
|
|
253
|
+
token_filters = []
|
|
254
|
+
for token in tokens:
|
|
255
|
+
tl = f"%{token}%"
|
|
256
|
+
token_filters.append(
|
|
257
|
+
or_(
|
|
258
|
+
EntryModel.title.ilike(tl),
|
|
259
|
+
EntryModel.content.ilike(tl),
|
|
260
|
+
EntryModel.aliases.ilike(tl),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
q = q.filter(or_(*token_filters))
|
|
264
|
+
|
|
265
|
+
rows = q.limit(500).all()
|
|
266
|
+
|
|
267
|
+
scored: list[tuple[int, str]] = []
|
|
268
|
+
for row in rows:
|
|
269
|
+
d = row.to_dict()
|
|
270
|
+
if tags and not any(t in d["tags"] for t in tags):
|
|
271
|
+
continue
|
|
272
|
+
title = (d.get("title") or "").lower()
|
|
273
|
+
aliases = str(d.get("aliases") or "").lower()
|
|
274
|
+
tags_str = str(d.get("tags") or "").lower()
|
|
275
|
+
content = (d.get("content") or "").lower()
|
|
276
|
+
score = 0
|
|
277
|
+
for token in tokens:
|
|
278
|
+
if token in title:
|
|
279
|
+
score += 10
|
|
280
|
+
if token in aliases:
|
|
281
|
+
score += 5
|
|
282
|
+
if token in tags_str:
|
|
283
|
+
score += 3
|
|
284
|
+
if token in content and token not in title:
|
|
285
|
+
score += 1
|
|
286
|
+
scored.append((score, d["id"]))
|
|
287
|
+
|
|
288
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
289
|
+
return [(eid, s) for s, eid in scored[:limit]]
|
|
290
|
+
|
|
291
|
+
def _with_entries(
|
|
292
|
+
self, hits: list[tuple[str, int]]
|
|
293
|
+
) -> list[tuple[str, int, Entry]]:
|
|
294
|
+
out: list[tuple[str, int, Entry]] = []
|
|
295
|
+
for eid, score in hits:
|
|
296
|
+
row = self._db.get(EntryModel, eid)
|
|
297
|
+
if row is None:
|
|
298
|
+
continue
|
|
299
|
+
out.append((eid, score, Entry(**row.to_dict())))
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Edge lookups
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def get_edges_for_entry(self, entry_id: str) -> list[Edge]:
|
|
307
|
+
rows = (
|
|
308
|
+
self._db.query(EdgeModel)
|
|
309
|
+
.filter(
|
|
310
|
+
(EdgeModel.source_id == entry_id)
|
|
311
|
+
| (EdgeModel.target_id == entry_id)
|
|
312
|
+
)
|
|
313
|
+
.all()
|
|
314
|
+
)
|
|
315
|
+
return [Edge(**row.to_dict()) for row in rows]
|
|
316
|
+
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
# Graph traversal
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
def get_related_entries(
|
|
322
|
+
self,
|
|
323
|
+
entry_id: str,
|
|
324
|
+
depth: int = 1,
|
|
325
|
+
relation: Optional[EdgeRelation] = None,
|
|
326
|
+
) -> list[Entry]:
|
|
327
|
+
related_ids = self._graph.get_related_ids(entry_id, depth=depth, relation=relation)
|
|
328
|
+
entries: list[Entry] = []
|
|
329
|
+
for rid in related_ids:
|
|
330
|
+
entry = self.get_entry_by_id(rid)
|
|
331
|
+
if entry:
|
|
332
|
+
entries.append(entry)
|
|
333
|
+
return entries
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
# Embedding maintenance — used by the backfill CLI
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def embedding_text_for(entry: Entry) -> str:
|
|
341
|
+
return build_embedding_text(
|
|
342
|
+
title=entry.title,
|
|
343
|
+
aliases=entry.aliases,
|
|
344
|
+
tags=entry.tags,
|
|
345
|
+
content=entry.content,
|
|
346
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Thin wrapper around the sqlite-vec `entry_embeddings` virtual table.
|
|
2
|
+
|
|
3
|
+
All operations are no-ops when sqlite-vec is not loaded — callers can use
|
|
4
|
+
the wrapper unconditionally and let it degrade silently. The virtual table
|
|
5
|
+
itself is created in ``core.storage.database.init_db``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import struct
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import text
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _pack(vec: list[float]) -> bytes:
|
|
21
|
+
"""sqlite-vec accepts vectors as little-endian float32 blobs."""
|
|
22
|
+
return struct.pack(f"<{len(vec)}f", *vec)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _table_available(db: Session) -> bool:
|
|
26
|
+
"""Cheap probe — returns True iff the vec0 virtual table is queryable."""
|
|
27
|
+
try:
|
|
28
|
+
db.execute(text("SELECT entry_id FROM entry_embeddings LIMIT 0"))
|
|
29
|
+
return True
|
|
30
|
+
except Exception:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def upsert(db: Session, entry_id: str, vec: list[float]) -> bool:
|
|
35
|
+
if not vec or not _table_available(db):
|
|
36
|
+
return False
|
|
37
|
+
try:
|
|
38
|
+
blob = _pack(vec)
|
|
39
|
+
db.execute(text("DELETE FROM entry_embeddings WHERE entry_id = :id"), {"id": entry_id})
|
|
40
|
+
db.execute(
|
|
41
|
+
text("INSERT INTO entry_embeddings (entry_id, embedding) VALUES (:id, :v)"),
|
|
42
|
+
{"id": entry_id, "v": blob},
|
|
43
|
+
)
|
|
44
|
+
db.commit()
|
|
45
|
+
return True
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
logger.warning("vector upsert failed for %s: %s", entry_id, exc)
|
|
48
|
+
db.rollback()
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delete(db: Session, entry_id: str) -> None:
|
|
53
|
+
if not _table_available(db):
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
db.execute(text("DELETE FROM entry_embeddings WHERE entry_id = :id"), {"id": entry_id})
|
|
57
|
+
db.commit()
|
|
58
|
+
except Exception:
|
|
59
|
+
db.rollback()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def knn(db: Session, vec: list[float], k: int = 50) -> list[tuple[str, float]]:
|
|
63
|
+
"""Return ``[(entry_id, distance), ...]`` ordered by ascending distance.
|
|
64
|
+
|
|
65
|
+
Empty list if the index is unavailable or empty.
|
|
66
|
+
"""
|
|
67
|
+
if not vec or not _table_available(db):
|
|
68
|
+
return []
|
|
69
|
+
try:
|
|
70
|
+
blob = _pack(vec)
|
|
71
|
+
rows = db.execute(
|
|
72
|
+
text(
|
|
73
|
+
"SELECT entry_id, distance FROM entry_embeddings "
|
|
74
|
+
"WHERE embedding MATCH :v AND k = :k "
|
|
75
|
+
"ORDER BY distance"
|
|
76
|
+
),
|
|
77
|
+
{"v": blob, "k": int(k)},
|
|
78
|
+
).all()
|
|
79
|
+
return [(r[0], float(r[1])) for r in rows]
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
logger.warning("vector knn query failed: %s", exc)
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def count(db: Session) -> Optional[int]:
|
|
86
|
+
if not _table_available(db):
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return int(db.execute(text("SELECT COUNT(*) FROM entry_embeddings")).scalar() or 0)
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
core/schemas/__init__.py
ADDED
|
File without changes
|
core/schemas/edge.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EdgeRelation(str, Enum):
|
|
11
|
+
dependency = "dependency"
|
|
12
|
+
compatible_with = "compatible_with"
|
|
13
|
+
alternative_to = "alternative_to"
|
|
14
|
+
related_workflow = "related_workflow"
|
|
15
|
+
generated_from = "generated_from"
|
|
16
|
+
memory_of = "memory_of"
|
|
17
|
+
refinement_of = "refinement_of"
|
|
18
|
+
derived_from = "derived_from"
|
|
19
|
+
warning_about = "warning_about"
|
|
20
|
+
cited_by = "cited_by"
|
|
21
|
+
wikilink = "wikilink"
|
|
22
|
+
prerequisite = "prerequisite"
|
|
23
|
+
replacement = "replacement"
|
|
24
|
+
execution_pathway = "execution_pathway"
|
|
25
|
+
transformation = "transformation"
|
|
26
|
+
provenance = "provenance"
|
|
27
|
+
compatibility = "compatibility"
|
|
28
|
+
implements = "implements"
|
|
29
|
+
uses = "uses"
|
|
30
|
+
documents = "documents"
|
|
31
|
+
# Hierarchical-memory edges (progressive disclosure).
|
|
32
|
+
# Direction convention is always *from child detail → parent skill*,
|
|
33
|
+
# so that traversal "out of" a planner-level node yields its details.
|
|
34
|
+
decomposes_to = "decomposes_to" # L1 → L2 (capability decomposed into procedure)
|
|
35
|
+
heuristic_for = "heuristic_for" # L3 → L1/L2 (heuristic attached to a skill)
|
|
36
|
+
constraint_on = "constraint_on" # L4 → L1/L2 (failure-mode attached to a skill)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Edge(BaseModel):
|
|
40
|
+
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
41
|
+
source_id: str
|
|
42
|
+
target_id: str
|
|
43
|
+
relation: EdgeRelation = EdgeRelation.wikilink
|
|
44
|
+
weight: float = 1.0
|
|
45
|
+
metadata: dict = Field(default_factory=dict)
|
|
46
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|