handrolled 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
handrolled/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Hand-rolled, stdlib-only replacements for things you would normally pip install.
2
+
3
+ Every module here exists for the same reason: the obvious solution adds a
4
+ dependency, and the dependency is bigger than the problem.
5
+
6
+ * :mod:`handrolled.text` — CJK bigrams, FTS5 match expressions and a
7
+ YAML-subset front matter reader (instead of jieba, a query builder and PyYAML)
8
+ * :mod:`handrolled.webdav` — a WebDAV client speaking the RFC 4918 subset that
9
+ actually gets used (instead of requests + webdavclient3)
10
+ * :mod:`handrolled.mounts` — network mount inspection that reports what is
11
+ really mounted, not its autofs trigger (instead of parsing ``mount`` output)
12
+ * :mod:`handrolled.pages` + :mod:`handrolled.importers` — turn another tool's
13
+ export (mem0, Obsidian, Notion, basic-memory, JSON/JSONL) into Markdown pages
14
+ * :mod:`handrolled.rerank`, :mod:`handrolled.deeprank`,
15
+ :mod:`handrolled.embed_onnx` — optional ranking helpers that degrade to
16
+ ``None`` when their model or dependency is absent
17
+
18
+ Nothing here imports a third-party package at module import time; the optional
19
+ ones import inside functions and return ``None`` when the dependency is missing.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = ["__version__"]
handrolled/deeprank.py ADDED
@@ -0,0 +1,183 @@
1
+ """深池 LLM 重排(`deep=true` 慢车道)。
2
+
3
+ 为什么单独一层而不是改主链路:
4
+ * 主链路要快(p95 < 1s),LLM 一次 5~15s,只能按需触发;
5
+ * 需要「看得到更远的候选」——先把候选扩到 40 页,再让模型挑;
6
+ * 失败必须静默降级:API 挂了就退回普通排序,绝不影响检索可用性。
7
+
8
+ 一次 33 题评测:普通 25/33 → 深池 + 挑 1 页 = 26/33,代价 p50 8.4s。
9
+ 单次用量约 3.7k 输入 token / 120 输出 token。
10
+
11
+ 注意一个实测过的失败模式:候选多时模型会报出**超出范围的编号**(例如只有 30 页却答 31)。
12
+ 所以解析不到合法编号时,会用「前 10 页」重试一次,仍然失败才降级。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.request
21
+ from typing import Sequence
22
+
23
+ #: This is an opt-in slow lane: nothing below runs unless the deployment says
24
+ #: where to send the request and with which credentials. No vendor endpoint,
25
+ #: no key file and no log path is assumed.
26
+ DEFAULT_MODEL = ""
27
+ DEFAULT_BASE = ""
28
+ CRED_PATH = os.environ.get("DSH_KB_DEEP_CREDENTIALS") or ""
29
+
30
+ LAST_RAW = ""
31
+ LAST_ERROR = ""
32
+ LOG_PATH = os.environ.get("DSH_KB_DEEP_LOG") or ""
33
+
34
+ PROMPT = """用户问题:%s
35
+
36
+ 下面是知识库候选页面(编号 1-%d,含该页最相关片段)。请挑出最可能回答了该问题的 %d 个页面,
37
+ 按可能性从高到低输出编号,逗号分隔,只输出编号(必须是 1 到 %d 之间的整数)。
38
+
39
+ %s"""
40
+
41
+
42
+ def _api_key() -> str | None:
43
+ """API key: ``DSH_KB_DEEP_API_KEY`` first, then an optional credentials file.
44
+
45
+ The file is whatever ``DSH_KB_DEEP_CREDENTIALS`` points at and only has to
46
+ contain ``DEEP_API_KEY`` (top level or under ``refs``). YAML is used when
47
+ PyYAML happens to be importable, otherwise one line is matched with a regex —
48
+ the MCP server interpreter may not have yaml installed.
49
+ """
50
+ global LAST_ERROR
51
+ key = (os.environ.get("DSH_KB_DEEP_API_KEY") or "").strip()
52
+ if key:
53
+ return key
54
+ if not CRED_PATH:
55
+ LAST_ERROR = "no-key"
56
+ return None
57
+ try:
58
+ raw = open(CRED_PATH, encoding="utf-8").read()
59
+ except Exception as exc:
60
+ LAST_ERROR = "cred-read: %s" % type(exc).__name__
61
+ return None
62
+ try:
63
+ import yaml
64
+
65
+ data = yaml.safe_load(raw) or {}
66
+ refs = data.get("refs") or data
67
+ value = refs.get("DEEP_API_KEY")
68
+ if value:
69
+ return str(value)
70
+ except Exception:
71
+ pass
72
+ m = re.search(r"^\s*DEEP_API_KEY\s*:\s*(\S+)", raw, re.M)
73
+ if m:
74
+ return m.group(1).strip().strip(chr(34)).strip(chr(39))
75
+ LAST_ERROR = "no-key"
76
+ return None
77
+
78
+
79
+ def _endpoint() -> str:
80
+ """OpenAI-compatible base URL, without the trailing slash."""
81
+ return (os.environ.get("DSH_KB_DEEP_ENDPOINT") or DEFAULT_BASE).rstrip("/")
82
+
83
+
84
+ def _parse_order(text: str, n: int, pick: int) -> list[int]:
85
+ """把「3,1,7」解析成 1 基下标;越界与重复丢掉。"""
86
+ out: list[int] = []
87
+ for raw in re.findall(r"\d+", text or ""):
88
+ try:
89
+ idx = int(raw)
90
+ except ValueError:
91
+ continue
92
+ if 1 <= idx <= n and idx not in out:
93
+ out.append(idx)
94
+ if len(out) >= pick:
95
+ break
96
+ return out
97
+
98
+
99
+ def _request(query: str, pages: Sequence[tuple[str, str]], *, key: str, model: str,
100
+ pick: int, timeout: float) -> tuple[list[int], str]:
101
+ blocks = ["[%d] %s" % (i, path.split("/")[-1]) + chr(10) + (body or "")[:320]
102
+ for i, (path, body) in enumerate(pages, 1)]
103
+ prompt = PROMPT % (query, len(pages), pick, len(pages), chr(10) + chr(10).join(blocks))
104
+ body = json.dumps({"model": model, "temperature": 0, "max_tokens": 400,
105
+ "messages": [{"role": "user", "content": prompt}]}).encode("utf-8")
106
+ req = urllib.request.Request(_endpoint() + "/chat/completions", data=body,
107
+ headers={"Content-Type": "application/json",
108
+ "Authorization": "Bearer " + key})
109
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
110
+ data = json.loads(resp.read().decode("utf-8"))
111
+ text = ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
112
+ return _parse_order(text, len(pages), pick), text
113
+
114
+
115
+ def _log_failure(query: str, error: str, raw: str) -> None:
116
+ """降级时留一行证据:MCP 进程看不到 stdout,只有文件能查。
117
+
118
+ Nothing is written unless ``DSH_KB_DEEP_LOG`` is set — a library has no
119
+ business picking a log location on someone else's machine.
120
+ """
121
+ if not LOG_PATH:
122
+ return
123
+ try:
124
+ from datetime import datetime
125
+
126
+ line = "%s\t%s\t%s\t%s" % (
127
+ datetime.now().isoformat(timespec="seconds"),
128
+ error,
129
+ (query or "")[:40].replace("\t", " "),
130
+ (raw or "").replace("\t", " ")[:40],
131
+ )
132
+ with open(LOG_PATH, "a", encoding="utf-8") as fh:
133
+ fh.write(line + chr(10))
134
+ except Exception:
135
+ pass
136
+
137
+
138
+ def pick_pages(
139
+ query: str,
140
+ pages: Sequence[tuple[str, str]],
141
+ *,
142
+ model: str = DEFAULT_MODEL,
143
+ pick: int = 1,
144
+ timeout: float = 25.0,
145
+ ) -> list[int] | None:
146
+ """让模型从候选页面里挑 pick 个;失败返回 None。
147
+
148
+ ``pages`` 是 [(path, passage)],passage 由调用方切好(避免这里再读盘)。
149
+ """
150
+ global LAST_RAW, LAST_ERROR
151
+ LAST_RAW, LAST_ERROR = "", ""
152
+ if not pages:
153
+ return []
154
+ key = _api_key()
155
+ if not key:
156
+ LAST_ERROR = "no-key"
157
+ return None
158
+ if not _endpoint() or not model:
159
+ LAST_ERROR = "no-endpoint-or-model"
160
+ return None
161
+ for attempt, subset in enumerate((list(pages), list(pages)[:10])):
162
+ if attempt and len(subset) >= len(pages):
163
+ break
164
+ try:
165
+ order, text = _request(query, subset, key=key, model=model, pick=pick,
166
+ timeout=timeout if attempt == 0 else min(timeout, 15.0))
167
+ except Exception as exc: # noqa: BLE001 - 任何失败都降级
168
+ LAST_ERROR = "%s: %s" % (type(exc).__name__, str(exc)[:120])
169
+ continue
170
+ LAST_RAW = (text or "").strip()[:80]
171
+ if order:
172
+ return order
173
+ LAST_ERROR = "unparsable"
174
+ _log_failure(query, LAST_ERROR, LAST_RAW)
175
+ return None
176
+
177
+
178
+ def reorder(items: Sequence, order: Sequence[int]) -> list:
179
+ """按 1 基下标把挑中的提到最前,其余保序。"""
180
+ idx = [i - 1 for i in order if 0 < i <= len(items)]
181
+ picked = [items[i] for i in idx]
182
+ rest = [x for j, x in enumerate(items) if j not in set(idx)]
183
+ return picked + rest
@@ -0,0 +1,168 @@
1
+ """ONNX 嵌入后端(Qwen3-Embedding-0.6B int8)—— 换模型 A/B 实验用。
2
+
3
+ 为什么单独一个模块:
4
+ * 生产嵌入走 ollama(bge-m3)。要评估"换模型能不能把召回抬上去",必须能在
5
+ 不改生产代码路径的前提下换掉**嵌入实现**,同时仍复用 VectorArm 的矩阵缓存、
6
+ 页级去重、重排与融合逻辑 —— 那些是逻辑资产,不该跟着模型一起换。
7
+ * 模型是 int8 ONNX(约 610 MB),本仓库不附带、不下载;路径由
8
+ ``DSH_KB_EMBED_ONNX_ROOT`` 指定(其下 ``onnx/model_int8.onnx`` + ``tokenizer.json``),
9
+ 依赖库目录由 ``DSH_KB_ONNX_LIB`` 指定(可选,能 import 到即可)。
10
+
11
+ 池化方式(Qwen3-Embedding 官方口径):
12
+ * 取**最后一个 token** 的 hidden state(不是 CLS,也不是 mean);
13
+ * L2 归一化;
14
+ * 查询侧加指令前缀 ``Instruct: {task}\nQuery: {query}``,文档侧不加 —— 这是它
15
+ 与非对称检索模型拉开差距的地方,和 bge-m3 的用法不同,别混。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ import threading
23
+ from pathlib import Path
24
+
25
+ __all__ = ["available", "embed_texts", "embed_query", "dim", "MODEL_NAME"]
26
+
27
+ #: Both are unset by default: the ONNX backend is an experiment, not a shipped path.
28
+ _ROOT = os.environ.get("DSH_KB_EMBED_ONNX_ROOT")
29
+ _LIB = os.environ.get("DSH_KB_ONNX_LIB")
30
+ ROOT = Path(_ROOT) if _ROOT else None
31
+ LIB = Path(_LIB) if _LIB else None
32
+ MODEL_NAME = os.environ.get("DSH_KB_EMBED_ONNX_NAME", "qwen3-embedding-0.6b-int8")
33
+ INSTRUCTION = os.environ.get(
34
+ "DSH_KB_EMBED_INSTRUCTION",
35
+ "Given a web search query, retrieve relevant passages that answer the query",
36
+ )
37
+ DIM = 1024
38
+ MAX_LEN = int(os.environ.get("DSH_KB_EMBED_MAXLEN", "512"))
39
+ BATCH = int(os.environ.get("DSH_KB_EMBED_BATCH", "8"))
40
+
41
+ _LOCK = threading.Lock()
42
+ _SESSION = None
43
+ _TOKENIZER = None
44
+ _INPUT_NAMES = ()
45
+ _INPUT_META = ()
46
+
47
+
48
+ def _lib_path() -> None:
49
+ if LIB is not None and LIB.is_dir() and str(LIB) not in sys.path:
50
+ sys.path.insert(0, str(LIB))
51
+
52
+
53
+ def available() -> bool:
54
+ """模型文件齐全且能 import 依赖时才为 True(不加载模型,不看网络)。"""
55
+ if ROOT is None:
56
+ return False
57
+ need = [ROOT / "onnx" / "model_int8.onnx", ROOT / "tokenizer.json"]
58
+ if not all(p.is_file() and p.stat().st_size > 1000 for p in need):
59
+ return False
60
+ try:
61
+ _lib_path()
62
+ import numpy # noqa: F401
63
+ import onnxruntime # noqa: F401
64
+ import tokenizers # noqa: F401
65
+ except Exception:
66
+ return False
67
+ return True
68
+
69
+
70
+ def _load():
71
+ global _SESSION, _TOKENIZER, _INPUT_NAMES
72
+ if _SESSION is not None:
73
+ return _SESSION, _TOKENIZER
74
+ with _LOCK:
75
+ if _SESSION is not None:
76
+ return _SESSION, _TOKENIZER
77
+ _lib_path()
78
+ import onnxruntime as ort
79
+ from tokenizers import Tokenizer
80
+
81
+ opts = ort.SessionOptions()
82
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
83
+ opts.intra_op_num_threads = int(os.environ.get("DSH_KB_EMBED_THREADS", "4"))
84
+ opts.inter_op_num_threads = 1
85
+ # 内存:推理 arena 会一直占着,和别的常驻服务共用一台小机器时很痛。默认
86
+ # 中间张量池留到进程结束(实测 4 进程各占 3G),关掉后 RSS 降到 ~1.3G。
87
+ opts.enable_cpu_mem_arena = os.environ.get("DSH_KB_EMBED_ARENA", "0") == "1"
88
+ sess = ort.InferenceSession(
89
+ str(ROOT / "onnx" / "model_int8.onnx"), opts, providers=["CPUExecutionProvider"]
90
+ )
91
+ tok = Tokenizer.from_file(str(ROOT / "tokenizer.json"))
92
+ tok.enable_truncation(max_length=MAX_LEN)
93
+ tok.no_padding()
94
+ _SESSION, _TOKENIZER = sess, tok
95
+ global _INPUT_META
96
+ _INPUT_NAMES = tuple(i.name for i in sess.get_inputs())
97
+ _INPUT_META = tuple((i.name, list(i.shape or ())) for i in sess.get_inputs())
98
+ return sess, tok
99
+
100
+
101
+ def dim() -> int:
102
+ return DIM
103
+
104
+
105
+ def _postprocess(hidden, mask):
106
+ """last-token pooling(按 attention_mask 取每条最后一个真实 token)+ L2。"""
107
+ import numpy as np
108
+
109
+ if isinstance(hidden, (list, tuple)):
110
+ hidden = hidden[0]
111
+ arr = np.asarray(hidden)
112
+ if arr.ndim == 3:
113
+ idx = np.asarray(mask).sum(axis=1) - 1
114
+ idx = np.clip(idx, 0, arr.shape[1] - 1)
115
+ arr = arr[np.arange(arr.shape[0]), idx]
116
+ norms = np.linalg.norm(arr, axis=1, keepdims=True)
117
+ norms[norms == 0] = 1.0
118
+ return arr / norms
119
+
120
+
121
+ def embed_texts(texts, *, is_query: bool = False, batch_size: int | None = None):
122
+ """批量嵌入,返回 list[list[float]](float32 归一化向量)。"""
123
+ if isinstance(texts, str):
124
+ texts = [texts]
125
+ sess, tok = _load()
126
+ import numpy as np
127
+
128
+ bs = batch_size or BATCH
129
+ prepared = []
130
+ for t in texts:
131
+ body = str(t or "").strip()
132
+ if is_query:
133
+ body = "Instruct: " + INSTRUCTION + "\nQuery: " + body
134
+ prepared.append(body or " ")
135
+ out = []
136
+ for i in range(0, len(prepared), bs):
137
+ chunk = prepared[i : i + bs]
138
+ encs = tok.encode_batch(chunk)
139
+ maxlen = max(len(e.ids) for e in encs)
140
+ ids = np.zeros((len(encs), maxlen), dtype="int64")
141
+ mask = np.zeros((len(encs), maxlen), dtype="int64")
142
+ for r, e in enumerate(encs):
143
+ ids[r, : len(e.ids)] = e.ids
144
+ mask[r, : len(e.ids)] = 1
145
+ feeds = {
146
+ "input_ids": ids,
147
+ "attention_mask": mask,
148
+ "position_ids": np.tile(np.arange(maxlen, dtype="int64"), (len(encs), 1)),
149
+ }
150
+ # 这个 ONNX 导出是「带 KV cache」的解码器图:56 个 past 输入必须显式给空张量
151
+ # (past_sequence_length=0)。不给就直接 ValueError;给了等价于无缓存的整段前向,
152
+ # 我们要的正是 last_hidden_state 整段。
153
+ for name, meta in _INPUT_META:
154
+ if name in feeds:
155
+ continue
156
+ if name.startswith("past_key_values"):
157
+ shape = [len(encs) if str(s).startswith("batch") else int(s)
158
+ if not str(s).startswith("past") else 0
159
+ for s in (meta or [])]
160
+ feeds[name] = np.zeros(shape or (len(encs), 1, 0, 1), dtype="float32")
161
+ hidden = sess.run(None, feeds)
162
+ vecs = _postprocess(hidden, mask)
163
+ out.extend([[float(x) for x in row] for row in vecs])
164
+ return out
165
+
166
+
167
+ def embed_query(text: str):
168
+ return embed_texts([text], is_query=True)[0]
@@ -0,0 +1,68 @@
1
+ """Adapters that turn somebody else's export into plain records.
2
+
3
+ Every adapter is a function ``load(path, **options) -> list[Record]`` that reads
4
+ one file or directory and returns plain records. Nothing here touches the
5
+ network, imports a third-party package, or knows where the knowledge base lives:
6
+ the renderer in :mod:`handrolled.pages` decides that.
7
+
8
+ Adding an adapter is one module plus one entry in :data:`ADAPTERS`. The rule for
9
+ accepting one is the same everywhere: if the format cannot be read with the
10
+ standard library and a documented shape, it does not belong here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Record:
22
+ """One unit of knowledge, before records are grouped into a page."""
23
+
24
+ content: str
25
+ #: The note's own title when the source has one (vault pages, basic-memory).
26
+ title: str = ""
27
+ id: str = ""
28
+ tags: tuple[str, ...] = ()
29
+ type: str = "note"
30
+ updated_at: str = ""
31
+ origin: str = ""
32
+ #: Provenance: a relative path inside the export, or an upstream identifier.
33
+ source: str = ""
34
+
35
+
36
+ #: adapter name -> (what it reads, module name)
37
+ ADAPTERS: dict[str, tuple[str, str]] = {
38
+ "records": ("the neutral records/v1 JSON every other format converts into", "records"),
39
+ "jsonl": ("one JSON object per line (content/text/memory/body)", "jsonl"),
40
+ "mem0": ("a mem0 memory export (JSON array or {\"results\": [...]})", "mem0"),
41
+ "obsidian": ("an Obsidian vault (Markdown + YAML front matter)", "obsidian"),
42
+ "notion": ("a Notion \"Markdown & CSV\" export directory", "notion"),
43
+ "basic-memory": ("basic-memory notes (observations + relations)", "basic_memory"),
44
+ }
45
+
46
+
47
+ def names() -> list[str]:
48
+ return sorted(ADAPTERS)
49
+
50
+
51
+ def describe() -> dict[str, str]:
52
+ return {name: description for name, (description, _) in ADAPTERS.items()}
53
+
54
+
55
+ def load(name: str, path: Path, **options) -> list[Record]:
56
+ """Run one adapter over ``path``.
57
+
58
+ Raises ``ValueError`` for an unknown adapter or an input that does not match
59
+ the shape it claims to read — silently importing nothing is the one outcome
60
+ worse than failing.
61
+ """
62
+ if name not in ADAPTERS:
63
+ raise ValueError(f"unknown format {name!r}; known formats: {', '.join(names())}")
64
+ target = Path(path)
65
+ if not target.exists():
66
+ raise ValueError(f"no such file or directory: {target}")
67
+ module = importlib.import_module(f"{__package__}.{ADAPTERS[name][1]}")
68
+ return module.load(target, **options)
@@ -0,0 +1,67 @@
1
+ """basic-memory notes.
2
+
3
+ basic-memory stores a note as Markdown with ``title``/``type``/``permalink`` in
4
+ the front matter and the knowledge itself as observation bullets:
5
+
6
+ - [decision] we chose SQLite over a server
7
+ - [fact] the corpus is 1.2 GB
8
+
9
+ The observation is the unit worth indexing, not the page — one note usually
10
+ holds several unrelated statements. When a page has no observations at all it
11
+ falls back to the whole body, so plain notes still import.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from . import Record
20
+ from .markdown import iter_markdown, one, page_record, strip_wikilinks, tags_of
21
+ from ..text import parse_frontmatter, split_frontmatter
22
+
23
+ _OBSERVATION = re.compile(r"^\s*[-*]\s*\[([^\]]+)\]\s*(.+?)\s*$", re.MULTILINE)
24
+
25
+
26
+ def load(path: Path, *, wikilinks: str = "strip", default_type: str = "note",
27
+ **_options) -> list[Record]:
28
+ out: list[Record] = []
29
+ for page in iter_markdown(path):
30
+ try:
31
+ raw = page.read_text(encoding="utf-8", errors="replace")
32
+ except OSError:
33
+ continue
34
+ front = parse_frontmatter(raw)
35
+ body, _ = split_frontmatter(raw)
36
+ if wikilinks == "strip":
37
+ body = strip_wikilinks(body)
38
+ try:
39
+ rel = str(page.relative_to(path))
40
+ except ValueError:
41
+ rel = page.name
42
+
43
+ observations = _OBSERVATION.findall(body)
44
+ if not observations:
45
+ record = page_record(page, path, default_type=default_type,
46
+ wikilinks=wikilinks, origin="basic-memory")
47
+ if record is not None:
48
+ out.append(record)
49
+ continue
50
+
51
+ tags = tags_of(front.get("tags"))
52
+ page_type = one(front.get("type")) or default_type
53
+ for index, (category, text) in enumerate(observations, 1):
54
+ text = text.strip()
55
+ if not text:
56
+ continue
57
+ out.append(Record(
58
+ content=text,
59
+ title=one(front.get("title")) or page.stem,
60
+ id=f"{rel}#{index}",
61
+ tags=tags,
62
+ type=category.strip() or page_type,
63
+ updated_at=one(front.get("updated")) or one(front.get("created")),
64
+ origin="basic-memory",
65
+ source=rel,
66
+ ))
67
+ return out
@@ -0,0 +1,35 @@
1
+ """JSON Lines — the lowest common denominator of "export everything".
2
+
3
+ One JSON object per line; blank lines and ``#`` comments are skipped so a file
4
+ can carry a header. A line that is a bare string becomes the content, which
5
+ covers ``{"content": ...}``-less exports without inventing a second format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from . import Record
14
+ from .records import coerce
15
+
16
+
17
+ def load(path: Path, *, default_type: str = "note", origin: str = "jsonl",
18
+ **_options) -> list[Record]:
19
+ out: list[Record] = []
20
+ text = path.read_text(encoding="utf-8", errors="replace")
21
+ for number, line in enumerate(text.splitlines(), 1):
22
+ stripped = line.strip()
23
+ if not stripped or stripped.startswith("#"):
24
+ continue
25
+ try:
26
+ item = json.loads(stripped)
27
+ except json.JSONDecodeError as exc:
28
+ raise ValueError(f"{path}:{number}: not valid JSON ({exc.msg})") from exc
29
+ if isinstance(item, str):
30
+ item = {"content": item}
31
+ record = coerce(item, default_type=default_type, origin=origin,
32
+ source=f"{path.name}:{number}")
33
+ if record is not None:
34
+ out.append(record)
35
+ return out
@@ -0,0 +1,137 @@
1
+ """Shared reader for Markdown vaults.
2
+
3
+ Obsidian, Notion's Markdown export and basic-memory all hand you a directory of
4
+ ``.md`` files whose front matter and headings differ only in convention. One
5
+ reader handles the conventions as options instead of three near-copies:
6
+
7
+ * front matter is read with :func:`handrolled.text.parse_frontmatter` (the same
8
+ zero-dependency YAML subset the rest of the project uses);
9
+ * the title is the front matter ``title``, else the first ``#`` heading, else
10
+ the file name;
11
+ * ``[[wikilinks]]`` are reduced to their label by default — the target indexes plain
12
+ text, and ``[[target|label]]`` would otherwise be indexed as markup;
13
+ * Notion appends a 32-hex page id to file names; ``strip_hash`` removes it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+
22
+ from . import Record
23
+ from ..text import parse_frontmatter, split_frontmatter
24
+
25
+ #: Directories that hold application state rather than notes.
26
+ SKIP_DIRS = frozenset({
27
+ ".obsidian", ".trash", ".git", ".stfolder", ".smart-env", ".obsidian-cache",
28
+ "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
29
+ })
30
+
31
+ _WIKILINK = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")
32
+ _NOTION_ID = re.compile(r"\s+[0-9a-f]{32}$")
33
+ _HEADING = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
34
+
35
+
36
+ def iter_markdown(root: Path, *, skip: frozenset[str] = SKIP_DIRS):
37
+ """Yield ``.md`` files under ``root``, skipping application-state dirs."""
38
+ root = Path(root)
39
+ if root.is_file():
40
+ yield root
41
+ return
42
+ for path in sorted(root.rglob("*.md")):
43
+ rel = path.relative_to(root)
44
+ if any(part in skip or part.startswith(".") for part in rel.parts[:-1]):
45
+ continue
46
+ if path.name.startswith("."):
47
+ continue
48
+ yield path
49
+
50
+
51
+ def strip_wikilinks(text: str) -> str:
52
+ """``[[page|label]]`` → ``label``; ``[[page]]`` → ``page``."""
53
+ return _WIKILINK.sub(lambda m: (m.group(2) or m.group(1)).strip(), text)
54
+
55
+
56
+ def one(value) -> str:
57
+ """Scalar-ish access for a front matter value that may be a list."""
58
+ if isinstance(value, (list, tuple)):
59
+ return str(value[0]).strip().strip("'\"") if value else ""
60
+ if isinstance(value, dict):
61
+ return ""
62
+ return str(value).strip().strip("'\"") if value is not None else ""
63
+
64
+
65
+ def tags_of(value) -> tuple[str, ...]:
66
+ if isinstance(value, str):
67
+ return tuple(part.strip() for part in re.split(r"[,;]", value) if part.strip())
68
+ if isinstance(value, (list, tuple)):
69
+ return tuple(str(item).strip() for item in value if str(item).strip())
70
+ return ()
71
+
72
+
73
+ def _stem(path: Path, *, strip_hash: bool) -> str:
74
+ stem = path.stem
75
+ return _NOTION_ID.sub("", stem).strip() if strip_hash else stem.strip()
76
+
77
+
78
+ def _first_heading(body: str) -> str:
79
+ match = _HEADING.search(body)
80
+ return match.group(1).strip() if match else ""
81
+
82
+
83
+ def _drop_leading_heading(body: str, title: str) -> str:
84
+ """Remove the page's own H1 when it just repeats the title we render anyway."""
85
+ match = _HEADING.match(body)
86
+ if match and match.group(1).strip().casefold() == title.strip().casefold():
87
+ return body[match.end():].lstrip("\n")
88
+ return body
89
+
90
+
91
+ def _updated_at(path: Path, front: dict) -> str:
92
+ for key in ("updated", "updated_at", "created", "created_at", "date"):
93
+ value = one(front.get(key))
94
+ if value:
95
+ return value
96
+ generated = front.get("generated")
97
+ if isinstance(generated, dict):
98
+ value = one(generated.get("at"))
99
+ if value:
100
+ return value
101
+ try:
102
+ return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat(timespec="seconds")
103
+ except OSError:
104
+ return ""
105
+
106
+
107
+ def page_record(path: Path, root: Path, *, default_type: str = "note",
108
+ wikilinks: str = "strip", strip_hash: bool = False,
109
+ origin: str = "") -> Record | None:
110
+ """One record per Markdown page; ``None`` when the page has no body."""
111
+ try:
112
+ raw = path.read_text(encoding="utf-8", errors="replace")
113
+ except OSError:
114
+ return None
115
+ front = parse_frontmatter(raw)
116
+ body, _ = split_frontmatter(raw)
117
+ title = one(front.get("title")) or _first_heading(body) or _stem(path, strip_hash=strip_hash)
118
+ body = _drop_leading_heading(body.strip(), title)
119
+ if wikilinks == "strip":
120
+ body = strip_wikilinks(body)
121
+ body = body.strip()
122
+ if not body:
123
+ return None
124
+ try:
125
+ rel = str(path.relative_to(root))
126
+ except ValueError:
127
+ rel = path.name
128
+ return Record(
129
+ content=body,
130
+ title=title,
131
+ id=rel,
132
+ tags=tags_of(front.get("tags")),
133
+ type=one(front.get("type")) or default_type,
134
+ updated_at=_updated_at(path, front),
135
+ origin=origin or "markdown",
136
+ source=rel,
137
+ )