deeptutor-knowledge 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. deeptutor/api/routers/knowledge.py +5225 -0
  2. deeptutor/api/routers/marginnote4.py +278 -0
  3. deeptutor/capabilities/ima/__init__.py +6 -0
  4. deeptutor/capabilities/ima/binding.py +118 -0
  5. deeptutor/capabilities/ima/capability.py +92 -0
  6. deeptutor/capabilities/ima/prompts/en/system.md +33 -0
  7. deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
  8. deeptutor/capabilities/ima/tools.py +476 -0
  9. deeptutor/capabilities/marginnote4/__init__.py +13 -0
  10. deeptutor/capabilities/marginnote4/binding.py +66 -0
  11. deeptutor/capabilities/marginnote4/capability.py +80 -0
  12. deeptutor/capabilities/marginnote4/models.py +173 -0
  13. deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
  14. deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
  15. deeptutor/capabilities/marginnote4/store.py +562 -0
  16. deeptutor/capabilities/marginnote4/tools.py +329 -0
  17. deeptutor/capabilities/obsidian/__init__.py +6 -0
  18. deeptutor/capabilities/obsidian/binding.py +75 -0
  19. deeptutor/capabilities/obsidian/capability.py +82 -0
  20. deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
  21. deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
  22. deeptutor/capabilities/obsidian/tools.py +399 -0
  23. deeptutor/capabilities/obsidian/vault.py +341 -0
  24. deeptutor/knowledge/__init__.py +28 -0
  25. deeptutor/knowledge/add_documents.py +734 -0
  26. deeptutor/knowledge/indexing_run.py +432 -0
  27. deeptutor/knowledge/initializer.py +448 -0
  28. deeptutor/knowledge/manager.py +2570 -0
  29. deeptutor/knowledge/module_service.py +29 -0
  30. deeptutor/knowledge/naming.py +40 -0
  31. deeptutor/knowledge/progress_events.py +45 -0
  32. deeptutor/knowledge/progress_tracker.py +355 -0
  33. deeptutor/knowledge/staging.py +69 -0
  34. deeptutor/services/config/knowledge_base_config.py +239 -0
  35. deeptutor/services/embedding/__init__.py +46 -0
  36. deeptutor/services/embedding/adapters/__init__.py +40 -0
  37. deeptutor/services/embedding/adapters/base.py +196 -0
  38. deeptutor/services/embedding/adapters/cohere.py +176 -0
  39. deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
  40. deeptutor/services/embedding/adapters/gemini.py +462 -0
  41. deeptutor/services/embedding/adapters/jina.py +161 -0
  42. deeptutor/services/embedding/adapters/ollama.py +145 -0
  43. deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
  44. deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
  45. deeptutor/services/embedding/client.py +323 -0
  46. deeptutor/services/embedding/config.py +114 -0
  47. deeptutor/services/embedding/request_options.py +46 -0
  48. deeptutor/services/embedding/validation.py +128 -0
  49. deeptutor/services/github_source/__init__.py +5 -0
  50. deeptutor/services/github_source/client.py +164 -0
  51. deeptutor/services/github_source/sync.py +384 -0
  52. deeptutor/services/github_source/sync_service.py +72 -0
  53. deeptutor/services/rag/__init__.py +21 -0
  54. deeptutor/services/rag/embedding_binding.py +331 -0
  55. deeptutor/services/rag/embedding_signature.py +63 -0
  56. deeptutor/services/rag/eval/__init__.py +92 -0
  57. deeptutor/services/rag/eval/dataset.py +244 -0
  58. deeptutor/services/rag/eval/diff.py +312 -0
  59. deeptutor/services/rag/eval/matching.py +218 -0
  60. deeptutor/services/rag/eval/metrics.py +213 -0
  61. deeptutor/services/rag/eval/report.py +111 -0
  62. deeptutor/services/rag/eval/runner.py +309 -0
  63. deeptutor/services/rag/factory.py +369 -0
  64. deeptutor/services/rag/file_routing.py +5 -0
  65. deeptutor/services/rag/index_probe.py +327 -0
  66. deeptutor/services/rag/index_versioning.py +387 -0
  67. deeptutor/services/rag/kb_paths.py +50 -0
  68. deeptutor/services/rag/linked_kb.py +237 -0
  69. deeptutor/services/rag/pipelines/__init__.py +7 -0
  70. deeptutor/services/rag/pipelines/base.py +41 -0
  71. deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
  72. deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
  73. deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
  74. deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
  75. deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
  76. deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
  77. deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
  78. deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
  79. deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
  80. deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
  81. deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
  82. deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
  83. deeptutor/services/rag/pipelines/ima/client.py +364 -0
  84. deeptutor/services/rag/pipelines/ima/config.py +140 -0
  85. deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
  86. deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
  87. deeptutor/services/rag/pipelines/ima/media.py +190 -0
  88. deeptutor/services/rag/pipelines/ima/models.py +348 -0
  89. deeptutor/services/rag/pipelines/ima/notes.py +240 -0
  90. deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
  91. deeptutor/services/rag/pipelines/ima/probe.py +105 -0
  92. deeptutor/services/rag/pipelines/ima/sources.py +105 -0
  93. deeptutor/services/rag/pipelines/ima/transport.py +130 -0
  94. deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
  95. deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
  96. deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
  97. deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
  98. deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
  99. deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
  100. deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
  101. deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
  102. deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
  103. deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
  104. deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
  105. deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
  106. deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
  107. deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
  108. deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
  109. deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
  110. deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
  111. deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
  112. deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
  113. deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
  114. deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
  115. deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
  116. deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
  117. deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
  118. deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
  119. deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
  120. deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
  121. deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
  122. deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
  123. deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
  124. deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
  125. deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
  126. deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
  127. deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
  128. deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
  129. deeptutor/services/rag/pipelines/modes.py +48 -0
  130. deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
  131. deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
  132. deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
  133. deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
  134. deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
  135. deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
  136. deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
  137. deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
  138. deeptutor/services/rag/pipelines/weknora/client.py +106 -0
  139. deeptutor/services/rag/pipelines/weknora/config.py +56 -0
  140. deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
  141. deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
  142. deeptutor/services/rag/preflight.py +274 -0
  143. deeptutor/services/rag/provider_binding.py +70 -0
  144. deeptutor/services/rag/query_rewrite.py +186 -0
  145. deeptutor/services/rag/service.py +379 -0
  146. deeptutor/services/rag/smart_retriever.py +81 -0
  147. deeptutor/services/rag/source_pages.py +292 -0
  148. deeptutor/services/rag/source_visuals.py +434 -0
  149. deeptutor/services/rag/visual_assets.py +430 -0
  150. deeptutor/services/rag/visual_coverage.py +165 -0
  151. deeptutor/services/web_source/__init__.py +1 -0
  152. deeptutor/services/web_source/bilingual.py +255 -0
  153. deeptutor/services/web_source/crawler.py +681 -0
  154. deeptutor/services/web_source/html_extractor.py +643 -0
  155. deeptutor/services/web_source/markdown.py +28 -0
  156. deeptutor/services/web_source/navigation.py +72 -0
  157. deeptutor/services/web_source/repository.py +640 -0
  158. deeptutor/services/web_source/robots.py +182 -0
  159. deeptutor/services/web_source/scheduler.py +336 -0
  160. deeptutor/services/web_source/snapshot_assets.py +171 -0
  161. deeptutor/services/web_source/sync.py +253 -0
  162. deeptutor/services/workspace/kb_move.py +446 -0
  163. deeptutor/tools/rag_tool.py +110 -0
  164. deeptutor_cli/kb.py +756 -0
  165. deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
  166. deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
  167. deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
  168. deeptutor_knowledge-1.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,341 @@
1
+ """Pure filesystem operations over a connected Obsidian vault.
2
+
3
+ This module is the *only* place that knows Obsidian's on-disk conventions:
4
+
5
+ * notes are ``.md`` files anywhere under the vault root;
6
+ * metadata is YAML frontmatter fenced by ``---`` at the top of a note;
7
+ * links are ``[[wikilinks]]`` (optionally ``[[Note#heading|alias]]``);
8
+ * ``.obsidian`` / ``.trash`` / ``.git`` are vault internals to ignore.
9
+
10
+ Every function takes the vault root explicitly and returns plain data — no
11
+ dependency on the chat loop, tools, or capability machinery, so it is trivial
12
+ to unit-test and reuse. Writes are additive only (create / append / set a
13
+ property); nothing here edits-in-place or deletes existing prose.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ import re
20
+ from typing import Any
21
+
22
+ import yaml
23
+
24
+ # Vault-internal folders that hold app state, not user notes.
25
+ IGNORED_DIRS: frozenset[str] = frozenset({".obsidian", ".trash", ".git"})
26
+
27
+ # Target of a wikilink, up to the first of ``#`` (heading), ``^`` (block), or
28
+ # ``|`` (display alias). ``![[...]]`` embeds share the same target syntax.
29
+ _WIKILINK_RE = re.compile(r"!?\[\[([^\]\n|#^]+)")
30
+ # Inline ``#tag`` (allowing nested ``#a/b``), not a Markdown heading (``# h``):
31
+ # requires a non-space immediately after ``#`` and not at line start after space.
32
+ _INLINE_TAG_RE = re.compile(r"(?:^|(?<=\s))#([A-Za-z0-9_][A-Za-z0-9_/-]*)")
33
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
34
+
35
+
36
+ class VaultError(Exception):
37
+ """A vault operation could not be completed (missing note, bad path, …)."""
38
+
39
+
40
+ # --- internals --------------------------------------------------------------
41
+
42
+
43
+ def _is_ignored(rel: Path) -> bool:
44
+ return any(part in IGNORED_DIRS for part in rel.parts)
45
+
46
+
47
+ def _iter_markdown(root: Path):
48
+ """Yield every user-facing ``.md`` file under ``root`` (ignored dirs skipped)."""
49
+ for path in sorted(root.rglob("*.md")):
50
+ if _is_ignored(path.relative_to(root)):
51
+ continue
52
+ yield path
53
+
54
+
55
+ def _read_text(path: Path) -> str:
56
+ """Decode a note for reading, tolerating bytes that are not valid UTF-8.
57
+
58
+ Vaults edited across editors, synced from Windows, or holding non-Latin
59
+ prose routinely carry a few mangled bytes — invisible in Obsidian itself.
60
+ Strict decoding would let one such note abort an operation that spans the
61
+ whole vault, so undecodable bytes are replaced and every decodable
62
+ character is kept.
63
+
64
+ Reads only. A read-modify-write must use :func:`_read_text_exact`, which
65
+ refuses to launder those bytes back onto disk.
66
+ """
67
+ return path.read_text(encoding="utf-8", errors="replace")
68
+
69
+
70
+ def _read_text_exact(path: Path) -> str:
71
+ """Decode a note that is about to be written back, without substitution.
72
+
73
+ Rewriting a note we could only partially decode would replace the user's
74
+ bytes with U+FFFD, so writes stay strict. Raising ``VaultError`` (rather
75
+ than letting ``UnicodeDecodeError`` escape) is what makes the refusal
76
+ legible: the tool layer turns it into a message naming the note and the
77
+ fix, instead of an opaque failure.
78
+ """
79
+ try:
80
+ return path.read_text(encoding="utf-8")
81
+ except UnicodeDecodeError as exc:
82
+ raise VaultError(
83
+ f"Note {path.name!r} is not valid UTF-8 (byte offset {exc.start}); "
84
+ "editing it would corrupt the undecodable bytes. Fix its encoding first."
85
+ ) from exc
86
+
87
+
88
+ def _safe_join(root: Path, rel: str) -> Path:
89
+ """Resolve ``rel`` under ``root``, refusing anything that escapes the vault."""
90
+ root_resolved = root.resolve()
91
+ candidate = (root_resolved / rel.lstrip("/")).resolve()
92
+ if candidate != root_resolved and root_resolved not in candidate.parents:
93
+ raise VaultError(f"Path {rel!r} escapes the vault.")
94
+ return candidate
95
+
96
+
97
+ def _with_md_suffix(rel: str) -> str:
98
+ return rel if rel.lower().endswith(".md") else f"{rel}.md"
99
+
100
+
101
+ def resolve_note(root: Path, ref: str) -> Path | None:
102
+ """Resolve a note reference to a file path the way Obsidian links do.
103
+
104
+ ``ref`` may be a vault-relative path (``folder/Note.md``) or a bare note
105
+ name (``Note``) matched by basename anywhere in the vault. Returns ``None``
106
+ when nothing matches.
107
+ """
108
+ ref = (ref or "").strip().split("#", 1)[0].split("|", 1)[0].strip()
109
+ if not ref:
110
+ return None
111
+ # Explicit path (has a separator or .md suffix) → resolve directly.
112
+ if "/" in ref or ref.lower().endswith(".md"):
113
+ candidate = _safe_join(root, _with_md_suffix(ref))
114
+ return candidate if candidate.is_file() else None
115
+ # Bare name → first .md whose stem matches (case-insensitive fallback).
116
+ wanted = ref.lower()
117
+ fallback: Path | None = None
118
+ for path in _iter_markdown(root):
119
+ if path.stem == ref:
120
+ return path
121
+ if fallback is None and path.stem.lower() == wanted:
122
+ fallback = path
123
+ return fallback
124
+
125
+
126
+ def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
127
+ """Split a note into (frontmatter dict, body). Tolerates malformed YAML."""
128
+ match = _FRONTMATTER_RE.match(text)
129
+ if not match:
130
+ return {}, text
131
+ try:
132
+ data = yaml.safe_load(match.group(1)) or {}
133
+ except yaml.YAMLError:
134
+ return {}, text
135
+ if not isinstance(data, dict):
136
+ return {}, text
137
+ return data, text[match.end() :]
138
+
139
+
140
+ def _compose_note(frontmatter: dict[str, Any], body: str) -> str:
141
+ if not frontmatter:
142
+ return body
143
+ fm = yaml.safe_dump(frontmatter, allow_unicode=True, sort_keys=False).strip()
144
+ return f"---\n{fm}\n---\n{body}"
145
+
146
+
147
+ def _rel(root: Path, path: Path) -> str:
148
+ return path.resolve().relative_to(root.resolve()).as_posix()
149
+
150
+
151
+ def _snippet(body: str, query: str, width: int = 160) -> str:
152
+ lowered = body.lower()
153
+ idx = lowered.find(query.lower())
154
+ if idx < 0:
155
+ return body[:width].strip().replace("\n", " ")
156
+ start = max(0, idx - width // 3)
157
+ return ("…" if start else "") + body[start : start + width].strip().replace("\n", " ") + "…"
158
+
159
+
160
+ # --- read operations --------------------------------------------------------
161
+
162
+
163
+ def read_note(root: Path, ref: str) -> dict[str, Any]:
164
+ """Return ``{path, frontmatter, body}`` for a note, or raise ``VaultError``."""
165
+ path = resolve_note(root, ref)
166
+ if path is None:
167
+ raise VaultError(f"Note {ref!r} not found in the vault.")
168
+ text = _read_text(path)
169
+ frontmatter, body = split_frontmatter(text)
170
+ return {"path": _rel(root, path), "frontmatter": frontmatter, "body": body}
171
+
172
+
173
+ def search_notes(root: Path, query: str, limit: int = 20) -> list[dict[str, str]]:
174
+ """Case-insensitive substring search over note titles and bodies."""
175
+ query = (query or "").strip()
176
+ if not query:
177
+ return []
178
+ needle = query.lower()
179
+ hits: list[dict[str, str]] = []
180
+ for path in _iter_markdown(root):
181
+ try:
182
+ text = _read_text(path)
183
+ except OSError:
184
+ continue
185
+ if needle in path.stem.lower() or needle in text.lower():
186
+ hits.append({"path": _rel(root, path), "snippet": _snippet(text, query)})
187
+ if len(hits) >= limit:
188
+ break
189
+ return hits
190
+
191
+
192
+ def list_notes(root: Path, folder: str = "", limit: int = 200) -> list[str]:
193
+ """List note paths (vault-relative), optionally under ``folder``."""
194
+ base = _safe_join(root, folder) if folder.strip() else root.resolve()
195
+ if not base.exists():
196
+ return []
197
+ out: list[str] = []
198
+ for path in _iter_markdown(base if base.is_dir() else root):
199
+ out.append(_rel(root, path))
200
+ if len(out) >= limit:
201
+ break
202
+ return out
203
+
204
+
205
+ def outgoing_links(root: Path, ref: str) -> list[str]:
206
+ """The wikilink targets a note points to, in order, de-duplicated."""
207
+ note = read_note(root, ref)
208
+ seen: dict[str, None] = {}
209
+ for target in _WIKILINK_RE.findall(note["body"]):
210
+ name = target.strip()
211
+ if name:
212
+ seen.setdefault(name, None)
213
+ return list(seen)
214
+
215
+
216
+ def backlinks(root: Path, ref: str, limit: int = 50) -> list[dict[str, str]]:
217
+ """Notes whose wikilinks resolve to the same destination as ``ref``."""
218
+ target = resolve_note(root, ref)
219
+ if target is None:
220
+ raise VaultError(f"Note {ref!r} not found in the vault.")
221
+ notes = list(_iter_markdown(root))
222
+ exact_names: dict[str, Path] = {}
223
+ folded_names: dict[str, Path] = {}
224
+ for note in notes:
225
+ exact_names.setdefault(note.stem, note)
226
+ folded_names.setdefault(note.stem.lower(), note)
227
+
228
+ def points_to_target(link: str) -> bool:
229
+ link = link.strip()
230
+ if "/" in link or link.lower().endswith(".md"):
231
+ try:
232
+ destination = resolve_note(root, link)
233
+ except VaultError:
234
+ return False
235
+ else:
236
+ destination = exact_names.get(link) or folded_names.get(link.lower())
237
+ return destination is not None and destination.resolve() == target.resolve()
238
+
239
+ out: list[dict[str, str]] = []
240
+ for path in notes:
241
+ if path == target:
242
+ continue
243
+ try:
244
+ text = _read_text(path)
245
+ except OSError:
246
+ continue
247
+ if any(points_to_target(link) for link in _WIKILINK_RE.findall(text)):
248
+ out.append({"path": _rel(root, path), "snippet": _snippet(text, target.stem)})
249
+ if len(out) >= limit:
250
+ break
251
+ return out
252
+
253
+
254
+ def collect_tags(root: Path, limit: int = 200) -> list[dict[str, Any]]:
255
+ """All tags across the vault (inline ``#tag`` + frontmatter ``tags``), by count."""
256
+ counts: dict[str, int] = {}
257
+
258
+ def bump(tag: str) -> None:
259
+ tag = tag.strip().lstrip("#")
260
+ if tag:
261
+ counts[tag] = counts.get(tag, 0) + 1
262
+
263
+ for path in _iter_markdown(root):
264
+ try:
265
+ text = _read_text(path)
266
+ except OSError:
267
+ continue
268
+ frontmatter, body = split_frontmatter(text)
269
+ fm_tags = frontmatter.get("tags")
270
+ if isinstance(fm_tags, str):
271
+ fm_tags = [fm_tags]
272
+ if isinstance(fm_tags, list):
273
+ for tag in fm_tags:
274
+ bump(str(tag))
275
+ for tag in _INLINE_TAG_RE.findall(body):
276
+ bump(tag)
277
+ ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
278
+ return [{"tag": tag, "count": count} for tag, count in ranked[:limit]]
279
+
280
+
281
+ # --- write operations (additive only) ---------------------------------------
282
+
283
+
284
+ def create_note(
285
+ root: Path,
286
+ rel_path: str,
287
+ content: str,
288
+ frontmatter: dict[str, Any] | None = None,
289
+ ) -> str:
290
+ """Create a new ``.md`` note. Refuses to overwrite an existing file."""
291
+ rel_path = (rel_path or "").strip()
292
+ if not rel_path:
293
+ raise VaultError("create_note needs a non-empty path.")
294
+ path = _safe_join(root, _with_md_suffix(rel_path))
295
+ if path.exists():
296
+ raise VaultError(f"Note {_with_md_suffix(rel_path)!r} already exists; use append instead.")
297
+ path.parent.mkdir(parents=True, exist_ok=True)
298
+ path.write_text(_compose_note(frontmatter or {}, content or ""), encoding="utf-8")
299
+ return _rel(root, path)
300
+
301
+
302
+ def append_note(root: Path, ref: str, content: str) -> str:
303
+ """Append text to the end of an existing note."""
304
+ path = resolve_note(root, ref)
305
+ if path is None:
306
+ raise VaultError(f"Note {ref!r} not found; create it first.")
307
+ existing = _read_text_exact(path)
308
+ separator = "" if existing.endswith("\n") or not existing else "\n"
309
+ path.write_text(existing + separator + (content or ""), encoding="utf-8")
310
+ return _rel(root, path)
311
+
312
+
313
+ def set_property(root: Path, ref: str, key: str, value: Any) -> str:
314
+ """Set a single frontmatter property on an existing note."""
315
+ key = (key or "").strip()
316
+ if not key:
317
+ raise VaultError("set_property needs a non-empty key.")
318
+ path = resolve_note(root, ref)
319
+ if path is None:
320
+ raise VaultError(f"Note {ref!r} not found; create it first.")
321
+ frontmatter, body = split_frontmatter(_read_text_exact(path))
322
+ frontmatter[key] = value
323
+ path.write_text(_compose_note(frontmatter, body), encoding="utf-8")
324
+ return _rel(root, path)
325
+
326
+
327
+ __all__ = [
328
+ "VaultError",
329
+ "IGNORED_DIRS",
330
+ "resolve_note",
331
+ "read_note",
332
+ "search_notes",
333
+ "list_notes",
334
+ "outgoing_links",
335
+ "backlinks",
336
+ "collect_tags",
337
+ "create_note",
338
+ "append_note",
339
+ "set_property",
340
+ "split_frontmatter",
341
+ ]
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python
2
+ """Knowledge base package exports (lazy-loaded)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ __all__ = [
9
+ "DocumentAdder",
10
+ "KnowledgeBaseInitializer",
11
+ "KnowledgeBaseManager",
12
+ ]
13
+
14
+
15
+ def __getattr__(name: str) -> Any:
16
+ if name == "DocumentAdder":
17
+ from .add_documents import DocumentAdder
18
+
19
+ return DocumentAdder
20
+ if name == "KnowledgeBaseInitializer":
21
+ from .initializer import KnowledgeBaseInitializer
22
+
23
+ return KnowledgeBaseInitializer
24
+ if name == "KnowledgeBaseManager":
25
+ from .manager import KnowledgeBaseManager
26
+
27
+ return KnowledgeBaseManager
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")