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,243 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ from sqlalchemy.orm import Session
10
+
11
+ from core.schemas.edge import Edge
12
+ from core.schemas.entry import Entry
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _notify(event_type: str, data: dict) -> None:
18
+ """Best-effort SSE broadcast on graph mutations.
19
+
20
+ Safe to call from CLI processes (no event loop → silently no-ops) and from
21
+ API worker threads. Never raises.
22
+ """
23
+ try:
24
+ from core import events as _events
25
+ _events.emit(event_type, data)
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ def _unique_slug(db: Session, base_slug: str, entry_id: str) -> str:
31
+ from core.storage.models import EntryModel
32
+
33
+ def _taken(s: str) -> bool:
34
+ return (
35
+ db.query(EntryModel)
36
+ .filter(EntryModel.slug == s, EntryModel.id != entry_id)
37
+ .first()
38
+ is not None
39
+ )
40
+
41
+ if not _taken(base_slug):
42
+ return base_slug
43
+ for i in range(1, 1000):
44
+ candidate = f"{base_slug}-{i}"
45
+ if not _taken(candidate):
46
+ return candidate
47
+ return f"{base_slug}-{entry_id[:8]}"
48
+
49
+
50
+ def _refresh_embedding(db: Session, entry: Entry, model) -> None:
51
+ """Compute / refresh the embedding row for *entry* and stamp its hash.
52
+
53
+ Failures are logged and swallowed — embedding must never break writes.
54
+ """
55
+ from core.retrieval import vector_store
56
+ from core.retrieval.embedder import build_embedding_text, get_default_embedder, text_hash
57
+
58
+ try:
59
+ embedder = get_default_embedder()
60
+ if not embedder.available:
61
+ return
62
+ text = build_embedding_text(
63
+ title=entry.title,
64
+ aliases=entry.aliases,
65
+ tags=entry.tags,
66
+ content=entry.content,
67
+ )
68
+ new_hash = text_hash(text)
69
+ if model.embedding_hash == new_hash:
70
+ return
71
+ vec = embedder.embed([text])[0]
72
+ if not vec:
73
+ return
74
+ if vector_store.upsert(db, entry.id, vec):
75
+ model.embedding_hash = new_hash
76
+ db.commit()
77
+ except Exception as exc:
78
+ logger.warning("embedding refresh failed for %s: %s", entry.id, exc)
79
+ try:
80
+ db.rollback()
81
+ except Exception:
82
+ pass
83
+
84
+
85
+ class EntryRepository:
86
+ def __init__(self, db: Session) -> None:
87
+ self._db = db
88
+
89
+ def create(self, entry: Entry) -> Entry:
90
+ from core.storage.models import EntryModel
91
+
92
+ slug = _unique_slug(self._db, entry.slug or _slug(entry.title), entry.id)
93
+ model = EntryModel(
94
+ id=entry.id,
95
+ title=entry.title,
96
+ slug=slug,
97
+ entry_type=entry.entry_type.value,
98
+ content=entry.content,
99
+ tags=json.dumps(entry.tags),
100
+ aliases=json.dumps(entry.aliases),
101
+ metadata_json=json.dumps(entry.metadata.model_dump(mode="json")),
102
+ internal_refs=json.dumps(entry.internal_refs),
103
+ scripts_json=json.dumps([s.model_dump() for s in entry.scripts]),
104
+ assets_json=json.dumps([a.model_dump() for a in entry.assets]),
105
+ created_at=datetime.utcnow(),
106
+ updated_at=datetime.utcnow(),
107
+ )
108
+ self._db.add(model)
109
+ self._db.commit()
110
+ self._db.refresh(model)
111
+ saved = Entry(**model.to_dict())
112
+ _refresh_embedding(self._db, saved, model)
113
+ _notify("node_added", {"id": saved.id, "title": saved.title, "slug": saved.slug})
114
+ return saved
115
+
116
+ def update(self, entry: Entry) -> Optional[Entry]:
117
+ from core.storage.models import EntryModel
118
+
119
+ model = self._db.get(EntryModel, entry.id)
120
+ if not model:
121
+ return None
122
+ model.title = entry.title
123
+ model.slug = _unique_slug(self._db, entry.slug or _slug(entry.title), entry.id)
124
+ model.entry_type = entry.entry_type.value
125
+ model.content = entry.content
126
+ model.tags = json.dumps(entry.tags)
127
+ model.aliases = json.dumps(entry.aliases)
128
+ model.metadata_json = json.dumps(entry.metadata.model_dump(mode="json"))
129
+ model.internal_refs = json.dumps(entry.internal_refs)
130
+ model.scripts_json = json.dumps([s.model_dump() for s in entry.scripts])
131
+ model.assets_json = json.dumps([a.model_dump() for a in entry.assets])
132
+ model.updated_at = datetime.utcnow()
133
+ self._db.commit()
134
+ self._db.refresh(model)
135
+ saved = Entry(**model.to_dict())
136
+ _refresh_embedding(self._db, saved, model)
137
+ _notify("node_updated", {"id": saved.id, "title": saved.title, "slug": saved.slug})
138
+ return saved
139
+
140
+ def delete(self, entry_id: str) -> bool:
141
+ from core.retrieval import vector_store
142
+ from core.storage.models import EntryModel
143
+
144
+ model = self._db.get(EntryModel, entry_id)
145
+ if not model:
146
+ return False
147
+ self._db.delete(model)
148
+ self._db.commit()
149
+ vector_store.delete(self._db, entry_id)
150
+ _notify("node_removed", {"id": entry_id})
151
+ return True
152
+
153
+ def get_all(self) -> list[Entry]:
154
+ from core.storage.models import EntryModel
155
+
156
+ rows = self._db.query(EntryModel).all()
157
+ return [Entry(**row.to_dict()) for row in rows]
158
+
159
+
160
+ class EdgeRepository:
161
+ def __init__(self, db: Session) -> None:
162
+ self._db = db
163
+
164
+ def create(self, edge: Edge) -> Edge:
165
+ from core.storage.models import EdgeModel
166
+
167
+ # Skip duplicates (same source/target/relation)
168
+ existing = (
169
+ self._db.query(EdgeModel)
170
+ .filter_by(source_id=edge.source_id, target_id=edge.target_id, relation=edge.relation.value)
171
+ .first()
172
+ )
173
+ if existing:
174
+ return Edge(**existing.to_dict())
175
+
176
+ model = EdgeModel(
177
+ id=edge.id,
178
+ source_id=edge.source_id,
179
+ target_id=edge.target_id,
180
+ relation=edge.relation.value,
181
+ weight=edge.weight,
182
+ metadata_json=json.dumps(edge.metadata),
183
+ created_at=datetime.utcnow(),
184
+ )
185
+ self._db.add(model)
186
+ self._db.commit()
187
+ _notify("edge_added", {
188
+ "id": edge.id,
189
+ "source_id": edge.source_id,
190
+ "target_id": edge.target_id,
191
+ "relation": edge.relation.value,
192
+ })
193
+ return edge
194
+
195
+ def delete(self, edge_id: str) -> bool:
196
+ from core.storage.models import EdgeModel
197
+
198
+ model = self._db.get(EdgeModel, edge_id)
199
+ if not model:
200
+ return False
201
+ src, tgt, rel = model.source_id, model.target_id, model.relation
202
+ self._db.delete(model)
203
+ self._db.commit()
204
+ _notify("edge_removed", {"id": edge_id, "source_id": src, "target_id": tgt, "relation": rel})
205
+ return True
206
+
207
+ def get_all(self) -> list[Edge]:
208
+ from core.storage.models import EdgeModel
209
+
210
+ rows = self._db.query(EdgeModel).all()
211
+ return [Edge(**row.to_dict()) for row in rows]
212
+
213
+
214
+ _CHAR_SUBS: dict[str, str] = {
215
+ "Å": "angstrom",
216
+ "å": "angstrom",
217
+ "µ": "micro",
218
+ "μ": "micro",
219
+ "°": "deg",
220
+ "±": "plus-minus",
221
+ "×": "x",
222
+ "·": "-",
223
+ }
224
+
225
+
226
+ def _slug(title: str) -> str:
227
+ import unicodedata
228
+ for sym, replacement in _CHAR_SUBS.items():
229
+ title = title.replace(sym, f" {replacement} ")
230
+ parts: list[str] = []
231
+ for ch in unicodedata.normalize("NFKD", title):
232
+ if ch.isascii():
233
+ parts.append(ch)
234
+ elif unicodedata.combining(ch):
235
+ pass
236
+ else:
237
+ name = unicodedata.name(ch, "").lower()
238
+ parts.append(name.split()[-1] if name else "")
239
+ slug = "".join(parts).lower().strip()
240
+ slug = re.sub(r"[^\w\s-]", "", slug)
241
+ slug = re.sub(r"[\s_]+", "-", slug)
242
+ slug = re.sub(r"-+", "-", slug)
243
+ return slug.strip("-")
core/sync/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Remote-source sync subsystem.
2
+
3
+ Mirrors upstream files (SKILL.md, scripts, ...) into entry bodies without
4
+ needing an LLM. See :mod:`core.sync.remote_sync` for the public API.
5
+ """
6
+
7
+ from core.sync.remote_sync import ( # noqa: F401
8
+ SyncResult,
9
+ parse_github_url,
10
+ sync_entry,
11
+ sync_all_due,
12
+ run_periodic_sync,
13
+ )
14
+ from core.sync.autolink import ( # noqa: F401
15
+ AutoLinkResult,
16
+ auto_link_entry,
17
+ build_alias_index,
18
+ find_mentions,
19
+ parse_frontmatter,
20
+ )
core/sync/autolink.py ADDED
@@ -0,0 +1,301 @@
1
+ """Generic auto-linker: derives edges from raw text content.
2
+
3
+ Why this exists
4
+ ---------------
5
+ Live-mirrored content (SKILL.md, README.md, web pages) is written in prose,
6
+ not in our ``[[wikilink]]`` syntax — so the internal_refs extractor never fires
7
+ on it. Without help, those nodes look like isolated islands in the graph.
8
+
9
+ What this module does
10
+ ---------------------
11
+ 1. **Generic mention scan** (``find_mentions``) — given a content string and an
12
+ index of {alias_lower: entry_id}, finds word-boundary, case-insensitive
13
+ matches. Aliases of length < 3 are skipped to avoid noise.
14
+
15
+ 2. **YAML frontmatter enricher** (``parse_frontmatter`` +
16
+ ``enrich_from_frontmatter``) — for SKILL.md-style files that declare
17
+ structured metadata like::
18
+
19
+ ---
20
+ name: vasp
21
+ dependent_skills:
22
+ - dpdisp
23
+ ---
24
+
25
+ we promote those declarations into typed edges (``dependency``, etc.).
26
+
27
+ 3. **Public entry point** (``auto_link_entry``) — runs both scanners against
28
+ one entry, persists new edges via the supplied ``EdgeRepository`` (which
29
+ already dedups), and returns the list of newly-created edges.
30
+
31
+ Design notes
32
+ ------------
33
+ * The mirrored ``content`` is NEVER mutated — the upstream text stays pristine.
34
+ We only add edges. This avoids spurious diffs on every sync.
35
+ * Edges from generic mentions use ``relation=documents`` with ``weight=0.4``
36
+ so they visually rank below user-curated edges.
37
+ * Frontmatter-derived edges use the strongest matching relation
38
+ (``dependency`` for ``dependent_skills``, ``alternative_to`` for
39
+ ``alternatives``, etc.) at ``weight=0.9``.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ import re
45
+ from dataclasses import dataclass, field
46
+ from typing import Iterable
47
+
48
+ from core.schemas.edge import Edge, EdgeRelation
49
+ from core.schemas.entry import Entry
50
+ from core.storage.repository import EdgeRepository
51
+
52
+ log = logging.getLogger(__name__)
53
+
54
+ # Aliases shorter than this are too noisy ("ml", "md", "io" …).
55
+ _MIN_ALIAS_LEN = 3
56
+
57
+ # Very generic words we should NEVER treat as entry mentions, even if some
58
+ # entry happens to be titled that way.
59
+ _STOPWORDS = {
60
+ "agent", "agents", "tool", "tools", "skill", "skills", "workflow", "workflows",
61
+ "script", "scripts", "data", "model", "models", "code", "file", "files",
62
+ "task", "tasks", "input", "output", "result", "results", "test", "tests",
63
+ "main", "default", "config", "configuration", "setup", "example", "examples",
64
+ "readme", "guide", "doc", "docs", "documentation", "notes", "summary",
65
+ "user", "users", "system", "project", "repo", "repository", "package",
66
+ }
67
+
68
+ # Frontmatter keys → (EdgeRelation, weight). Keys are matched on their YAML name.
69
+ _FRONTMATTER_RELATIONS: dict[str, tuple[EdgeRelation, float]] = {
70
+ "dependent_skills": (EdgeRelation.dependency, 0.9),
71
+ "dependencies": (EdgeRelation.dependency, 0.9),
72
+ "depends_on": (EdgeRelation.dependency, 0.9),
73
+ "requires": (EdgeRelation.prerequisite, 0.9),
74
+ "prerequisites": (EdgeRelation.prerequisite, 0.9),
75
+ "uses": (EdgeRelation.uses, 0.8),
76
+ "alternatives": (EdgeRelation.alternative_to, 0.7),
77
+ "alternative_to": (EdgeRelation.alternative_to, 0.7),
78
+ "compatible_with": (EdgeRelation.compatible_with, 0.7),
79
+ "related": (EdgeRelation.related_workflow, 0.5),
80
+ }
81
+
82
+
83
+ # ──────────────────────────────────────────────────────────────────────────────
84
+ # Alias index
85
+ # ──────────────────────────────────────────────────────────────────────────────
86
+
87
+ @dataclass
88
+ class AliasIndex:
89
+ """Maps lowercase-alias → set of entry ids. One alias may resolve to many."""
90
+ by_alias: dict[str, set[str]] = field(default_factory=dict)
91
+ # Also keep slug → id for direct frontmatter lookups.
92
+ by_slug: dict[str, str] = field(default_factory=dict)
93
+
94
+ def add(self, alias: str, entry_id: str) -> None:
95
+ a = alias.strip().lower()
96
+ if len(a) < _MIN_ALIAS_LEN or a in _STOPWORDS:
97
+ return
98
+ self.by_alias.setdefault(a, set()).add(entry_id)
99
+
100
+
101
+ def build_alias_index(entries: Iterable[Entry]) -> AliasIndex:
102
+ """Build a lookup index over all entries' titles/aliases/slugs.
103
+
104
+ For mirrored skills with prefixed slugs (e.g. ``pfd-vasp``), we also index
105
+ the prefix-stripped variant (``vasp``) so the body of *other* sources can
106
+ point at the canonical mirror.
107
+ """
108
+ idx = AliasIndex()
109
+ for e in entries:
110
+ idx.by_slug[e.slug] = e.id
111
+ idx.add(e.title, e.id)
112
+ idx.add(e.slug, e.id)
113
+ for alias in (e.aliases or []):
114
+ idx.add(alias, e.id)
115
+ # Strip common namespace prefixes so "pfd-vasp" also matches "vasp".
116
+ for prefix in ("pfd-", "lammps-", "vasp-"):
117
+ if e.slug.startswith(prefix):
118
+ idx.add(e.slug[len(prefix):], e.id)
119
+ return idx
120
+
121
+
122
+ # ──────────────────────────────────────────────────────────────────────────────
123
+ # Generic mention scan
124
+ # ──────────────────────────────────────────────────────────────────────────────
125
+
126
+ def find_mentions(content: str, idx: AliasIndex, *, self_id: str | None = None) -> set[str]:
127
+ """Return ids of entries whose alias appears in ``content``.
128
+
129
+ Self-mentions are filtered out so an entry doesn't link to itself.
130
+ Ambiguous aliases (resolving to >1 id) are dropped — better to skip than
131
+ to create a wrong edge.
132
+ """
133
+ if not content:
134
+ return set()
135
+ text = content.lower()
136
+ hits: set[str] = set()
137
+ for alias, ids in idx.by_alias.items():
138
+ if len(ids) > 1:
139
+ continue # ambiguous — skip
140
+ # Word boundary that also treats '-' and '_' as separators.
141
+ if re.search(rf"(?<![\w\-]){re.escape(alias)}(?![\w\-])", text):
142
+ (target_id,) = tuple(ids)
143
+ if target_id != self_id:
144
+ hits.add(target_id)
145
+ return hits
146
+
147
+
148
+ # ──────────────────────────────────────────────────────────────────────────────
149
+ # YAML frontmatter
150
+ # ──────────────────────────────────────────────────────────────────────────────
151
+
152
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?\n)---\s*\n", re.DOTALL)
153
+
154
+
155
+ def parse_frontmatter(content: str) -> dict | None:
156
+ """Return parsed YAML frontmatter dict, or None.
157
+
158
+ Uses PyYAML if available, otherwise a small hand-rolled subset that handles
159
+ the shape we actually see in SKILL.md (``key: scalar`` and ``key:`` followed
160
+ by `` - item`` lists, plus nested ``metadata:`` block).
161
+
162
+ The nested ``metadata`` sub-dict — used by SKILL.md to hold structured
163
+ fields like ``dependent_skills`` — is flattened into the returned mapping
164
+ so callers don't have to special-case it.
165
+ """
166
+ m = _FRONTMATTER_RE.match(content or "")
167
+ if not m:
168
+ return None
169
+ body = m.group(1)
170
+ try:
171
+ import yaml # type: ignore
172
+ loaded = yaml.safe_load(body)
173
+ data = loaded if isinstance(loaded, dict) else None
174
+ except Exception:
175
+ data = _mini_yaml(body)
176
+ if data is None:
177
+ return None
178
+ # Promote nested metadata:{...} keys to the top level (parent wins on conflict
179
+ # so we never clobber an explicit top-level field).
180
+ nested = data.get("metadata")
181
+ if isinstance(nested, dict):
182
+ for k, v in nested.items():
183
+ data.setdefault(k, v)
184
+ return data
185
+
186
+
187
+ def _mini_yaml(text: str) -> dict:
188
+ """Tiny YAML subset parser for ``key: value`` and ``key:\\n - item`` lists.
189
+
190
+ Tracks indentation depth so nested dicts (``metadata:`` block) flatten into
191
+ the top-level result alongside their parent — good enough for our scan,
192
+ which only inspects known keys.
193
+ """
194
+ out: dict[str, object] = {}
195
+ current_key: str | None = None
196
+ current_list: list[str] | None = None
197
+ for raw in text.splitlines():
198
+ line = raw.rstrip()
199
+ if not line.strip() or line.lstrip().startswith("#"):
200
+ continue
201
+ stripped = line.lstrip(" ")
202
+ indent = len(line) - len(stripped)
203
+ if stripped.startswith("- ") and current_list is not None:
204
+ current_list.append(stripped[2:].strip().strip("'\""))
205
+ continue
206
+ if ":" in stripped:
207
+ key, _, val = stripped.partition(":")
208
+ key = key.strip()
209
+ val = val.strip()
210
+ if not val:
211
+ current_key = key
212
+ current_list = []
213
+ out[key] = current_list
214
+ else:
215
+ out[key] = val.strip("'\"")
216
+ current_list = None
217
+ # nested dicts under metadata: are intentionally flattened.
218
+ # Drop empty list entries we never populated.
219
+ return {k: v for k, v in out.items() if v not in ([], "", None)}
220
+
221
+
222
+ def enrich_from_frontmatter(
223
+ fm: dict, idx: AliasIndex, *, self_id: str | None
224
+ ) -> list[tuple[str, EdgeRelation, float]]:
225
+ """Translate frontmatter list-keys into (target_id, relation, weight) tuples."""
226
+ out: list[tuple[str, EdgeRelation, float]] = []
227
+ for key, (relation, weight) in _FRONTMATTER_RELATIONS.items():
228
+ raw = fm.get(key)
229
+ if not raw:
230
+ continue
231
+ values = raw if isinstance(raw, list) else [raw]
232
+ for v in values:
233
+ if not isinstance(v, str):
234
+ continue
235
+ name = v.strip().lower()
236
+ if not name:
237
+ continue
238
+ target = idx.by_slug.get(name)
239
+ if target is None:
240
+ # Try alias index for namespaced variants.
241
+ ids = idx.by_alias.get(name, set())
242
+ if len(ids) == 1:
243
+ (target,) = tuple(ids)
244
+ if target and target != self_id:
245
+ out.append((target, relation, weight))
246
+ return out
247
+
248
+
249
+ # ──────────────────────────────────────────────────────────────────────────────
250
+ # Public entry point
251
+ # ──────────────────────────────────────────────────────────────────────────────
252
+
253
+ @dataclass
254
+ class AutoLinkResult:
255
+ entry_id: str
256
+ mention_edges: int = 0
257
+ frontmatter_edges: int = 0
258
+
259
+ @property
260
+ def total(self) -> int:
261
+ return self.mention_edges + self.frontmatter_edges
262
+
263
+
264
+ def auto_link_entry(
265
+ entry: Entry,
266
+ all_entries: list[Entry],
267
+ edge_repo: EdgeRepository,
268
+ *,
269
+ enable_mentions: bool = True,
270
+ enable_frontmatter: bool = True,
271
+ ) -> AutoLinkResult:
272
+ """Run all enrichers against one entry and persist resulting edges."""
273
+ idx = build_alias_index(all_entries)
274
+ result = AutoLinkResult(entry_id=entry.id)
275
+
276
+ if enable_frontmatter:
277
+ fm = parse_frontmatter(entry.content or "")
278
+ if fm:
279
+ for target_id, relation, weight in enrich_from_frontmatter(fm, idx, self_id=entry.id):
280
+ edge_repo.create(Edge(
281
+ source_id=entry.id,
282
+ target_id=target_id,
283
+ relation=relation,
284
+ weight=weight,
285
+ metadata={"derived_by": "autolink.frontmatter"},
286
+ ))
287
+ result.frontmatter_edges += 1
288
+
289
+ if enable_mentions:
290
+ mentions = find_mentions(entry.content or "", idx, self_id=entry.id)
291
+ for target_id in mentions:
292
+ edge_repo.create(Edge(
293
+ source_id=entry.id,
294
+ target_id=target_id,
295
+ relation=EdgeRelation.documents,
296
+ weight=0.4,
297
+ metadata={"derived_by": "autolink.mention"},
298
+ ))
299
+ result.mention_edges += 1
300
+
301
+ return result