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,399 @@
1
+ """Progressive (staged) retrieval over the hierarchical skill memory.
2
+
3
+ Layers (see :class:`core.schemas.entry.SkillLevel`):
4
+ L1 — Capability (entry_type ∈ {capability, workflow})
5
+ L2 — Procedure (entry_type = procedure)
6
+ L3 — Heuristic (entry_type = heuristic)
7
+ L4 — Constraint (entry_type = constraint)
8
+
9
+ The motivation is to avoid dumping all paper knowledge as a single flat blob
10
+ into the agent's context. Typical flow::
11
+
12
+ goal
13
+ → ProgressiveRetriever.plan(goal) # L1 / L2 only
14
+ → execution
15
+ → verifier feedback or uncertainty
16
+ → ProgressiveRetriever.heuristics_for(skill) # L3 on demand
17
+ → ProgressiveRetriever.constraints_for(skill) # L4 on demand
18
+
19
+ This module is a thin layer on top of :class:`RetrievalEngine` — it reuses the
20
+ hybrid keyword+vector ranking and adds level/edge filters.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Optional
26
+
27
+ from sqlalchemy.orm import Session
28
+
29
+ from core.graph.graph import KnowDoGraph
30
+ from core.retrieval.retrieval import RetrievalEngine
31
+ from core.schemas.edge import EdgeRelation
32
+ from core.schemas.entry import (
33
+ DEFAULT_LEVEL_FOR_TYPE,
34
+ Entry,
35
+ EntryType,
36
+ SkillLevel,
37
+ implied_level,
38
+ )
39
+ from core.storage.models import EdgeModel, EntryModel
40
+
41
+ # Levels considered "planner context" (cheap to load up front).
42
+ _PLAN_LEVELS = {SkillLevel.L1, SkillLevel.L2}
43
+
44
+
45
+ class ProgressiveRetriever:
46
+ """Staged retrieval interface for the hierarchical operational memory."""
47
+
48
+ def __init__(self, db: Session, graph: KnowDoGraph) -> None:
49
+ self._db = db
50
+ self._graph = graph
51
+ self._engine = RetrievalEngine(db, graph)
52
+
53
+ # ------------------------------------------------------------------
54
+ # Stage 1 — planning context (L1 + L2)
55
+ # ------------------------------------------------------------------
56
+
57
+ def plan(
58
+ self,
59
+ goal: str,
60
+ k: int = 5,
61
+ mode: str = "hybrid",
62
+ include_l2: bool = True,
63
+ ) -> list[Entry]:
64
+ """Return planner-level candidates (L1 capabilities, optionally L2 procedures).
65
+
66
+ Heuristics (L3) and constraints (L4) are deliberately excluded — call
67
+ :meth:`heuristics_for` / :meth:`constraints_for` once a candidate is
68
+ selected.
69
+ """
70
+ allowed = {SkillLevel.L1, SkillLevel.L2} if include_l2 else {SkillLevel.L1}
71
+ # Pull a generous superset, then level-filter; we don't push the filter
72
+ # into SQL because skill_level lives in the metadata JSON blob.
73
+ candidates = self._engine.search_entries(query=goal, limit=max(k * 4, 20), mode=mode)
74
+ out: list[Entry] = []
75
+ for e in candidates:
76
+ if implied_level(e.entry_type, e.metadata.skill_level) in allowed:
77
+ out.append(e)
78
+ if len(out) >= k:
79
+ break
80
+ return out
81
+
82
+ # ------------------------------------------------------------------
83
+ # Stage 2 — heuristics (L3)
84
+ # ------------------------------------------------------------------
85
+
86
+ def heuristics_for(
87
+ self,
88
+ skill: str,
89
+ k: int = 5,
90
+ include_semantic_fallback: bool = True,
91
+ ) -> list[Entry]:
92
+ """Return L3 heuristics attached to *skill* (id, slug, or alias).
93
+
94
+ Resolution order:
95
+ 1. Nodes connected to *skill* by an inbound ``heuristic_for`` edge.
96
+ 2. (Fallback, optional) Semantic search restricted to L3 entries.
97
+ """
98
+ return self._sidecar_for(
99
+ skill=skill,
100
+ edge_relation=EdgeRelation.heuristic_for,
101
+ target_level=SkillLevel.L3,
102
+ target_entry_type=EntryType.heuristic,
103
+ k=k,
104
+ include_semantic_fallback=include_semantic_fallback,
105
+ )
106
+
107
+ # ------------------------------------------------------------------
108
+ # Stage 3 — constraints / failure modes (L4)
109
+ # ------------------------------------------------------------------
110
+
111
+ def constraints_for(
112
+ self,
113
+ skill: str,
114
+ k: int = 5,
115
+ include_semantic_fallback: bool = True,
116
+ ) -> list[Entry]:
117
+ """Return L4 constraints / failure modes attached to *skill*.
118
+
119
+ Resolution order:
120
+ 1. Nodes connected to *skill* by an inbound ``constraint_on`` or
121
+ ``warning_about`` edge.
122
+ 2. (Fallback, optional) Semantic search restricted to L4 entries.
123
+ """
124
+ return self._sidecar_for(
125
+ skill=skill,
126
+ edge_relation=EdgeRelation.constraint_on,
127
+ extra_edge_relations=(EdgeRelation.warning_about,),
128
+ target_level=SkillLevel.L4,
129
+ target_entry_type=EntryType.constraint,
130
+ k=k,
131
+ include_semantic_fallback=include_semantic_fallback,
132
+ )
133
+
134
+ # ------------------------------------------------------------------
135
+ # Stage 4 — bundle for verifier / debugging loop
136
+ # ------------------------------------------------------------------
137
+
138
+ def expand(
139
+ self,
140
+ skill: str,
141
+ stages: Optional[list[str]] = None,
142
+ k: int = 5,
143
+ ) -> dict:
144
+ """Return a bundle of additional context for an already-selected skill.
145
+
146
+ ``stages`` is a subset of {"heuristics", "constraints", "decomposition"}.
147
+ Defaults to ``["heuristics", "constraints"]``.
148
+ """
149
+ stages = stages or ["heuristics", "constraints"]
150
+ anchor = self._engine.resolve_identifier(skill)
151
+ if anchor is None:
152
+ return {"error": f"Skill '{skill}' not found.", "skill": skill}
153
+
154
+ bundle: dict = {
155
+ "skill": {
156
+ "id": anchor.id,
157
+ "slug": anchor.slug,
158
+ "title": anchor.title,
159
+ "level": (implied_level(anchor.entry_type, anchor.metadata.skill_level) or SkillLevel.L1).value,
160
+ },
161
+ }
162
+ if "heuristics" in stages:
163
+ bundle["heuristics"] = [self._summarize(e) for e in self.heuristics_for(anchor.id, k=k)]
164
+ if "constraints" in stages:
165
+ bundle["constraints"] = [self._summarize(e) for e in self.constraints_for(anchor.id, k=k)]
166
+ if "decomposition" in stages:
167
+ bundle["decomposition"] = [
168
+ self._summarize(e) for e in self._decomposition_for(anchor.id, k=k * 2)
169
+ ]
170
+ return bundle
171
+
172
+ # ==================================================================
173
+ # Internal helpers
174
+ # ==================================================================
175
+
176
+ def _sidecar_for(
177
+ self,
178
+ skill: str,
179
+ edge_relation: EdgeRelation,
180
+ target_level: SkillLevel,
181
+ target_entry_type: EntryType,
182
+ k: int,
183
+ include_semantic_fallback: bool,
184
+ extra_edge_relations: tuple[EdgeRelation, ...] = (),
185
+ ) -> list[Entry]:
186
+ anchor = self._engine.resolve_identifier(skill)
187
+ if anchor is None:
188
+ return []
189
+
190
+ # 1) Graph-attached sidecar nodes (authoritative).
191
+ seen: set[str] = set()
192
+ out: list[Entry] = []
193
+ relations = (edge_relation, *extra_edge_relations)
194
+ for source_id in self._inbound_sources(anchor.id, relations):
195
+ if source_id in seen:
196
+ continue
197
+ seen.add(source_id)
198
+ entry = self._engine.get_entry_by_id(source_id)
199
+ if entry is None:
200
+ continue
201
+ out.append(entry)
202
+
203
+ # 2) Semantic fallback restricted to the right level / type.
204
+ if include_semantic_fallback and len(out) < k:
205
+ need = k - len(out)
206
+ query = f"{anchor.title} {' '.join(anchor.tags)}".strip()
207
+ candidates = self._engine.search_entries(
208
+ query=query,
209
+ entry_type=target_entry_type,
210
+ limit=max(need * 4, 10),
211
+ mode="semantic",
212
+ )
213
+ for e in candidates:
214
+ if e.id in seen or e.id == anchor.id:
215
+ continue
216
+ if implied_level(e.entry_type, e.metadata.skill_level) != target_level:
217
+ continue
218
+ out.append(e)
219
+ seen.add(e.id)
220
+ if len(out) >= k:
221
+ break
222
+
223
+ return out[:k]
224
+
225
+ def _decomposition_for(self, skill_id: str, k: int) -> list[Entry]:
226
+ """Return L2 nodes connected via ``decomposes_to`` from *skill_id*.
227
+
228
+ Note: ``decomposes_to`` is recorded as source=L1, target=L2 (the
229
+ capability decomposes into procedure).
230
+ """
231
+ out: list[Entry] = []
232
+ seen: set[str] = set()
233
+ for edge in (
234
+ self._db.query(EdgeModel)
235
+ .filter(EdgeModel.source_id == skill_id, EdgeModel.relation == EdgeRelation.decomposes_to.value)
236
+ .limit(k * 4)
237
+ .all()
238
+ ):
239
+ if edge.target_id in seen:
240
+ continue
241
+ seen.add(edge.target_id)
242
+ entry = self._engine.get_entry_by_id(edge.target_id)
243
+ if entry is not None:
244
+ out.append(entry)
245
+ if len(out) >= k:
246
+ break
247
+ return out
248
+
249
+ def _inbound_sources(
250
+ self,
251
+ target_id: str,
252
+ relations: tuple[EdgeRelation, ...],
253
+ ) -> list[str]:
254
+ rel_values = [r.value for r in relations]
255
+ rows = (
256
+ self._db.query(EdgeModel)
257
+ .filter(EdgeModel.target_id == target_id, EdgeModel.relation.in_(rel_values))
258
+ .all()
259
+ )
260
+ return [r.source_id for r in rows]
261
+
262
+ # ------------------------------------------------------------------
263
+ # Cheap counts (used by /remote/entry to surface a progressive hint)
264
+ # ------------------------------------------------------------------
265
+
266
+ def count_attached(self, skill: str) -> dict:
267
+ """Return counts of L3 heuristics and L4 constraints **edge-attached** to
268
+ *skill* (id, slug, or alias).
269
+
270
+ This is a cheap probe — no entry bodies are loaded and no semantic
271
+ fallback is performed. It deliberately only counts nodes that are
272
+ explicitly connected to the current node via ``heuristic_for`` /
273
+ ``constraint_on`` / ``warning_about`` edges, so callers can prompt
274
+ the user / agent to drill down only when there is something to find.
275
+ """
276
+ anchor = self._engine.resolve_identifier(skill)
277
+ if anchor is None:
278
+ return {"resolved": False, "heuristics": 0, "constraints": 0}
279
+
280
+ heur = len(self._inbound_sources(anchor.id, (EdgeRelation.heuristic_for,)))
281
+ cons = len(
282
+ self._inbound_sources(
283
+ anchor.id,
284
+ (EdgeRelation.constraint_on, EdgeRelation.warning_about),
285
+ )
286
+ )
287
+ return {
288
+ "resolved": True,
289
+ "anchor_id": anchor.id,
290
+ "heuristics": heur,
291
+ "constraints": cons,
292
+ }
293
+
294
+ # ------------------------------------------------------------------
295
+ # Scoped search — search inside the L3/L4 sidecars of a single skill
296
+ # ------------------------------------------------------------------
297
+
298
+ # ``kind`` → (edge relations to follow inbound, expected target level)
299
+ _SIDECAR_KINDS: dict[str, tuple[tuple[EdgeRelation, ...], SkillLevel]] = {
300
+ "heuristics": ((EdgeRelation.heuristic_for,), SkillLevel.L3),
301
+ "constraints": (
302
+ (EdgeRelation.constraint_on, EdgeRelation.warning_about),
303
+ SkillLevel.L4,
304
+ ),
305
+ }
306
+
307
+ def search_attached(
308
+ self,
309
+ skill: str,
310
+ kind: str,
311
+ query: Optional[str] = None,
312
+ tags: Optional[list[str]] = None,
313
+ limit: int = 10,
314
+ mode: str = "hybrid",
315
+ ) -> tuple[list[Entry], int]:
316
+ """Search the L3/L4 sidecar nodes attached to *skill*.
317
+
318
+ Returns ``(entries, total_attached)`` where ``total_attached`` is the
319
+ size of the scope (useful for paginating / warning the caller when
320
+ the scope is large).
321
+
322
+ - ``kind`` is ``"heuristics"`` (L3) or ``"constraints"`` (L4).
323
+ - When ``query`` is given, runs the same hybrid keyword+vector search
324
+ as :meth:`RetrievalEngine.search_entries` but **restricts the
325
+ candidate pool to the attached sidecar nodes**.
326
+ - When ``query`` is None, returns up to ``limit`` of the attached
327
+ nodes ordered by ``usage_count`` desc so the most-used experience
328
+ / most-cited limitation surfaces first.
329
+
330
+ An L3/L4 node attached to multiple parents is unaffected — we scope
331
+ by inbound edges to *this* anchor, so the same node will correctly
332
+ appear under each parent it is attached to.
333
+ """
334
+ spec = self._SIDECAR_KINDS.get(kind)
335
+ if spec is None:
336
+ raise ValueError(f"Unknown sidecar kind: {kind!r}")
337
+ relations, _target_level = spec
338
+
339
+ anchor = self._engine.resolve_identifier(skill)
340
+ if anchor is None:
341
+ return [], 0
342
+
343
+ scope_ids = set(self._inbound_sources(anchor.id, relations))
344
+ total = len(scope_ids)
345
+ if not scope_ids:
346
+ return [], 0
347
+
348
+ # No query → return a usage-ranked slice of the scope. Cheap path
349
+ # that never loads the whole scope when it's large.
350
+ if not query:
351
+ rows = (
352
+ self._db.query(EntryModel)
353
+ .filter(EntryModel.id.in_(scope_ids))
354
+ .all()
355
+ )
356
+ entries = [Entry(**r.to_dict()) for r in rows]
357
+ entries = self._filter_by_tags(entries, tags)
358
+ entries.sort(
359
+ key=lambda e: (e.metadata.usage_count or 0),
360
+ reverse=True,
361
+ )
362
+ return entries[:limit], total
363
+
364
+ # With query → hybrid search, then intersect with scope. We over-fetch
365
+ # so the post-filter still has room to return ``limit`` items.
366
+ oversample = max(limit * 10, 50)
367
+ ranked = self._engine.search_entries(
368
+ query=query,
369
+ tags=tags,
370
+ limit=oversample,
371
+ mode=mode,
372
+ )
373
+ scoped = [e for e in ranked if e.id in scope_ids]
374
+ return scoped[:limit], total
375
+
376
+ @staticmethod
377
+ def _filter_by_tags(entries: list[Entry], tags: Optional[list[str]]) -> list[Entry]:
378
+ if not tags:
379
+ return entries
380
+ wanted = {t.lower() for t in tags}
381
+ return [e for e in entries if any(t.lower() in wanted for t in (e.tags or []))]
382
+
383
+
384
+ @staticmethod
385
+ def _summarize(entry: Entry) -> dict:
386
+ level = implied_level(entry.entry_type, entry.metadata.skill_level)
387
+ return {
388
+ "id": entry.id,
389
+ "slug": entry.slug,
390
+ "title": entry.title,
391
+ "entry_type": entry.entry_type.value,
392
+ "level": level.value if level else None,
393
+ "tags": entry.tags,
394
+ "content": entry.content,
395
+ "applicability": entry.metadata.applicability,
396
+ }
397
+
398
+
399
+ __all__ = ["ProgressiveRetriever"]