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.
- deeptutor/api/routers/knowledge.py +5225 -0
- deeptutor/api/routers/marginnote4.py +278 -0
- deeptutor/capabilities/ima/__init__.py +6 -0
- deeptutor/capabilities/ima/binding.py +118 -0
- deeptutor/capabilities/ima/capability.py +92 -0
- deeptutor/capabilities/ima/prompts/en/system.md +33 -0
- deeptutor/capabilities/ima/prompts/zh/system.md +24 -0
- deeptutor/capabilities/ima/tools.py +476 -0
- deeptutor/capabilities/marginnote4/__init__.py +13 -0
- deeptutor/capabilities/marginnote4/binding.py +66 -0
- deeptutor/capabilities/marginnote4/capability.py +80 -0
- deeptutor/capabilities/marginnote4/models.py +173 -0
- deeptutor/capabilities/marginnote4/prompts/en/system.md +54 -0
- deeptutor/capabilities/marginnote4/prompts/zh/system.md +44 -0
- deeptutor/capabilities/marginnote4/store.py +562 -0
- deeptutor/capabilities/marginnote4/tools.py +329 -0
- deeptutor/capabilities/obsidian/__init__.py +6 -0
- deeptutor/capabilities/obsidian/binding.py +75 -0
- deeptutor/capabilities/obsidian/capability.py +82 -0
- deeptutor/capabilities/obsidian/prompts/en/system.md +32 -0
- deeptutor/capabilities/obsidian/prompts/zh/system.md +28 -0
- deeptutor/capabilities/obsidian/tools.py +399 -0
- deeptutor/capabilities/obsidian/vault.py +341 -0
- deeptutor/knowledge/__init__.py +28 -0
- deeptutor/knowledge/add_documents.py +734 -0
- deeptutor/knowledge/indexing_run.py +432 -0
- deeptutor/knowledge/initializer.py +448 -0
- deeptutor/knowledge/manager.py +2570 -0
- deeptutor/knowledge/module_service.py +29 -0
- deeptutor/knowledge/naming.py +40 -0
- deeptutor/knowledge/progress_events.py +45 -0
- deeptutor/knowledge/progress_tracker.py +355 -0
- deeptutor/knowledge/staging.py +69 -0
- deeptutor/services/config/knowledge_base_config.py +239 -0
- deeptutor/services/embedding/__init__.py +46 -0
- deeptutor/services/embedding/adapters/__init__.py +40 -0
- deeptutor/services/embedding/adapters/base.py +196 -0
- deeptutor/services/embedding/adapters/cohere.py +176 -0
- deeptutor/services/embedding/adapters/dashscope_native.py +242 -0
- deeptutor/services/embedding/adapters/gemini.py +462 -0
- deeptutor/services/embedding/adapters/jina.py +161 -0
- deeptutor/services/embedding/adapters/ollama.py +145 -0
- deeptutor/services/embedding/adapters/openai_compatible.py +406 -0
- deeptutor/services/embedding/adapters/openai_sdk.py +150 -0
- deeptutor/services/embedding/client.py +323 -0
- deeptutor/services/embedding/config.py +114 -0
- deeptutor/services/embedding/request_options.py +46 -0
- deeptutor/services/embedding/validation.py +128 -0
- deeptutor/services/github_source/__init__.py +5 -0
- deeptutor/services/github_source/client.py +164 -0
- deeptutor/services/github_source/sync.py +384 -0
- deeptutor/services/github_source/sync_service.py +72 -0
- deeptutor/services/rag/__init__.py +21 -0
- deeptutor/services/rag/embedding_binding.py +331 -0
- deeptutor/services/rag/embedding_signature.py +63 -0
- deeptutor/services/rag/eval/__init__.py +92 -0
- deeptutor/services/rag/eval/dataset.py +244 -0
- deeptutor/services/rag/eval/diff.py +312 -0
- deeptutor/services/rag/eval/matching.py +218 -0
- deeptutor/services/rag/eval/metrics.py +213 -0
- deeptutor/services/rag/eval/report.py +111 -0
- deeptutor/services/rag/eval/runner.py +309 -0
- deeptutor/services/rag/factory.py +369 -0
- deeptutor/services/rag/file_routing.py +5 -0
- deeptutor/services/rag/index_probe.py +327 -0
- deeptutor/services/rag/index_versioning.py +387 -0
- deeptutor/services/rag/kb_paths.py +50 -0
- deeptutor/services/rag/linked_kb.py +237 -0
- deeptutor/services/rag/pipelines/__init__.py +7 -0
- deeptutor/services/rag/pipelines/base.py +41 -0
- deeptutor/services/rag/pipelines/graphrag/__init__.py +18 -0
- deeptutor/services/rag/pipelines/graphrag/compatibility.py +42 -0
- deeptutor/services/rag/pipelines/graphrag/completion_adapter.py +251 -0
- deeptutor/services/rag/pipelines/graphrag/config.py +357 -0
- deeptutor/services/rag/pipelines/graphrag/engine.py +479 -0
- deeptutor/services/rag/pipelines/graphrag/errors.py +328 -0
- deeptutor/services/rag/pipelines/graphrag/ingestion.py +135 -0
- deeptutor/services/rag/pipelines/graphrag/pandas_compat.py +68 -0
- deeptutor/services/rag/pipelines/graphrag/pipeline.py +284 -0
- deeptutor/services/rag/pipelines/graphrag/provider.py +134 -0
- deeptutor/services/rag/pipelines/graphrag/storage.py +127 -0
- deeptutor/services/rag/pipelines/ima/__init__.py +28 -0
- deeptutor/services/rag/pipelines/ima/client.py +364 -0
- deeptutor/services/rag/pipelines/ima/config.py +140 -0
- deeptutor/services/rag/pipelines/ima/envelope.py +109 -0
- deeptutor/services/rag/pipelines/ima/inventory.py +209 -0
- deeptutor/services/rag/pipelines/ima/media.py +190 -0
- deeptutor/services/rag/pipelines/ima/models.py +348 -0
- deeptutor/services/rag/pipelines/ima/notes.py +240 -0
- deeptutor/services/rag/pipelines/ima/pipeline.py +238 -0
- deeptutor/services/rag/pipelines/ima/probe.py +105 -0
- deeptutor/services/rag/pipelines/ima/sources.py +105 -0
- deeptutor/services/rag/pipelines/ima/transport.py +130 -0
- deeptutor/services/rag/pipelines/kiwix/__init__.py +1 -0
- deeptutor/services/rag/pipelines/kiwix/client.py +369 -0
- deeptutor/services/rag/pipelines/kiwix/pipeline.py +75 -0
- deeptutor/services/rag/pipelines/lightrag/README.md +78 -0
- deeptutor/services/rag/pipelines/lightrag/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag/block_policy.py +266 -0
- deeptutor/services/rag/pipelines/lightrag/cache_reuse.py +137 -0
- deeptutor/services/rag/pipelines/lightrag/config.py +453 -0
- deeptutor/services/rag/pipelines/lightrag/engine.py +536 -0
- deeptutor/services/rag/pipelines/lightrag/indexing_policy.py +696 -0
- deeptutor/services/rag/pipelines/lightrag/ingress.py +364 -0
- deeptutor/services/rag/pipelines/lightrag/parser.py +99 -0
- deeptutor/services/rag/pipelines/lightrag/pipeline.py +692 -0
- deeptutor/services/rag/pipelines/lightrag/roles.py +132 -0
- deeptutor/services/rag/pipelines/lightrag/sidecar.py +320 -0
- deeptutor/services/rag/pipelines/lightrag/storage.py +338 -0
- deeptutor/services/rag/pipelines/lightrag/worker.py +259 -0
- deeptutor/services/rag/pipelines/lightrag/write_lock.py +55 -0
- deeptutor/services/rag/pipelines/lightrag_server/__init__.py +15 -0
- deeptutor/services/rag/pipelines/lightrag_server/client.py +149 -0
- deeptutor/services/rag/pipelines/lightrag_server/config.py +68 -0
- deeptutor/services/rag/pipelines/lightrag_server/pipeline.py +120 -0
- deeptutor/services/rag/pipelines/lightrag_server/probe.py +110 -0
- deeptutor/services/rag/pipelines/llamaindex/__init__.py +3 -0
- deeptutor/services/rag/pipelines/llamaindex/config.py +199 -0
- deeptutor/services/rag/pipelines/llamaindex/document_loader.py +648 -0
- deeptutor/services/rag/pipelines/llamaindex/embedding_adapter.py +264 -0
- deeptutor/services/rag/pipelines/llamaindex/errors.py +76 -0
- deeptutor/services/rag/pipelines/llamaindex/exercise_lookup.py +373 -0
- deeptutor/services/rag/pipelines/llamaindex/ingestion.py +129 -0
- deeptutor/services/rag/pipelines/llamaindex/pipeline.py +556 -0
- deeptutor/services/rag/pipelines/llamaindex/rerank.py +130 -0
- deeptutor/services/rag/pipelines/llamaindex/retrievers.py +360 -0
- deeptutor/services/rag/pipelines/llamaindex/storage.py +655 -0
- deeptutor/services/rag/pipelines/llamaindex/vector_store.py +288 -0
- deeptutor/services/rag/pipelines/modes.py +48 -0
- deeptutor/services/rag/pipelines/pageindex/__init__.py +47 -0
- deeptutor/services/rag/pipelines/pageindex/client.py +146 -0
- deeptutor/services/rag/pipelines/pageindex/config.py +49 -0
- deeptutor/services/rag/pipelines/pageindex/pipeline.py +313 -0
- deeptutor/services/rag/pipelines/pageindex/reasoning.py +262 -0
- deeptutor/services/rag/pipelines/pageindex/storage.py +133 -0
- deeptutor/services/rag/pipelines/pageindex/tools.py +221 -0
- deeptutor/services/rag/pipelines/weknora/__init__.py +13 -0
- deeptutor/services/rag/pipelines/weknora/client.py +106 -0
- deeptutor/services/rag/pipelines/weknora/config.py +56 -0
- deeptutor/services/rag/pipelines/weknora/pipeline.py +74 -0
- deeptutor/services/rag/pipelines/weknora/probe.py +80 -0
- deeptutor/services/rag/preflight.py +274 -0
- deeptutor/services/rag/provider_binding.py +70 -0
- deeptutor/services/rag/query_rewrite.py +186 -0
- deeptutor/services/rag/service.py +379 -0
- deeptutor/services/rag/smart_retriever.py +81 -0
- deeptutor/services/rag/source_pages.py +292 -0
- deeptutor/services/rag/source_visuals.py +434 -0
- deeptutor/services/rag/visual_assets.py +430 -0
- deeptutor/services/rag/visual_coverage.py +165 -0
- deeptutor/services/web_source/__init__.py +1 -0
- deeptutor/services/web_source/bilingual.py +255 -0
- deeptutor/services/web_source/crawler.py +681 -0
- deeptutor/services/web_source/html_extractor.py +643 -0
- deeptutor/services/web_source/markdown.py +28 -0
- deeptutor/services/web_source/navigation.py +72 -0
- deeptutor/services/web_source/repository.py +640 -0
- deeptutor/services/web_source/robots.py +182 -0
- deeptutor/services/web_source/scheduler.py +336 -0
- deeptutor/services/web_source/snapshot_assets.py +171 -0
- deeptutor/services/web_source/sync.py +253 -0
- deeptutor/services/workspace/kb_move.py +446 -0
- deeptutor/tools/rag_tool.py +110 -0
- deeptutor_cli/kb.py +756 -0
- deeptutor_knowledge-1.7.0.dist-info/METADATA +15 -0
- deeptutor_knowledge-1.7.0.dist-info/RECORD +168 -0
- deeptutor_knowledge-1.7.0.dist-info/WHEEL +5 -0
- 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}")
|