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 +26 -0
- handrolled/deeprank.py +183 -0
- handrolled/embed_onnx.py +168 -0
- handrolled/importers/__init__.py +68 -0
- handrolled/importers/basic_memory.py +67 -0
- handrolled/importers/jsonl.py +35 -0
- handrolled/importers/markdown.py +137 -0
- handrolled/importers/mem0.py +41 -0
- handrolled/importers/notion.py +28 -0
- handrolled/importers/obsidian.py +24 -0
- handrolled/importers/records.py +94 -0
- handrolled/mounts.py +189 -0
- handrolled/pages.py +274 -0
- handrolled/py.typed +0 -0
- handrolled/rerank.py +197 -0
- handrolled/text.py +267 -0
- handrolled/webdav.py +232 -0
- handrolled-0.1.0.dist-info/METADATA +99 -0
- handrolled-0.1.0.dist-info/RECORD +22 -0
- handrolled-0.1.0.dist-info/WHEEL +5 -0
- handrolled-0.1.0.dist-info/licenses/LICENSE +21 -0
- handrolled-0.1.0.dist-info/top_level.txt +1 -0
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
|
handrolled/embed_onnx.py
ADDED
|
@@ -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
|
+
)
|