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.
@@ -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
+