okfgraph 0.2.4__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.
- okfgraph/__init__.py +7 -0
- okfgraph/cli.py +1364 -0
- okfgraph/components/__init__.py +61 -0
- okfgraph/components/converters.py +128 -0
- okfgraph/components/delta.py +271 -0
- okfgraph/components/diff.py +172 -0
- okfgraph/components/doctor.py +216 -0
- okfgraph/components/embedding.py +323 -0
- okfgraph/components/export.py +354 -0
- okfgraph/components/image_assets.py +319 -0
- okfgraph/components/import_.py +1110 -0
- okfgraph/components/ingest.py +495 -0
- okfgraph/components/links.py +133 -0
- okfgraph/components/lint.py +118 -0
- okfgraph/components/purge.py +342 -0
- okfgraph/components/ranking.py +168 -0
- okfgraph/components/schema.py +443 -0
- okfgraph/components/search.py +1006 -0
- okfgraph/config.py +393 -0
- okfgraph/images.py +435 -0
- okfgraph/mcp_server.py +461 -0
- okfgraph/models.py +128 -0
- okfgraph/router.py +485 -0
- okfgraph/security.py +269 -0
- okfgraph/tools.py +460 -0
- okfgraph-0.2.4.dist-info/METADATA +25 -0
- okfgraph-0.2.4.dist-info/RECORD +31 -0
- okfgraph-0.2.4.dist-info/WHEEL +5 -0
- okfgraph-0.2.4.dist-info/entry_points.txt +3 -0
- okfgraph-0.2.4.dist-info/licenses/LICENSE +6 -0
- okfgraph-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import heapq
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from datetime import datetime, timezone, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, Set
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
import mordant
|
|
20
|
+
import numpy as np
|
|
21
|
+
import yaml
|
|
22
|
+
import frontmatter
|
|
23
|
+
from okfgraph.models import ChunkModel, ConceptModel
|
|
24
|
+
from okfgraph.images import IngestMode
|
|
25
|
+
from okfgraph.components.links import (
|
|
26
|
+
build_name_index,
|
|
27
|
+
extract_md_links,
|
|
28
|
+
extract_wikilinks,
|
|
29
|
+
is_external,
|
|
30
|
+
normalize_path_link,
|
|
31
|
+
resolve_wiki,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
#: Bulk import skips these filenames: generated navigation files are graph
|
|
37
|
+
#: noise (self-linking ``*/index`` nodes that dilute PPR), never knowledge.
|
|
38
|
+
#: Explicit single-file ``import <path>/index.md`` still works — explicit
|
|
39
|
+
#: beats implicit. Mirrors google-okf's ``RESERVED_FILENAMES``.
|
|
40
|
+
RESERVED_FILENAMES = frozenset({"index.md"})
|
|
41
|
+
|
|
42
|
+
#: Extensions import (and diff/delta/lint) treat as concept sources.
|
|
43
|
+
SOURCE_EXTS = (".md", ".markdown", ".txt")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_concept_file(fp: Path) -> bool:
|
|
47
|
+
"""True when ``fp`` is a bulk-importable concept file.
|
|
48
|
+
|
|
49
|
+
Single choke point shared by import, diff, delta, and lint so all agree
|
|
50
|
+
on what a "concept file" is (drift mode would otherwise flag every
|
|
51
|
+
exported bundle as changed: import skips index.md, diff counts it).
|
|
52
|
+
"""
|
|
53
|
+
return (
|
|
54
|
+
fp.is_file()
|
|
55
|
+
and fp.suffix.lower() in SOURCE_EXTS
|
|
56
|
+
and fp.name.lower() not in RESERVED_FILENAMES
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_USERINFO_RE = re.compile(
|
|
61
|
+
r"^(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)(?P<userinfo>[^/@]*@)(?P<rest>.*)$",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sanitize_resource(uri: Any) -> Any:
|
|
66
|
+
"""Strip credentials from a ``resource:`` URI (``u:p@host`` → ``***@host``).
|
|
67
|
+
|
|
68
|
+
Connection strings landing verbatim in frontmatter would persist secrets
|
|
69
|
+
into the graph, exports, and vaults. Only the authority section
|
|
70
|
+
(between ``://`` and the next ``/``) is rewritten, so bare paths,
|
|
71
|
+
``mailto:``, anchor-only targets, and ``okf-asset://`` (UUID, no
|
|
72
|
+
userinfo) pass through untouched. Non-strings pass through as-is.
|
|
73
|
+
"""
|
|
74
|
+
if not isinstance(uri, str):
|
|
75
|
+
return uri
|
|
76
|
+
m = _USERINFO_RE.match(uri.strip())
|
|
77
|
+
if not m:
|
|
78
|
+
return uri
|
|
79
|
+
return f"{m.group('scheme')}***@{m.group('rest')}"
|
|
80
|
+
|
|
81
|
+
def parse_source_file(
|
|
82
|
+
file_path: Path, root: Path
|
|
83
|
+
) -> Tuple["ConceptModel", str, str]:
|
|
84
|
+
"""Parse a .md/.txt source into ``(ConceptModel, body, concept_id)``.
|
|
85
|
+
|
|
86
|
+
Module-level so the structural diff can parse bundle directories without
|
|
87
|
+
a database-backed manager. ``ImportManager._parse_source_file`` delegates
|
|
88
|
+
here; behaviour is identical (path-derived id, ``type``/``title``
|
|
89
|
+
synthesis, frontmatter ``id:`` preserved as ``uid``).
|
|
90
|
+
"""
|
|
91
|
+
post = frontmatter.load(file_path)
|
|
92
|
+
body = post.content
|
|
93
|
+
fm = dict(post.metadata)
|
|
94
|
+
|
|
95
|
+
rel_path = file_path.relative_to(root) if file_path.is_relative_to(root) else None
|
|
96
|
+
if rel_path is not None:
|
|
97
|
+
# with_suffix("") strips only the final extension (.md/.txt/.markdown),
|
|
98
|
+
# avoiding the old str.replace(".md","") which could corrupt paths.
|
|
99
|
+
concept_id = str(rel_path.with_suffix("")).replace("\\", "/")
|
|
100
|
+
else:
|
|
101
|
+
# File lives outside bundle_root (common when a GUI writes each .md
|
|
102
|
+
# next to its source). Fall back to the bare stem so the import
|
|
103
|
+
# doesn't crash with a relative_to ValueError.
|
|
104
|
+
concept_id = file_path.stem
|
|
105
|
+
|
|
106
|
+
if not fm.get("type"):
|
|
107
|
+
fm["type"] = "note"
|
|
108
|
+
if not fm.get("title"):
|
|
109
|
+
stem = file_path.stem.replace("_", " ").replace("-", " ").strip()
|
|
110
|
+
fm["title"] = stem or concept_id
|
|
111
|
+
|
|
112
|
+
# A frontmatter ``id:`` (Foam/Dendron-style stable identity) must not
|
|
113
|
+
# overwrite the path-derived concept id — but dropping it would lose
|
|
114
|
+
# a wikilink name. Preserve it as ``uid`` (extra MAP): the name index
|
|
115
|
+
# resolves it, and export writes it back as ``id:``.
|
|
116
|
+
fm_id = fm.pop("id", None)
|
|
117
|
+
if fm_id is not None and str(fm_id) != concept_id:
|
|
118
|
+
fm["uid"] = str(fm_id)
|
|
119
|
+
|
|
120
|
+
# Hygiene: never persist credentials hiding in ``resource:`` URIs
|
|
121
|
+
# (connection strings would otherwise land in graph → export → vault).
|
|
122
|
+
if fm.get("resource") is not None:
|
|
123
|
+
fm["resource"] = sanitize_resource(fm["resource"])
|
|
124
|
+
|
|
125
|
+
concept = ConceptModel.model_validate({**fm, "id": concept_id, "body": body})
|
|
126
|
+
return concept, body, concept_id
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ImportManager:
|
|
130
|
+
SUPPORTED_SOURCE_EXTS = (".md", ".markdown", ".txt")
|
|
131
|
+
def __init__(self, conn, bundle_root, _write_lock_ctx, enable_chunking,
|
|
132
|
+
schema_mgr, delta_mgr, embed_engine, image_mgr, purge_mgr,
|
|
133
|
+
token_counter, context_window=8192):
|
|
134
|
+
self.conn = conn
|
|
135
|
+
self.bundle_root = bundle_root
|
|
136
|
+
self._write_lock_ctx = _write_lock_ctx
|
|
137
|
+
# Token counting for the context-window guard comes from the Rust
|
|
138
|
+
# encoder (count_tokens); no transformers tokenizer exists anymore.
|
|
139
|
+
self.token_counter = token_counter
|
|
140
|
+
self.context_window = context_window
|
|
141
|
+
self.enable_chunking = enable_chunking
|
|
142
|
+
self.schema_mgr = schema_mgr
|
|
143
|
+
self.delta_mgr = delta_mgr
|
|
144
|
+
self.embed_engine = embed_engine
|
|
145
|
+
self.image_mgr = image_mgr
|
|
146
|
+
self.purge_mgr = purge_mgr
|
|
147
|
+
|
|
148
|
+
def _batch_build_directories(self, cids: List[str]):
|
|
149
|
+
"""Build directory hierarchy for a batch of concept IDs.
|
|
150
|
+
|
|
151
|
+
Collects all unique directory paths and creates them in order
|
|
152
|
+
(shallowest first) to ensure parents exist before children.
|
|
153
|
+
"""
|
|
154
|
+
# Collect all unique directory paths
|
|
155
|
+
dir_paths = set()
|
|
156
|
+
for cid in cids:
|
|
157
|
+
parts = cid.split("/")
|
|
158
|
+
if len(parts) > 1:
|
|
159
|
+
for i in range(1, len(parts)):
|
|
160
|
+
dir_paths.add("/".join(parts[:i]))
|
|
161
|
+
|
|
162
|
+
# Sort by depth (shallowest first)
|
|
163
|
+
sorted_dirs = sorted(dir_paths, key=lambda d: d.count("/"))
|
|
164
|
+
|
|
165
|
+
# Create directory hierarchy
|
|
166
|
+
for d in sorted_dirs:
|
|
167
|
+
parent = "/".join(d.split("/")[:-1]) if "/" in d else None
|
|
168
|
+
if parent and parent in dir_paths:
|
|
169
|
+
self.conn.execute("""
|
|
170
|
+
MERGE (p:Directory {id: $parent})
|
|
171
|
+
MERGE (d:Directory {id: $child})
|
|
172
|
+
MERGE (p)-[:CONTAINS]->(d)
|
|
173
|
+
""", {"parent": parent, "child": d})
|
|
174
|
+
elif parent:
|
|
175
|
+
# Parent is root (not a directory node)
|
|
176
|
+
self.conn.execute("""
|
|
177
|
+
MERGE (d:Directory {id: $child})
|
|
178
|
+
""", {"child": d})
|
|
179
|
+
else:
|
|
180
|
+
self.conn.execute("""
|
|
181
|
+
MERGE (d:Directory {id: $child})
|
|
182
|
+
""", {"child": d})
|
|
183
|
+
|
|
184
|
+
# Link each concept to its parent directory
|
|
185
|
+
for cid in cids:
|
|
186
|
+
parts = cid.split("/")
|
|
187
|
+
if len(parts) > 1:
|
|
188
|
+
parent_dir = "/".join(parts[:-1])
|
|
189
|
+
self.conn.execute("""
|
|
190
|
+
MERGE (d:Directory {id: $parent})
|
|
191
|
+
MERGE (c:Concept {id: $child})
|
|
192
|
+
MERGE (d)-[:CONTAINS]->(c)
|
|
193
|
+
""", {"parent": parent_dir, "child": cid})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _load_link_index(self):
|
|
197
|
+
"""Load the known-id set + wikilink name index over all concepts.
|
|
198
|
+
|
|
199
|
+
Single pair of queries feeding every link-resolution site (batch,
|
|
200
|
+
single, repair). ``aliases``/``alias``/``uid``/``title`` come from
|
|
201
|
+
the ``extra`` MAP (JSON-decoded when needed); ambiguous names resolve
|
|
202
|
+
to nothing (see ``okfgraph.components.links``).
|
|
203
|
+
"""
|
|
204
|
+
rows = self.conn.execute(
|
|
205
|
+
"MATCH (c:Concept) RETURN c.id, c.title, c.extra"
|
|
206
|
+
).rows_as_dict().get_all()
|
|
207
|
+
concepts = []
|
|
208
|
+
for r in rows:
|
|
209
|
+
extra = r.get("c.extra") or {}
|
|
210
|
+
entry: Dict[str, Any] = {"id": r["c.id"], "title": r.get("c.title")}
|
|
211
|
+
for key in ("uid", "aliases", "alias"):
|
|
212
|
+
val = extra.get(key)
|
|
213
|
+
if isinstance(val, str) and val.startswith("["):
|
|
214
|
+
try:
|
|
215
|
+
val = json.loads(val)
|
|
216
|
+
except json.JSONDecodeError:
|
|
217
|
+
pass
|
|
218
|
+
if val is not None:
|
|
219
|
+
entry[key] = val
|
|
220
|
+
concepts.append(entry)
|
|
221
|
+
maps, ambiguous = build_name_index(concepts)
|
|
222
|
+
if ambiguous:
|
|
223
|
+
shown = sorted(ambiguous)[:10]
|
|
224
|
+
logger.warning(
|
|
225
|
+
"wikilinks: %d ambiguous name(s), resolving to nothing (e.g. %s)",
|
|
226
|
+
len(ambiguous), ", ".join(shown),
|
|
227
|
+
)
|
|
228
|
+
return maps, {c["id"] for c in concepts}
|
|
229
|
+
|
|
230
|
+
def _record_links(self, links: List[Tuple[str, Optional[str], str]]):
|
|
231
|
+
"""Persist resolved/unresolved ``(source, target_or_None, raw)`` links.
|
|
232
|
+
|
|
233
|
+
Resolved targets get ``MERGE (source)-[:LINKS_TO]->(target)``;
|
|
234
|
+
misses become ``BrokenLink`` records for ``repair_links``/doctor.
|
|
235
|
+
"""
|
|
236
|
+
for source_id, target_id, raw in links:
|
|
237
|
+
if target_id is not None:
|
|
238
|
+
self.conn.execute("""
|
|
239
|
+
MATCH (source:Concept {id: $source})
|
|
240
|
+
MATCH (target:Concept {id: $target})
|
|
241
|
+
MERGE (source)-[:LINKS_TO]->(target)
|
|
242
|
+
""", {"source": source_id, "target": target_id})
|
|
243
|
+
else:
|
|
244
|
+
link_id = f"{source_id}\u2192{raw}"
|
|
245
|
+
now = datetime.now()
|
|
246
|
+
self.conn.execute("""
|
|
247
|
+
MERGE (bl:BrokenLink {id: $id})
|
|
248
|
+
SET bl.source_id = $source, bl.target_id = $target, bl.timestamp = $ts
|
|
249
|
+
""", {"id": link_id, "source": source_id, "target": raw, "ts": now})
|
|
250
|
+
|
|
251
|
+
def _resolve_body_links(
|
|
252
|
+
self, source_id: str, body: str, maps, known_ids
|
|
253
|
+
) -> List[Tuple[str, Optional[str], str]]:
|
|
254
|
+
"""Resolve one body to ``(source, target_or_None, raw)`` triples.
|
|
255
|
+
|
|
256
|
+
Markdown ``](path.md)`` links resolve by path; ``[[wikilinks]]`` by
|
|
257
|
+
name (uid → alias → title → stem). External URLs are skipped.
|
|
258
|
+
"""
|
|
259
|
+
out: List[Tuple[str, Optional[str], str]] = []
|
|
260
|
+
for raw in extract_md_links(body):
|
|
261
|
+
if is_external(raw):
|
|
262
|
+
continue
|
|
263
|
+
target = normalize_path_link(raw.split("#", 1)[0])
|
|
264
|
+
out.append((source_id, target if target in known_ids else None, target))
|
|
265
|
+
for raw in extract_wikilinks(body):
|
|
266
|
+
if not raw or is_external(raw):
|
|
267
|
+
continue
|
|
268
|
+
out.append((source_id, resolve_wiki(raw, maps, known_ids), raw))
|
|
269
|
+
return out
|
|
270
|
+
|
|
271
|
+
def _batch_extract_links(self, parsed: List[Dict[str, Any]]):
|
|
272
|
+
"""Extract and create LINKS_TO relationships for a batch of concepts.
|
|
273
|
+
|
|
274
|
+
Collects markdown links (by path) and wikilinks (by name) over the
|
|
275
|
+
batch, resolves against every concept in the graph, and persists
|
|
276
|
+
hits/misses in bulk via ``_record_links``.
|
|
277
|
+
"""
|
|
278
|
+
maps, known_ids = self._load_link_index()
|
|
279
|
+
all_links: List[Tuple[str, Optional[str], str]] = []
|
|
280
|
+
for item in parsed:
|
|
281
|
+
all_links.extend(
|
|
282
|
+
self._resolve_body_links(item["cid"], item["body"], maps, known_ids)
|
|
283
|
+
)
|
|
284
|
+
if all_links:
|
|
285
|
+
self._record_links(all_links)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _batch_upsert_concepts(
|
|
289
|
+
self,
|
|
290
|
+
parsed: List[Dict[str, Any]],
|
|
291
|
+
all_embeddings: List[List[float]],
|
|
292
|
+
):
|
|
293
|
+
"""Upsert all concepts in one transaction.
|
|
294
|
+
|
|
295
|
+
Deletes existing concepts, then creates new ones with embeddings.
|
|
296
|
+
"""
|
|
297
|
+
# Collect IDs for bulk delete
|
|
298
|
+
cids = [p["cid"] for p in parsed]
|
|
299
|
+
if cids:
|
|
300
|
+
# Bulk delete existing concepts. Use a bound parameter (never string
|
|
301
|
+
# interpolation) and DETACH DELETE so concepts that already have
|
|
302
|
+
# edges (LINKS_TO / CONTAINS / INCLUDES_ASSET) can be replaced on
|
|
303
|
+
# re-import instead of raising a duplicated-primary-key error.
|
|
304
|
+
self.conn.execute(
|
|
305
|
+
"MATCH (c:Concept) WHERE c.id IN $ids DETACH DELETE c",
|
|
306
|
+
{"ids": cids},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Create all concepts
|
|
310
|
+
for item, emb in zip(parsed, all_embeddings):
|
|
311
|
+
concept = item["concept"]
|
|
312
|
+
body = item["body"]
|
|
313
|
+
concept_id_val = item["cid"]
|
|
314
|
+
all_data = concept.model_dump()
|
|
315
|
+
all_data.pop("body", None)
|
|
316
|
+
all_data.pop("id", None)
|
|
317
|
+
all_data.pop("embedding", None) # embedding is passed separately, must not leak into extra MAP
|
|
318
|
+
|
|
319
|
+
core = {
|
|
320
|
+
"type": all_data.pop("type"),
|
|
321
|
+
"title": all_data.pop("title", None),
|
|
322
|
+
"description": all_data.pop("description", None),
|
|
323
|
+
"resource": all_data.pop("resource", None),
|
|
324
|
+
"tags": all_data.pop("tags", []),
|
|
325
|
+
"timestamp": all_data.pop("timestamp", None),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
extra = {
|
|
329
|
+
k: json.dumps(v) if not isinstance(v, str) else v
|
|
330
|
+
for k, v in all_data.items()
|
|
331
|
+
}
|
|
332
|
+
extra_keys = list(extra.keys())
|
|
333
|
+
extra_values = list(extra.values())
|
|
334
|
+
|
|
335
|
+
if isinstance(core["timestamp"], datetime):
|
|
336
|
+
core["timestamp"] = core["timestamp"].isoformat()
|
|
337
|
+
|
|
338
|
+
params: Dict[str, Any] = {
|
|
339
|
+
"id": concept_id_val,
|
|
340
|
+
"body": body,
|
|
341
|
+
"embedding": emb,
|
|
342
|
+
**core,
|
|
343
|
+
}
|
|
344
|
+
if extra_keys:
|
|
345
|
+
params["extra_keys"] = extra_keys
|
|
346
|
+
params["extra_values"] = extra_values
|
|
347
|
+
self.conn.execute("""
|
|
348
|
+
CREATE (c:Concept {
|
|
349
|
+
id: $id, type: $type, title: $title,
|
|
350
|
+
description: $description, resource: $resource,
|
|
351
|
+
tags: $tags, timestamp: $timestamp,
|
|
352
|
+
body: $body, embedding: $embedding,
|
|
353
|
+
extra: MAP($extra_keys, $extra_values)
|
|
354
|
+
})
|
|
355
|
+
""", params)
|
|
356
|
+
else:
|
|
357
|
+
self.conn.execute("""
|
|
358
|
+
CREATE (c:Concept {
|
|
359
|
+
id: $id, type: $type, title: $title,
|
|
360
|
+
description: $description, resource: $resource,
|
|
361
|
+
tags: $tags, timestamp: $timestamp,
|
|
362
|
+
body: $body, embedding: $embedding
|
|
363
|
+
})
|
|
364
|
+
""", params)
|
|
365
|
+
|
|
366
|
+
# Concepts changed -> search indexes are now stale. Bumping inside the
|
|
367
|
+
# caller's transaction means the marker rolls back with the data if the
|
|
368
|
+
# import aborts, keeping dirty-state consistent with what's committed.
|
|
369
|
+
if parsed:
|
|
370
|
+
self.schema_mgr._bump_write_epoch()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _extract_links_for_concept(self, concept_id: str, body: str):
|
|
374
|
+
"""Extract and create LINKS_TO relationships for a single concept.
|
|
375
|
+
|
|
376
|
+
Handles markdown links [text](file.md) by path and [[wikilinks]] by
|
|
377
|
+
name (uid → alias → title → stem); misses become BrokenLink records.
|
|
378
|
+
"""
|
|
379
|
+
maps, known_ids = self._load_link_index()
|
|
380
|
+
links = self._resolve_body_links(concept_id, body, maps, known_ids)
|
|
381
|
+
if links:
|
|
382
|
+
self._record_links(links)
|
|
383
|
+
|
|
384
|
+
def _get_property(self, cid: str, prop: str) -> Any:
|
|
385
|
+
"""Retrieve a single property from a concept node."""
|
|
386
|
+
result = self.conn.execute(
|
|
387
|
+
f"MATCH (c:Concept {{id: $id}}) RETURN c.{prop}",
|
|
388
|
+
{"id": cid},
|
|
389
|
+
)
|
|
390
|
+
row = result.rows_as_dict().get_all()
|
|
391
|
+
return row[0][f"c.{prop}"] if row else None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _import_bundle_inner(
|
|
395
|
+
self,
|
|
396
|
+
bundle_path: Optional[Path],
|
|
397
|
+
batch_size: int,
|
|
398
|
+
mode: "str | IngestMode",
|
|
399
|
+
purge_deleted: bool,
|
|
400
|
+
) -> List[str]:
|
|
401
|
+
"""Inner implementation of import_bundle (called under write lock)."""
|
|
402
|
+
mode = IngestMode.coerce(mode)
|
|
403
|
+
root = bundle_path or self.bundle_root
|
|
404
|
+
# Single walk, partitioned: reserved names (index.md, ...) are graph
|
|
405
|
+
# noise, never knowledge — but counted in the log so silent loss is
|
|
406
|
+
# impossible. Shared predicate with diff/delta/lint (is_concept_file).
|
|
407
|
+
candidates = sorted(
|
|
408
|
+
fp for fp in root.rglob("*")
|
|
409
|
+
if fp.is_file() and fp.suffix.lower() in SOURCE_EXTS
|
|
410
|
+
)
|
|
411
|
+
source_files = [fp for fp in candidates if is_concept_file(fp)]
|
|
412
|
+
if len(source_files) != len(candidates):
|
|
413
|
+
logger.info(
|
|
414
|
+
"skipping %d reserved file(s) (index.md, ...)",
|
|
415
|
+
len(candidates) - len(source_files),
|
|
416
|
+
)
|
|
417
|
+
if not source_files:
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
_t0 = time.monotonic()
|
|
421
|
+
|
|
422
|
+
# Phase 0: Delta detection — directory-level hash aggregation.
|
|
423
|
+
# Skips entire subtrees when directory hash is unchanged.
|
|
424
|
+
_t1 = time.monotonic()
|
|
425
|
+
changed, deleted = self.delta_mgr._changed_directories(source_files)
|
|
426
|
+
logger.info("directory-delta: %d changed, %d deleted (%.1fs)", len(changed), len(deleted), time.monotonic() - _t1)
|
|
427
|
+
|
|
428
|
+
# Purge deleted concepts if requested.
|
|
429
|
+
if purge_deleted and deleted:
|
|
430
|
+
cid_map = self.delta_mgr._load_file_hash_concept_ids()
|
|
431
|
+
for path in deleted:
|
|
432
|
+
cid = cid_map.get(path)
|
|
433
|
+
if cid:
|
|
434
|
+
self.purge_mgr._purge_concept(cid)
|
|
435
|
+
logger.info("purged %d deleted concept(s)", len(deleted))
|
|
436
|
+
|
|
437
|
+
if not changed and not deleted:
|
|
438
|
+
return []
|
|
439
|
+
source_files = changed
|
|
440
|
+
|
|
441
|
+
# Update FileHash table after purge (so deleted entries are removed).
|
|
442
|
+
# This ensures the cid_map used by purge has the correct entries.
|
|
443
|
+
file_hashes: Dict[str, str] = {}
|
|
444
|
+
for fp in source_files:
|
|
445
|
+
rel = str(fp.relative_to(self.bundle_root))
|
|
446
|
+
h = self.delta_mgr._file_hash(fp)
|
|
447
|
+
file_hashes[rel] = h
|
|
448
|
+
self.delta_mgr._store_file_hashes(file_hashes)
|
|
449
|
+
|
|
450
|
+
# Phase 1: Parse all files
|
|
451
|
+
_t1 = time.monotonic()
|
|
452
|
+
parsed: List[Dict[str, Any]] = []
|
|
453
|
+
for fp in source_files:
|
|
454
|
+
try:
|
|
455
|
+
concept, body, cid = self._parse_source_file(fp, root)
|
|
456
|
+
search_text = f"{concept.title or ''} {concept.description or ''} {concept.body or ''}"
|
|
457
|
+
parsed.append({
|
|
458
|
+
"concept": concept,
|
|
459
|
+
"search_text": search_text,
|
|
460
|
+
"body": body,
|
|
461
|
+
"cid": cid,
|
|
462
|
+
"dir": fp.parent,
|
|
463
|
+
})
|
|
464
|
+
except Exception as e:
|
|
465
|
+
logger.warning("parse failed %s: %s", fp.name, e)
|
|
466
|
+
|
|
467
|
+
# No-op guard: if nothing parsed successfully, do no work (no encode, no
|
|
468
|
+
# transaction, no index rebuild).
|
|
469
|
+
if not parsed:
|
|
470
|
+
return []
|
|
471
|
+
|
|
472
|
+
logger.info("parsed %d concept(s)", len(parsed))
|
|
473
|
+
|
|
474
|
+
# Phase 2: Batch encode (chunked by batch_size)
|
|
475
|
+
_t1 = time.monotonic()
|
|
476
|
+
all_search_texts = [p["search_text"] for p in parsed]
|
|
477
|
+
all_embeddings: List[List[float]] = []
|
|
478
|
+
for i in range(0, len(all_search_texts), batch_size):
|
|
479
|
+
chunk = all_search_texts[i : i + batch_size]
|
|
480
|
+
batch_embs = self.embed_engine._encode_batch(chunk, task="Document")
|
|
481
|
+
all_embeddings.extend(batch_embs)
|
|
482
|
+
logger.info("encode: %d texts in %.1fs", len(all_search_texts), time.monotonic() - _t1)
|
|
483
|
+
|
|
484
|
+
# Phase 3: Batch upsert all concepts in a single transaction
|
|
485
|
+
_t1 = time.monotonic()
|
|
486
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
487
|
+
try:
|
|
488
|
+
self._batch_upsert_concepts(parsed, all_embeddings)
|
|
489
|
+
self.conn.execute("COMMIT")
|
|
490
|
+
except Exception:
|
|
491
|
+
try:
|
|
492
|
+
self.conn.execute("ROLLBACK")
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
raise
|
|
496
|
+
logger.info("upsert: %d concepts in %.1fs", len(parsed), time.monotonic() - _t1)
|
|
497
|
+
|
|
498
|
+
# Phase 3.5: Chunk all documents (NEW) — per-concept error isolation
|
|
499
|
+
_import_chunk_errors: List[Tuple[str, Exception]] = []
|
|
500
|
+
_t1 = time.monotonic()
|
|
501
|
+
if self.enable_chunking:
|
|
502
|
+
for p in parsed:
|
|
503
|
+
try:
|
|
504
|
+
self._import_chunks_for_concept(p)
|
|
505
|
+
except Exception as e:
|
|
506
|
+
_import_chunk_errors.append((p["cid"], e))
|
|
507
|
+
logger.warning(
|
|
508
|
+
"chunk import failed for %s: %s",
|
|
509
|
+
p["cid"], e,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if _import_chunk_errors:
|
|
513
|
+
logger.warning(
|
|
514
|
+
"chunk import: %d concept(s) failed out of %d",
|
|
515
|
+
len(_import_chunk_errors), len(parsed),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
logger.info("chunk: %d concepts in %.1fs", len(parsed), time.monotonic() - _t1)
|
|
519
|
+
else:
|
|
520
|
+
_t1 = time.monotonic()
|
|
521
|
+
|
|
522
|
+
# Phase 4: Batch directory hierarchy (collected from all concept IDs)
|
|
523
|
+
_t1 = time.monotonic()
|
|
524
|
+
self._batch_build_directories([p["cid"] for p in parsed])
|
|
525
|
+
logger.info("directories: %d in %.1fs", len(parsed), time.monotonic() - _t1)
|
|
526
|
+
|
|
527
|
+
# Phase 5: Batch link extraction
|
|
528
|
+
_t1 = time.monotonic()
|
|
529
|
+
self._batch_extract_links(parsed)
|
|
530
|
+
logger.info("links: %d concepts in %.1fs", len(parsed), time.monotonic() - _t1)
|
|
531
|
+
|
|
532
|
+
# Phase 6: Image ingestion (per concept, honouring the selected mode)
|
|
533
|
+
_import_image_errors: List[Tuple[str, Exception]] = []
|
|
534
|
+
_t1 = time.monotonic()
|
|
535
|
+
for p in parsed:
|
|
536
|
+
try:
|
|
537
|
+
self.image_mgr._ingest_concept_images(p["cid"], p["body"], p["dir"], mode)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
_import_image_errors.append((p["cid"], e))
|
|
540
|
+
|
|
541
|
+
if _import_image_errors:
|
|
542
|
+
logger.warning(
|
|
543
|
+
"image ingestion: %d concept(s) failed out of %d",
|
|
544
|
+
len(_import_image_errors), len(parsed),
|
|
545
|
+
)
|
|
546
|
+
logger.info("images: %d concepts in %.1fs", len(parsed), time.monotonic() - _t1)
|
|
547
|
+
|
|
548
|
+
# Phase 7: rebuild vector + FTS indexes once so every concept/image
|
|
549
|
+
# written above is searchable (indexes reflect table contents at build
|
|
550
|
+
# time, not subsequent inserts).
|
|
551
|
+
_t1 = time.monotonic()
|
|
552
|
+
self.schema_mgr._build_search_indexes(rebuild=True)
|
|
553
|
+
logger.info("reindex: %.1fs", time.monotonic() - _t1)
|
|
554
|
+
|
|
555
|
+
# Aggregate failure report
|
|
556
|
+
total_failures = len(_import_chunk_errors) + len(_import_image_errors)
|
|
557
|
+
if total_failures > 0:
|
|
558
|
+
logger.warning(
|
|
559
|
+
"import_bundle: %d concept(s) had non-fatal errors "
|
|
560
|
+
"(chunks: %d, images: %d) out of %d total",
|
|
561
|
+
total_failures,
|
|
562
|
+
len(_import_chunk_errors),
|
|
563
|
+
len(_import_image_errors),
|
|
564
|
+
len(parsed),
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
elapsed = time.monotonic() - _t0
|
|
568
|
+
logger.info(
|
|
569
|
+
"import_bundle: %d concept(s) in %.1fs",
|
|
570
|
+
len(parsed), elapsed,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return [p["cid"] for p in parsed]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _import_chunks_for_concept(self, parsed_item: Dict[str, Any]) -> None:
|
|
577
|
+
"""Split, encode, and upsert chunks for a single concept.
|
|
578
|
+
|
|
579
|
+
Isolated so that a failure for one concept doesn't block the rest of
|
|
580
|
+
the bundle. Also checks context-window occupancy and logs a warning
|
|
581
|
+
when a chunk exceeds 90%% of the tokenizer's limit.
|
|
582
|
+
"""
|
|
583
|
+
body = parsed_item["body"]
|
|
584
|
+
cid = parsed_item["cid"]
|
|
585
|
+
|
|
586
|
+
# Delete old chunks for this document (re-import)
|
|
587
|
+
self.conn.execute(
|
|
588
|
+
"MATCH (c:Concept {id: $id})-[:PART_OF]->(ch:Chunk) DETACH DELETE ch",
|
|
589
|
+
{"id": cid},
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
chunks = self.embed_engine._split_into_chunks(body, cid)
|
|
593
|
+
if not chunks:
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
# Compute overlap payloads for embedding
|
|
597
|
+
payloads = self.embed_engine._compute_overlap_payloads(chunks)
|
|
598
|
+
texts = [p["text"] for p in payloads]
|
|
599
|
+
|
|
600
|
+
# --- Context-window warning (Gap #14a) ---
|
|
601
|
+
ctx_window = self.context_window
|
|
602
|
+
threshold = int(ctx_window * 0.9)
|
|
603
|
+
for i, t in enumerate(texts):
|
|
604
|
+
token_count = self.token_counter(t)
|
|
605
|
+
if token_count >= threshold:
|
|
606
|
+
logger.warning(
|
|
607
|
+
"chunk %s#%d is %d tokens (%.0f%% of context window %d). "
|
|
608
|
+
"Consider reducing chunk_size or splitting the document.",
|
|
609
|
+
cid, i, token_count,
|
|
610
|
+
100.0 * token_count / ctx_window,
|
|
611
|
+
ctx_window,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
embeddings = self.embed_engine._encode_batch(texts, task="Document")
|
|
615
|
+
|
|
616
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
617
|
+
try:
|
|
618
|
+
for payload, emb in zip(payloads, embeddings):
|
|
619
|
+
chunk_id = payload["chunk_id"]
|
|
620
|
+
# Find the original chunk for metadata
|
|
621
|
+
orig_chunk = next(
|
|
622
|
+
c for c in chunks
|
|
623
|
+
if c["chunk_index"] == int(chunk_id.split(":")[-1])
|
|
624
|
+
)
|
|
625
|
+
self.conn.execute("""
|
|
626
|
+
CREATE (ch:Chunk {
|
|
627
|
+
id: $id, parent_doc_id: $doc_id,
|
|
628
|
+
chunk_index: $idx, chunk_text: $text,
|
|
629
|
+
block_type: $block_type,
|
|
630
|
+
start_offset: $start, end_offset: $end_offset,
|
|
631
|
+
embedding: $emb
|
|
632
|
+
})
|
|
633
|
+
""", {
|
|
634
|
+
"id": chunk_id,
|
|
635
|
+
"doc_id": cid,
|
|
636
|
+
"idx": orig_chunk["chunk_index"],
|
|
637
|
+
"text": orig_chunk["chunk_text"],
|
|
638
|
+
"block_type": orig_chunk["block_type"],
|
|
639
|
+
"start": orig_chunk["start_offset"],
|
|
640
|
+
"end_offset": orig_chunk["end_offset"],
|
|
641
|
+
"emb": emb,
|
|
642
|
+
})
|
|
643
|
+
self.conn.execute("COMMIT")
|
|
644
|
+
except Exception:
|
|
645
|
+
try:
|
|
646
|
+
self.conn.execute("ROLLBACK")
|
|
647
|
+
except Exception:
|
|
648
|
+
pass
|
|
649
|
+
raise
|
|
650
|
+
self.schema_mgr._bump_write_epoch()
|
|
651
|
+
|
|
652
|
+
# Create PART_OF relationships between Concept and its Chunks
|
|
653
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
654
|
+
try:
|
|
655
|
+
for chunk in chunks:
|
|
656
|
+
chunk_id = f"{cid}#chunk:{chunk['chunk_index']}"
|
|
657
|
+
self.conn.execute("""
|
|
658
|
+
MATCH (d:Concept {id: $doc})
|
|
659
|
+
MATCH (ch:Chunk {id: $cid})
|
|
660
|
+
MERGE (d)-[:PART_OF]->(ch)
|
|
661
|
+
""", {"doc": cid, "cid": chunk_id})
|
|
662
|
+
self.conn.execute("COMMIT")
|
|
663
|
+
except Exception:
|
|
664
|
+
try:
|
|
665
|
+
self.conn.execute("ROLLBACK")
|
|
666
|
+
except Exception:
|
|
667
|
+
pass
|
|
668
|
+
raise
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _import_single_concept(
|
|
672
|
+
self,
|
|
673
|
+
concept: "ConceptModel",
|
|
674
|
+
body: str,
|
|
675
|
+
mode: "str | IngestMode" = IngestMode.TEXT,
|
|
676
|
+
) -> Dict[str, Any]:
|
|
677
|
+
"""Import a single concept using the full pipeline (encode, upsert, chunk, etc.).
|
|
678
|
+
|
|
679
|
+
This is the core shared logic between ingest_md() and ingest_thoughts().
|
|
680
|
+
"""
|
|
681
|
+
mode = IngestMode.coerce(mode)
|
|
682
|
+
|
|
683
|
+
# Phase 2: Encode (Document prefix)
|
|
684
|
+
search_text = (
|
|
685
|
+
f"{concept.title or ''} {concept.description or ''} {concept.body or ''}"
|
|
686
|
+
)
|
|
687
|
+
concept.embedding = self.embed_engine._encode(search_text, task="Document")
|
|
688
|
+
|
|
689
|
+
# Phase 3: Upsert in transaction
|
|
690
|
+
self._insert_concept(concept, body, concept.id)
|
|
691
|
+
|
|
692
|
+
# Phase 3.5: Chunk
|
|
693
|
+
chunk_count = 0
|
|
694
|
+
if self.enable_chunking:
|
|
695
|
+
# Delete old chunks for this document (re-import)
|
|
696
|
+
self.conn.execute(
|
|
697
|
+
"MATCH (c:Concept {id: $id})-[:PART_OF]->(ch:Chunk) DETACH DELETE ch",
|
|
698
|
+
{"id": concept.id},
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
chunks = self.embed_engine._split_into_chunks(body, concept.id)
|
|
702
|
+
if chunks:
|
|
703
|
+
# Compute overlap payloads for embedding
|
|
704
|
+
payloads = self.embed_engine._compute_overlap_payloads(chunks)
|
|
705
|
+
texts = [p["text"] for p in payloads]
|
|
706
|
+
embeddings = self.embed_engine._encode_batch(texts, task="Document")
|
|
707
|
+
|
|
708
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
709
|
+
try:
|
|
710
|
+
for payload, emb in zip(payloads, embeddings):
|
|
711
|
+
chunk_id = payload["chunk_id"]
|
|
712
|
+
# Find the original chunk for metadata
|
|
713
|
+
orig_chunk = next(
|
|
714
|
+
c for c in chunks
|
|
715
|
+
if c["chunk_index"] == int(chunk_id.split(":")[-1])
|
|
716
|
+
)
|
|
717
|
+
self.conn.execute("""
|
|
718
|
+
CREATE (ch:Chunk {
|
|
719
|
+
id: $id, parent_doc_id: $doc_id,
|
|
720
|
+
chunk_index: $idx, chunk_text: $text,
|
|
721
|
+
block_type: $block_type,
|
|
722
|
+
start_offset: $start, end_offset: $end_offset,
|
|
723
|
+
embedding: $emb
|
|
724
|
+
})
|
|
725
|
+
""", {
|
|
726
|
+
"id": chunk_id,
|
|
727
|
+
"doc_id": concept.id,
|
|
728
|
+
"idx": orig_chunk["chunk_index"],
|
|
729
|
+
"text": orig_chunk["chunk_text"],
|
|
730
|
+
"block_type": orig_chunk["block_type"],
|
|
731
|
+
"start": orig_chunk["start_offset"],
|
|
732
|
+
"end_offset": orig_chunk["end_offset"],
|
|
733
|
+
"emb": emb,
|
|
734
|
+
})
|
|
735
|
+
self.conn.execute("COMMIT")
|
|
736
|
+
except Exception:
|
|
737
|
+
try:
|
|
738
|
+
self.conn.execute("ROLLBACK")
|
|
739
|
+
except Exception:
|
|
740
|
+
pass
|
|
741
|
+
raise
|
|
742
|
+
self.schema_mgr._bump_write_epoch()
|
|
743
|
+
|
|
744
|
+
chunk_count = len(chunks)
|
|
745
|
+
|
|
746
|
+
# Create PART_OF relationships
|
|
747
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
748
|
+
try:
|
|
749
|
+
for chunk in chunks:
|
|
750
|
+
chunk_id = f"{concept.id}#chunk:{chunk['chunk_index']}"
|
|
751
|
+
self.conn.execute("""
|
|
752
|
+
MATCH (d:Concept {id: $doc})
|
|
753
|
+
MATCH (ch:Chunk {id: $cid})
|
|
754
|
+
MERGE (d)-[:PART_OF]->(ch)
|
|
755
|
+
""", {"doc": concept.id, "cid": chunk_id})
|
|
756
|
+
self.conn.execute("COMMIT")
|
|
757
|
+
except Exception:
|
|
758
|
+
try:
|
|
759
|
+
self.conn.execute("ROLLBACK")
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
raise
|
|
763
|
+
|
|
764
|
+
# Phase 5: Links
|
|
765
|
+
self._extract_links_for_concept(concept.id, body)
|
|
766
|
+
|
|
767
|
+
# Phase 6: Images
|
|
768
|
+
image_count = 0
|
|
769
|
+
try:
|
|
770
|
+
image_count = len(
|
|
771
|
+
self.image_mgr._ingest_concept_images(concept.id, body, Path("."), mode)
|
|
772
|
+
)
|
|
773
|
+
except Exception:
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
# Phase 7: Rebuild indexes
|
|
777
|
+
self.schema_mgr._build_search_indexes(rebuild=True)
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
"concept_id": concept.id,
|
|
781
|
+
"title": concept.title,
|
|
782
|
+
"description": concept.description,
|
|
783
|
+
"tags": concept.tags,
|
|
784
|
+
"chunk_count": chunk_count,
|
|
785
|
+
"image_count": image_count,
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _insert_concept(
|
|
790
|
+
self,
|
|
791
|
+
concept: ConceptModel,
|
|
792
|
+
body_text: str,
|
|
793
|
+
concept_id_val: str,
|
|
794
|
+
) -> str:
|
|
795
|
+
"""Internal helper: upsert a single concept into the graph."""
|
|
796
|
+
all_data = concept.model_dump()
|
|
797
|
+
embedding_vec = all_data.pop("embedding", None)
|
|
798
|
+
all_data.pop("body", None)
|
|
799
|
+
all_data.pop("id", None)
|
|
800
|
+
|
|
801
|
+
core = {
|
|
802
|
+
"type": all_data.pop("type"),
|
|
803
|
+
"title": all_data.pop("title", None),
|
|
804
|
+
"description": all_data.pop("description", None),
|
|
805
|
+
"resource": all_data.pop("resource", None),
|
|
806
|
+
"tags": all_data.pop("tags", []),
|
|
807
|
+
"timestamp": all_data.pop("timestamp", None),
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
extra = {
|
|
811
|
+
k: json.dumps(v) if not isinstance(v, str) else v
|
|
812
|
+
for k, v in all_data.items()
|
|
813
|
+
}
|
|
814
|
+
extra_keys = list(extra.keys())
|
|
815
|
+
extra_values = list(extra.values())
|
|
816
|
+
|
|
817
|
+
if isinstance(core["timestamp"], datetime):
|
|
818
|
+
core["timestamp"] = core["timestamp"].isoformat()
|
|
819
|
+
|
|
820
|
+
# Atomic upsert
|
|
821
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
822
|
+
try:
|
|
823
|
+
self.conn.execute(
|
|
824
|
+
"MATCH (c:Concept {id: $id}) DETACH DELETE c",
|
|
825
|
+
{"id": concept_id_val},
|
|
826
|
+
)
|
|
827
|
+
params: Dict[str, Any] = {
|
|
828
|
+
"id": concept_id_val,
|
|
829
|
+
"body": body_text,
|
|
830
|
+
"embedding": embedding_vec,
|
|
831
|
+
**core,
|
|
832
|
+
}
|
|
833
|
+
if extra_keys:
|
|
834
|
+
params["extra_keys"] = extra_keys
|
|
835
|
+
params["extra_values"] = extra_values
|
|
836
|
+
self.conn.execute("""
|
|
837
|
+
CREATE (c:Concept {
|
|
838
|
+
id: $id, type: $type, title: $title,
|
|
839
|
+
description: $description, resource: $resource,
|
|
840
|
+
tags: $tags, timestamp: $timestamp,
|
|
841
|
+
body: $body, embedding: $embedding,
|
|
842
|
+
extra: MAP($extra_keys, $extra_values)
|
|
843
|
+
})
|
|
844
|
+
""", params)
|
|
845
|
+
else:
|
|
846
|
+
self.conn.execute("""
|
|
847
|
+
CREATE (c:Concept {
|
|
848
|
+
id: $id, type: $type, title: $title,
|
|
849
|
+
description: $description, resource: $resource,
|
|
850
|
+
tags: $tags, timestamp: $timestamp,
|
|
851
|
+
body: $body, embedding: $embedding
|
|
852
|
+
})
|
|
853
|
+
""", params)
|
|
854
|
+
self.schema_mgr._bump_write_epoch() # concept changed -> indexes dirty
|
|
855
|
+
self.conn.execute("COMMIT")
|
|
856
|
+
except Exception:
|
|
857
|
+
try:
|
|
858
|
+
self.conn.execute("ROLLBACK")
|
|
859
|
+
except Exception:
|
|
860
|
+
pass
|
|
861
|
+
raise
|
|
862
|
+
|
|
863
|
+
# Build Directory Hierarchy
|
|
864
|
+
path_parts = concept_id_val.split("/")
|
|
865
|
+
if len(path_parts) > 1:
|
|
866
|
+
for i in range(1, len(path_parts)):
|
|
867
|
+
parent = "/".join(path_parts[:i])
|
|
868
|
+
child = "/".join(path_parts[: i + 1])
|
|
869
|
+
if i == len(path_parts) - 1:
|
|
870
|
+
self.conn.execute("""
|
|
871
|
+
MERGE (d:Directory {id: $parent})
|
|
872
|
+
MERGE (c:Concept {id: $child})
|
|
873
|
+
MERGE (d)-[:CONTAINS]->(c)
|
|
874
|
+
""", {"parent": parent, "child": child})
|
|
875
|
+
else:
|
|
876
|
+
self.conn.execute("""
|
|
877
|
+
MERGE (p:Directory {id: $parent})
|
|
878
|
+
MERGE (d:Directory {id: $child})
|
|
879
|
+
MERGE (p)-[:CONTAINS]->(d)
|
|
880
|
+
""", {"parent": parent, "child": child})
|
|
881
|
+
|
|
882
|
+
# NOTE: link extraction intentionally lives outside _insert_concept
|
|
883
|
+
# (callers run _extract_links_for_concept after the node exists), so
|
|
884
|
+
# path-links and [[wikilinks]] share one resolution path.
|
|
885
|
+
|
|
886
|
+
return concept_id_val
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _parse_source_file(
|
|
890
|
+
self, file_path: Path, root: Path
|
|
891
|
+
) -> Tuple[ConceptModel, str, str]:
|
|
892
|
+
"""Parse a .md/.txt source into ``(ConceptModel, body, concept_id)``.
|
|
893
|
+
|
|
894
|
+
Frontmatter is used when present. Plain-text files (and Markdown lacking
|
|
895
|
+
frontmatter) get a synthesized ``type`` ('note') and a ``title`` derived
|
|
896
|
+
from the filename, so the simplified text-only pipeline can ingest .txt
|
|
897
|
+
alongside .md without every file needing OKF frontmatter.
|
|
898
|
+
|
|
899
|
+
Delegates to :func:`parse_source_file` (shared with the diff tool).
|
|
900
|
+
"""
|
|
901
|
+
return parse_source_file(file_path, root)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def import_bundle(
|
|
905
|
+
self,
|
|
906
|
+
bundle_path: Optional[Path] = None,
|
|
907
|
+
batch_size: int = 32,
|
|
908
|
+
mode: "str | IngestMode" = IngestMode.TEXT,
|
|
909
|
+
purge_deleted: bool = False,
|
|
910
|
+
) -> List[str]:
|
|
911
|
+
"""Import an entire OKF bundle directory with batched encoding.
|
|
912
|
+
|
|
913
|
+
Walks the bundle directory, parses all .md files, generates
|
|
914
|
+
embeddings in batched ONNX forward passes, and upserts them.
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
bundle_path: Root directory of the OKF bundle (defaults to constructor bundle_root).
|
|
918
|
+
batch_size: Number of texts per ONNX forward pass.
|
|
919
|
+
mode: Image ingestion mode (``text`` | ``optional`` | ``omni``).
|
|
920
|
+
purge_deleted: If True, concepts whose source files were deleted
|
|
921
|
+
from disk are removed from the graph (including chunks,
|
|
922
|
+
links, and orphaned image assets).
|
|
923
|
+
|
|
924
|
+
Returns:
|
|
925
|
+
List of imported concept IDs.
|
|
926
|
+
"""
|
|
927
|
+
# Acquire write lock (Gap #7b)
|
|
928
|
+
with self._write_lock_ctx():
|
|
929
|
+
return self._import_bundle_inner(bundle_path, batch_size, mode, purge_deleted)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def import_from_okf(
|
|
933
|
+
self,
|
|
934
|
+
file_path: Path,
|
|
935
|
+
mode: "str | IngestMode" = IngestMode.TEXT,
|
|
936
|
+
rebuild_indexes: bool = True,
|
|
937
|
+
) -> str:
|
|
938
|
+
"""Parse an OKF .md/.txt file and create/update the concept in the graph.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
file_path: Path to the source file (``.md``, ``.markdown`` or ``.txt``).
|
|
942
|
+
mode: Image ingestion mode — ``text`` (alt-text / filename fallback,
|
|
943
|
+
no omni model), ``optional`` (omni only for images without
|
|
944
|
+
alt-text), or ``omni`` (omni for every image).
|
|
945
|
+
|
|
946
|
+
Returns the concept ID (relative path without its extension).
|
|
947
|
+
"""
|
|
948
|
+
mode = IngestMode.coerce(mode)
|
|
949
|
+
|
|
950
|
+
# 1-2. Parse frontmatter/body and build the Concept model.
|
|
951
|
+
concept, body, concept_id = self._parse_source_file(file_path, self.bundle_root)
|
|
952
|
+
|
|
953
|
+
# 2.5. Chunk the body (NEW)
|
|
954
|
+
if self.enable_chunking:
|
|
955
|
+
# Delete old chunks for this document (re-import)
|
|
956
|
+
self.conn.execute(
|
|
957
|
+
"MATCH (c:Concept {id: $id})-[:PART_OF]->(ch:Chunk) DETACH DELETE ch",
|
|
958
|
+
{"id": concept_id},
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
chunks = self.embed_engine._split_into_chunks(body, concept_id)
|
|
962
|
+
if chunks:
|
|
963
|
+
# Compute overlap payloads for embedding
|
|
964
|
+
payloads = self.embed_engine._compute_overlap_payloads(chunks)
|
|
965
|
+
texts = [p["text"] for p in payloads]
|
|
966
|
+
embeddings = self.embed_engine._encode_batch(texts, task="Document")
|
|
967
|
+
|
|
968
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
969
|
+
try:
|
|
970
|
+
for payload, emb in zip(payloads, embeddings):
|
|
971
|
+
chunk_id = payload["chunk_id"]
|
|
972
|
+
# Find the original chunk for metadata
|
|
973
|
+
orig_chunk = next(
|
|
974
|
+
c for c in chunks
|
|
975
|
+
if c["chunk_index"] == int(chunk_id.split(":")[-1])
|
|
976
|
+
)
|
|
977
|
+
self.conn.execute("""
|
|
978
|
+
CREATE (ch:Chunk {
|
|
979
|
+
id: $id, parent_doc_id: $doc_id,
|
|
980
|
+
chunk_index: $idx, chunk_text: $text,
|
|
981
|
+
block_type: $block_type,
|
|
982
|
+
start_offset: $start, end_offset: $end_offset,
|
|
983
|
+
embedding: $emb
|
|
984
|
+
})
|
|
985
|
+
""", {
|
|
986
|
+
"id": chunk_id,
|
|
987
|
+
"doc_id": concept_id,
|
|
988
|
+
"idx": orig_chunk["chunk_index"],
|
|
989
|
+
"text": orig_chunk["chunk_text"],
|
|
990
|
+
"block_type": orig_chunk["block_type"],
|
|
991
|
+
"start": orig_chunk["start_offset"],
|
|
992
|
+
"end_offset": orig_chunk["end_offset"],
|
|
993
|
+
"emb": emb,
|
|
994
|
+
})
|
|
995
|
+
self.conn.execute("COMMIT")
|
|
996
|
+
except Exception:
|
|
997
|
+
try:
|
|
998
|
+
self.conn.execute("ROLLBACK")
|
|
999
|
+
except Exception:
|
|
1000
|
+
pass
|
|
1001
|
+
raise
|
|
1002
|
+
self.schema_mgr._bump_write_epoch()
|
|
1003
|
+
|
|
1004
|
+
# 3. Generate embedding (Document prefix)
|
|
1005
|
+
search_text = f"{concept.title or ''} {concept.description or ''} {concept.body or ''}"
|
|
1006
|
+
concept.embedding = self.embed_engine._encode(search_text, task="Document")
|
|
1007
|
+
|
|
1008
|
+
# 4. Insert into graph (delegates to shared upsert logic)
|
|
1009
|
+
self._insert_concept(concept, body, concept_id)
|
|
1010
|
+
|
|
1011
|
+
# 4.5. Create PART_OF relationships between Concept and its Chunks
|
|
1012
|
+
# (must happen after _insert_concept so the Concept node exists)
|
|
1013
|
+
if self.enable_chunking:
|
|
1014
|
+
self.conn.execute("BEGIN TRANSACTION")
|
|
1015
|
+
try:
|
|
1016
|
+
for chunk in chunks:
|
|
1017
|
+
chunk_id = f"{concept_id}#chunk:{chunk['chunk_index']}"
|
|
1018
|
+
self.conn.execute("""
|
|
1019
|
+
MATCH (d:Concept {id: $doc})
|
|
1020
|
+
MATCH (ch:Chunk {id: $cid})
|
|
1021
|
+
MERGE (d)-[:PART_OF]->(ch)
|
|
1022
|
+
""", {"doc": concept_id, "cid": chunk_id})
|
|
1023
|
+
self.conn.execute("COMMIT")
|
|
1024
|
+
except Exception:
|
|
1025
|
+
try:
|
|
1026
|
+
self.conn.execute("ROLLBACK")
|
|
1027
|
+
except Exception:
|
|
1028
|
+
pass
|
|
1029
|
+
raise
|
|
1030
|
+
|
|
1031
|
+
# 5. Ingest any embedded images under the requested mode
|
|
1032
|
+
self.image_mgr._ingest_concept_images(concept_id, body, file_path.parent, mode)
|
|
1033
|
+
|
|
1034
|
+
# 6. Extract and create LINKS_TO relationships for this concept
|
|
1035
|
+
self._extract_links_for_concept(concept_id, body)
|
|
1036
|
+
|
|
1037
|
+
# 7. Rebuild search indexes so this concept (and its images) are
|
|
1038
|
+
# actually returned by vector/FTS search. Callers importing many
|
|
1039
|
+
# files one-by-one can pass rebuild_indexes=False and call
|
|
1040
|
+
# _build_search_indexes(rebuild=True) once at the end.
|
|
1041
|
+
if rebuild_indexes:
|
|
1042
|
+
self.schema_mgr._build_search_indexes(rebuild=True)
|
|
1043
|
+
|
|
1044
|
+
return concept_id
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def list_broken_links(self) -> List[Dict[str, Any]]:
|
|
1048
|
+
"""List all tracked broken links (references to concepts not yet imported)."""
|
|
1049
|
+
result = self.conn.execute("""
|
|
1050
|
+
MATCH (bl:BrokenLink)
|
|
1051
|
+
RETURN bl.source_id AS source, bl.target_id AS target, bl.timestamp AS timestamp
|
|
1052
|
+
ORDER BY bl.timestamp
|
|
1053
|
+
""")
|
|
1054
|
+
rows = result.rows_as_dict().get_all()
|
|
1055
|
+
return [
|
|
1056
|
+
{"source": r["source"], "target": r["target"], "timestamp": r["timestamp"]}
|
|
1057
|
+
for r in rows
|
|
1058
|
+
]
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def repair_links(self, skip_sources: Optional[Set[str]] = None) -> int:
|
|
1062
|
+
"""Attempt to repair broken links by re-checking if targets now exist.
|
|
1063
|
+
|
|
1064
|
+
Scans all tracked broken links. A record repairs when its target is
|
|
1065
|
+
now an existing concept id — or when it is a ``[[wikiname]]`` that
|
|
1066
|
+
now resolves to exactly one concept via the name index (uid → alias
|
|
1067
|
+
→ title → stem). Ambiguous names are left alone, never guessed.
|
|
1068
|
+
|
|
1069
|
+
Returns:
|
|
1070
|
+
Number of links successfully repaired.
|
|
1071
|
+
"""
|
|
1072
|
+
skip_sources = skip_sources or set()
|
|
1073
|
+
broken = self.list_broken_links()
|
|
1074
|
+
if not broken:
|
|
1075
|
+
return 0
|
|
1076
|
+
maps, known_ids = self._load_link_index()
|
|
1077
|
+
repaired = 0
|
|
1078
|
+
for link in broken:
|
|
1079
|
+
source_id = link["source"]
|
|
1080
|
+
raw_target = link["target"]
|
|
1081
|
+
if source_id in skip_sources:
|
|
1082
|
+
# reviewed:true concepts are never modified (doctor --fix).
|
|
1083
|
+
continue
|
|
1084
|
+
link_id = f"{source_id}→{raw_target}"
|
|
1085
|
+
|
|
1086
|
+
if source_id not in known_ids:
|
|
1087
|
+
continue
|
|
1088
|
+
if raw_target in known_ids:
|
|
1089
|
+
target_id = raw_target
|
|
1090
|
+
else:
|
|
1091
|
+
# Path-style miss that is really a wikilink, or vice versa:
|
|
1092
|
+
# try name resolution before giving up.
|
|
1093
|
+
target_id = resolve_wiki(raw_target, maps, known_ids)
|
|
1094
|
+
if target_id is None:
|
|
1095
|
+
continue
|
|
1096
|
+
|
|
1097
|
+
self.conn.execute("""
|
|
1098
|
+
MATCH (source:Concept {id: $source})
|
|
1099
|
+
MATCH (target:Concept {id: $target})
|
|
1100
|
+
MERGE (source)-[:LINKS_TO]->(target)
|
|
1101
|
+
""", {"source": source_id, "target": target_id})
|
|
1102
|
+
# Remove the BrokenLink record
|
|
1103
|
+
self.conn.execute(
|
|
1104
|
+
"MATCH (bl:BrokenLink {id: $id}) DELETE bl",
|
|
1105
|
+
{"id": link_id},
|
|
1106
|
+
)
|
|
1107
|
+
repaired += 1
|
|
1108
|
+
|
|
1109
|
+
return repaired
|
|
1110
|
+
|