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,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
|