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.
Files changed (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. 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
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))