@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0
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.
- package/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +30 -1
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +0 -0
- package/dsh-plugin/lib/index.js +299 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +0 -0
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rag.py`(553 行,仓库根)的 Node 对应物(Node 后端 M6-3)。
|
|
3
|
+
*
|
|
4
|
+
* Touchstone 案例库语义检索(RAG)底座:API 嵌入(OpenAI 兼容 `/v1/embeddings`)+ 纯标准库
|
|
5
|
+
* BM25 兜底 + RRF 混合排序。派生索引落 `<案例库根>/.live/`(`rag_index.jsonl` /
|
|
6
|
+
* `similar.jsonl`),原子写、可随时重建;未配置(`~/.touchstone/rag.json` 不存在)时一切
|
|
7
|
+
* 功能 no-op;任何检索故障静默降级,永不阻塞任务。
|
|
8
|
+
*
|
|
9
|
+
* ── 导出名 ────────────────────────────────────────────────────────────────────
|
|
10
|
+
* 主名**照 Python 名**(`load_config`/`_bm25_search`/…),文件末尾再给一套 camelCase 别名
|
|
11
|
+
* (`loadConfig`/`_bm25Search`/…)供 JS 调用方使用——`export-cases.js` 的 `--semantic` 分支
|
|
12
|
+
* 已按 `rag.loadConfig()` / `await rag.retrieve(...)` 调用(该文件 M6-2 先落,契约在它那边)。
|
|
13
|
+
* 对拍用例的 `fn` 写 Python 名、`nodeFn` 写本文件的导出名,逐条对齐、不靠命名巧合。
|
|
14
|
+
*
|
|
15
|
+
* ── 与 Python 的三处**形态**差异(都已在调用方或量具里显式对齐) ─────────────────
|
|
16
|
+
* ① **异步**:Python 用同步 `urllib`,Node 的 `fetch` 只有异步形态 ⇒ `_post_embeddings` /
|
|
17
|
+
* `test_connection` / `refresh` / `retrieve_detail` / `retrieve` / `cmd_rebuild` /
|
|
18
|
+
* `cmd_search` / `main` 返回 Promise。`await` 一个同步值是无害的,故 `export-cases.js`
|
|
19
|
+
* 的 `await rag.retrieve(...)` 两种形态都能用。**其余函数保持同步**(纯算法/文件)。
|
|
20
|
+
* ② **元组 → 数组**:Python 的 `(rows, meta)` 在 JS 侧就是 `[rows, meta]`(不用对象),
|
|
21
|
+
* 对拍无需 `shape` 适配。
|
|
22
|
+
* ③ **错误类型**:Python 的 `ValueError`/`KeyError`/`AttributeError`/`OverflowError` 在本文件里
|
|
23
|
+
* 有同名 JS 类(`constructor.name` 与 Python 的 `type(exc).__name__` 逐字相同),
|
|
24
|
+
* 故「两侧同抛」的对拍分支能比 `$kind`;消息也逐字照抄(含中文文案)。
|
|
25
|
+
* ④ **`scan_cases` 自带一份**(不 import `export-cases.js`):那条 import 会与它的 TLA 入口
|
|
26
|
+
* 构成异步模块环而死锁,详见 `scanCases` 的注释(含最小复现与「改回一行 import」的条件)。
|
|
27
|
+
*
|
|
28
|
+
* ── 逐点等价的口径(每条都对着 rag.py 核过) ─────────────────────────────────────
|
|
29
|
+
* 1. **截断按码点**:`[:EMBED_TEXT_MAX]`(2000) / `[:4000]` / `[:200]` 是 Python 的
|
|
30
|
+
* 「码点切片」。JS 的 `slice` 按 UTF-16 码元 —— emoji(代理对)落在边界上时,
|
|
31
|
+
* `slice` 会切出半个代理 ⇒ 用 `pySliceCodePoints`(迭代器按码点走)。
|
|
32
|
+
* 2. **银行家舍入**:`round(s, 4)` 是 CPython 的「十进制正确舍入 + 半偶」。JS 的
|
|
33
|
+
* `Math.round`/`toFixed` 是半**上**入,且在 `0.15625` 这类**可精确表示**的十进制中点
|
|
34
|
+
* 上真的会分叉 ⇒ 用 `pyRound`(BigInt 精确分解 double → 半偶 → 十进制串 → `Number`)。
|
|
35
|
+
* 3. **`or` 的真值口径**:Python 的 `x or d` 用 `bool(x)`(空 list/dict/""/0/None 皆假),
|
|
36
|
+
* JS 的 `||` 里空数组/空对象是**真** ⇒ 用 `pyOr`(`pyTruthy` 照 Python 的 `bool()`)。
|
|
37
|
+
* 4. **`json.dumps` 的两种形态**:
|
|
38
|
+
* - `_atomic_write_jsonl` / `cmd_rebuild` 的无缩进 `ensure_ascii=False` → `pyJsonDumps`;
|
|
39
|
+
* - `save_config` 的 `indent=2` → `pyJsonDumpsIndent(v, 2)`,末尾补 `"\n"` 照 `f.write("\n")`;
|
|
40
|
+
* - `_post_embeddings` 的请求体是 `json.dumps(...)` **默认**(`ensure_ascii=True`)——
|
|
41
|
+
* 中文会被转成 `\uXXXX`、星平面字符转成**代理对**转义,分隔符 `", "` / `": "`。
|
|
42
|
+
* 这在 `pyjson.js` 的口径之外(它只做 `ensure_ascii=False`),故本文件内实现
|
|
43
|
+
* `pyJsonDumpsAscii`(私有,仅用于请求体,测试里逐字节钉住)。
|
|
44
|
+
* 5. **`str.splitlines()`**(`_load_index`/`_load_jsonl`)比分 `"\n"` 多切 8 个字符
|
|
45
|
+
* (`\v \f \x1c-\x1e \x85 \u2028 \u2029`)⇒ 用 `pySplitLines`(`read_text` 已先把
|
|
46
|
+
* `\r\n`/`\r` 折成 `\n`)。
|
|
47
|
+
* 6. **`os.path`**:`join` 用 lib.js 的 `pyJoin`(后段绝对路径覆盖前段)、`relpath` 用
|
|
48
|
+
* `path.relative` + 「同路径给 `.`」(见私有 `scanCases`);mtime 浮点用 `pyMtimeFloat`
|
|
49
|
+
* (CPython 是「秒 + 纳秒×1e-9」两次舍入,不是 `ns/1e9`)。
|
|
50
|
+
* 7. **`int()`**:`_cfg_int` 与 `refresh` 的 `int(cfg.get("batch_size") or 16)` 都走 `pyInt`
|
|
51
|
+
* (bool → 0/1、float 向零截断、str 支持两侧空白与 `1_0` 下划线、非法串抛
|
|
52
|
+
* `invalid literal for int() with base 10: …`)。
|
|
53
|
+
* 8. **排序**:`sorted(index)` / `sorted(v)`(RRF 的 via)用 `pyStrCompare`(Python 按**码点**,
|
|
54
|
+
* JS 默认按 UTF-16 码元,星平面字符上会分叉);按分数降序的 `sort(..., reverse=True)`
|
|
55
|
+
* 用「比较器取反」,保持相等元素的稳定次序(与 Python 同)。
|
|
56
|
+
*
|
|
57
|
+
* ── 已知的残余差异(原理性/未验;不要当 bug 修,见 M6-3 报告) ───────────────────
|
|
58
|
+
* - **`.live/*.jsonl` 里「整数值的浮点」字节不同**:`_norm([1,0])` 在 Python 是
|
|
59
|
+
* `[1.0, 0.0]`、JS 是 `[1, 0]`;`round(0.0,4)` 同理(Python 写 `0.0`、JS 写 `0`)。
|
|
60
|
+
* JS 的 number 没有 int/float 之分,`pyjson.js` 文件头已把这条列为不可消除;对拍夹具
|
|
61
|
+
* 刻意避开整数值浮点(cosine 取非整数)以免用白名单掩盖它。
|
|
62
|
+
* - **`json.loads` 的 `NaN`/`Infinity`**:CPython 默认接受这三个字面量,`JSON.parse` 不接受
|
|
63
|
+
* ⇒ 坏索引行/坏配置里出现它们时,Python 会解析、JS 会当「损坏」跳过(索引可自愈)。
|
|
64
|
+
* - **`re.findall` 的 Unicode 数字**:`tokenize` 的 `[A-Za-z0-9_]` 是 ASCII 口径,两侧同;
|
|
65
|
+
* 但 `pyInt` 只认 ASCII 数字(Python `int("١٢")` 能过)——与 lib.js 文件头 ③ 同一条备案。
|
|
66
|
+
* - **HTTP 错误文案**:urllib 是 `HTTP Error 401: Unauthorized`、`<urlopen error [Errno 111] …>`,
|
|
67
|
+
* fetch 是 `HTTP Error 401: …` / `fetch failed` ⇒ 网络失败分支的 `vec_error` 文案两侧不同
|
|
68
|
+
* (对拍刻意不让任何 op 真的发起请求;见用例 note)。
|
|
69
|
+
* - **BN25 求和次序**:Python `set(tokenize(query))` 的迭代序是哈希序(harness 固定
|
|
70
|
+
* `PYTHONHASHSEED=0` 也只是「确定」而不是「与 JS 插入序相同」)⇒ 一个文档被**多个不同
|
|
71
|
+
* 查询词**命中时,浮点累加次序可能不同、末位可能分叉。对拍语料只用「单词查询 / 各命中
|
|
72
|
+
* 不相交文档」的查询,避免把这件事用白名单盖掉。
|
|
73
|
+
* - **`pyLog` 与本地 libm 的 1 ULP 残余**:BM25 的 IDF 用 `pyLog`(正确舍入)。oracle 的
|
|
74
|
+
* `math.log` 是本地 libm 的实现,本身**并非**处处正确舍入 ⇒ n ≤ 2000 的可达参数里仍有
|
|
75
|
+
* 1,915/2,001,000(0.096%)差 1 ULP(n ≤ 8 的小语料 0/36 全中)。这是两侧「同一个公式、
|
|
76
|
+
* 两个 libm」的固有残差;影响限于「几乎同分」时的排序与逐位比,4 位小数的输出不受影响。
|
|
77
|
+
* 要彻底消除只能照搬 glibc 的 log 算法(表 + 双 double),不划算;对拍用例把语料控制在
|
|
78
|
+
* n ≤ 8 并用已核对的参数钉住原始得分。
|
|
79
|
+
*
|
|
80
|
+
* 零 npm 依赖:只用 node:crypto / node:fs / node:os / node:path。
|
|
81
|
+
*/
|
|
82
|
+
import { createHash } from 'node:crypto';
|
|
83
|
+
import fs from 'node:fs';
|
|
84
|
+
import os from 'node:os';
|
|
85
|
+
import path from 'node:path';
|
|
86
|
+
import { pathToFileURL } from 'node:url';
|
|
87
|
+
|
|
88
|
+
// 案例库扫描:**单一实现**在 `export-cases.js`(M6-2 落地,本模块只 import)。
|
|
89
|
+
// 2026-10-13 M6-3 的一处收口:这里一度自带过一份私有副本——因为 export-cases.js 的 CLI 入口
|
|
90
|
+
// 曾是**顶层 await**,与本模块的静态反向 import 构成异步模块环而死锁(症状:--semantic
|
|
91
|
+
// 分支 Detected unsettled top-level await + exit 13)。入口改成 main().then(...) 后环消失,
|
|
92
|
+
// 私有副本删掉,改回下面这行 import(重复实现归零)。
|
|
93
|
+
import { scanCases } from './export-cases.js';
|
|
94
|
+
import { pyJoin, pyMtimeFloat, pyStrCompare, pyStrip, readText } from './lib.js';
|
|
95
|
+
import { pyJsonDumps, pyJsonDumpsIndent } from './pyjson.js';
|
|
96
|
+
|
|
97
|
+
// 派生文件名(<案例库根>/.live/ 下,平台维护,agent 只读)
|
|
98
|
+
export const RAG_INDEX_NAME = 'rag_index.jsonl';
|
|
99
|
+
export const SIMILAR_NAME = 'similar.jsonl';
|
|
100
|
+
// 嵌入源文本截断长度(字符);近邻条数;检索默认 top-k
|
|
101
|
+
export const EMBED_TEXT_MAX = 2000;
|
|
102
|
+
export const TOPK_SIMILAR = 5;
|
|
103
|
+
export const DEFAULT_K = 8;
|
|
104
|
+
// RRF 常数(标准 60);融合前单路候选数;BM25 参数(标准值)
|
|
105
|
+
export const RRF_K = 60;
|
|
106
|
+
export const CAND_TOP = 20;
|
|
107
|
+
export const BM25_K1 = 1.5;
|
|
108
|
+
export const BM25_B = 0.75;
|
|
109
|
+
// BM25 内存缓存 TTL(秒),key=案例库根+扫描签名
|
|
110
|
+
export const CACHE_TTL = 1.0;
|
|
111
|
+
// 嵌入请求失败重试次数(加首次共 2 次尝试)
|
|
112
|
+
export const HTTP_RETRY = 1;
|
|
113
|
+
// 未显式配置时的缺省请求超时(秒)与嵌入批量大小(load_config 兜底 / 管理页保存校验同源)
|
|
114
|
+
export const DEF_TIMEOUT_S = 30;
|
|
115
|
+
export const DEF_BATCH_SIZE = 16;
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
// --------------------------------------------------------------------------
|
|
119
|
+
// Python 语义小工具(错误类 / 真值 / str() / 码点切片 / round / int / json)
|
|
120
|
+
// --------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Python `ValueError` 的同名 JS 类。
|
|
124
|
+
*
|
|
125
|
+
* 为什么要类名:对拍里「两侧同抛」的分支会逐字比 `$kind`(探针取 `type(exc).__name__`、
|
|
126
|
+
* worker 取 `constructor.name`)——用裸 `Error` 会变成永久差异,只能靠白名单盖住。
|
|
127
|
+
*/
|
|
128
|
+
export class ValueError extends Error {
|
|
129
|
+
constructor(message) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = 'ValueError';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Python `KeyError`(`str(exc)` 是**键的 repr**,如 `'dir'`) */
|
|
136
|
+
export class KeyError extends Error {
|
|
137
|
+
constructor(message) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = 'KeyError';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Python `AttributeError`(`'int' object has no attribute 'strip'` 这类) */
|
|
144
|
+
export class AttributeError extends Error {
|
|
145
|
+
constructor(message) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = 'AttributeError';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Python `OverflowError`(`int(inf)` 走这条;`_cfg_int` 只 catch `(TypeError, ValueError)`) */
|
|
152
|
+
export class OverflowError extends Error {
|
|
153
|
+
constructor(message) {
|
|
154
|
+
super(message);
|
|
155
|
+
this.name = 'OverflowError';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Python `IndexError` */
|
|
160
|
+
export class IndexError extends Error {
|
|
161
|
+
constructor(message) {
|
|
162
|
+
super(message);
|
|
163
|
+
this.name = 'IndexError';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Python `bool(x)`:空串/空容器/0/None 假;JS 的 `[]`/`{}` 在 `||` 里是真,必须区分 */
|
|
168
|
+
export function pyTruthy(value) {
|
|
169
|
+
if (value === null || value === undefined) return false;
|
|
170
|
+
switch (typeof value) {
|
|
171
|
+
case 'boolean': return value;
|
|
172
|
+
case 'number': return value !== 0 && !Number.isNaN(value);
|
|
173
|
+
case 'string': return value !== '';
|
|
174
|
+
case 'object':
|
|
175
|
+
return Array.isArray(value) ? value.length > 0 : Object.keys(value).length > 0;
|
|
176
|
+
default: return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Python `x or d` */
|
|
181
|
+
function pyOr(value, fallback) {
|
|
182
|
+
return pyTruthy(value) ? value : fallback;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Python 类型名(只用于错误文案;int/float 在 JS 里已不可分,按是否整数猜) */
|
|
186
|
+
function pyTypeName(value) {
|
|
187
|
+
if (value === null || value === undefined) return 'NoneType';
|
|
188
|
+
if (Array.isArray(value)) return 'list';
|
|
189
|
+
switch (typeof value) {
|
|
190
|
+
case 'boolean': return 'bool';
|
|
191
|
+
case 'number': return Number.isInteger(value) ? 'int' : 'float';
|
|
192
|
+
case 'string': return 'str';
|
|
193
|
+
case 'object': return 'dict';
|
|
194
|
+
default: return typeof value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Python `repr(str)` 的可用子集(单引号、转义反斜杠/引号/控制字符) */
|
|
199
|
+
function pyReprString(text) {
|
|
200
|
+
let out = "'";
|
|
201
|
+
for (const ch of String(text)) {
|
|
202
|
+
if (ch === '\\') out += '\\\\';
|
|
203
|
+
else if (ch === "'") out += "\\'";
|
|
204
|
+
else if (ch === '\n') out += '\\n';
|
|
205
|
+
else if (ch === '\r') out += '\\r';
|
|
206
|
+
else if (ch === '\t') out += '\\t';
|
|
207
|
+
else if (ch.codePointAt(0) < 0x20 || ch.codePointAt(0) === 0x7f) {
|
|
208
|
+
out += `\\x${ch.codePointAt(0).toString(16).padStart(2, '0')}`;
|
|
209
|
+
} else out += ch;
|
|
210
|
+
}
|
|
211
|
+
return `${out}'`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Python `str(x)`:`None`→`"None"`、`True`→`"True"`、容器走 repr。
|
|
216
|
+
*
|
|
217
|
+
* 浮点整数值(`1.0`)在 JS 里就是 `1` ⇒ 输出 `"1"`(`pyjson.js` 文件头同一条不可消除差异)。
|
|
218
|
+
*/
|
|
219
|
+
export function pyStr(value) {
|
|
220
|
+
if (value === null || value === undefined) return 'None';
|
|
221
|
+
if (typeof value === 'string') return value;
|
|
222
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False';
|
|
223
|
+
if (typeof value === 'number') return String(value);
|
|
224
|
+
if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
|
|
225
|
+
if (typeof value === 'object') {
|
|
226
|
+
const items = Object.keys(value).map((k) => `${pyRepr(k)}: ${pyRepr(value[k])}`);
|
|
227
|
+
return `{${items.join(', ')}}`;
|
|
228
|
+
}
|
|
229
|
+
throw new TypeError(`str() 不支持 ${typeof value}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Python `repr(x)` 的可用子集(str 走 repr 规则,其余用 `pyStr`) */
|
|
233
|
+
function pyRepr(value) {
|
|
234
|
+
if (typeof value === 'string') return pyReprString(value);
|
|
235
|
+
return pyStr(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Python `x.strip()`(非 str 抛 AttributeError,照 Python)。
|
|
240
|
+
*
|
|
241
|
+
* 空白集合走 lib.js 的 `pyStrip`(`\s` 口径已与 Python 对齐:含 `\x1c-\x1f`/`\x85`、
|
|
242
|
+
* 不含 U+FEFF)——`int(str)` 的前后空白、`save_config` 的字段规整都用它。
|
|
243
|
+
*/
|
|
244
|
+
function pyStrStrip(value) {
|
|
245
|
+
if (typeof value !== 'string') {
|
|
246
|
+
throw new AttributeError(`'${pyTypeName(value)}' object has no attribute 'strip'`);
|
|
247
|
+
}
|
|
248
|
+
return pyStrip(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Python `str.splitlines()`(比 `split("\n")` 多 8 个分隔字符;`read_text` 已折过 `\r`) */
|
|
252
|
+
function pySplitLines(text) {
|
|
253
|
+
const s = String(text);
|
|
254
|
+
if (s === '') return [];
|
|
255
|
+
return s.split(/[\n\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Python `len(x)`(str 按码点) */
|
|
259
|
+
function pyLen(value) {
|
|
260
|
+
if (typeof value === 'string') return [...value].length;
|
|
261
|
+
if (Array.isArray(value)) return value.length;
|
|
262
|
+
if (value && typeof value === 'object') return Object.keys(value).length;
|
|
263
|
+
throw new TypeError(`object of type '${pyTypeName(value)}' has no len()`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Python 字符串切片 `text[:n]`(**按码点**)。
|
|
268
|
+
*
|
|
269
|
+
* `n < 0` 照 Python:丢弃末尾 `|n|` 个码点。非 str 抛 TypeError(Python 是
|
|
270
|
+
* `'int' object is not subscriptable`)——本模块只对 str 用。
|
|
271
|
+
*/
|
|
272
|
+
export function pySliceCodePoints(text, n) {
|
|
273
|
+
if (typeof text !== 'string') {
|
|
274
|
+
throw new TypeError(`'${pyTypeName(text)}' object is not subscriptable`);
|
|
275
|
+
}
|
|
276
|
+
const chars = Array.from(text);
|
|
277
|
+
const end = n < 0 ? Math.max(0, chars.length + n) : Math.min(chars.length, n);
|
|
278
|
+
return chars.slice(0, end).join('');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* CPython `round(x, n)`(n ≥ 0):**对 double 的精确十进制值**做半偶舍入,再取最近 double。
|
|
283
|
+
*
|
|
284
|
+
* 为什么不能用 `toFixed`:`(0.15625).toFixed(4)` = `"0.1563"`(半上入),而 Python 给
|
|
285
|
+
* `0.1562`(半偶)——`0.15625` 是可精确表示的二进制小数,这个分叉在真实分数上会遇到。
|
|
286
|
+
* 实现:拆出 `x = m·2^e` 的精确整数表示 → 乘 `10^n` 做精确整数除法 → 余数比 `2r` 与除数
|
|
287
|
+
* (相等时看商是否为偶数)→ 十进制串交给 `Number()`(正确舍入,等价 CPython 的 strtod)。
|
|
288
|
+
*/
|
|
289
|
+
export function pyRound(value, digits) {
|
|
290
|
+
if (typeof value !== 'number') return value; // Python round(int, n) 仍是 int
|
|
291
|
+
if (!Number.isFinite(value)) return value; // round(nan)=nan、round(inf)=inf
|
|
292
|
+
if (!Number.isInteger(digits)) throw new TypeError('ndigits 须为整数');
|
|
293
|
+
const n = digits;
|
|
294
|
+
const neg = value < 0;
|
|
295
|
+
const ax = Math.abs(value);
|
|
296
|
+
if (ax === 0) return value; // ±0.0 原样(含 -0.0)
|
|
297
|
+
if (n < 0) {
|
|
298
|
+
// Python 允许负 ndigits(按 10^|n| 位舍入);本模块未用到,显式抛错而非静默给错值。
|
|
299
|
+
throw new RangeError('pyRound: 只实现 ndigits >= 0(rag.py 只用 round(s, 4))');
|
|
300
|
+
}
|
|
301
|
+
// 精确分解 ax = m · 2^e(m 为整数 BigInt,e 为整数)
|
|
302
|
+
let m = ax;
|
|
303
|
+
let e = 0;
|
|
304
|
+
while (!Number.isInteger(m)) { m *= 2; e -= 1; }
|
|
305
|
+
let num = BigInt(m);
|
|
306
|
+
let den = 1n;
|
|
307
|
+
if (e >= 0) num <<= BigInt(e);
|
|
308
|
+
else den = 1n << BigInt(-e);
|
|
309
|
+
num *= 10n ** BigInt(n);
|
|
310
|
+
let q = num / den;
|
|
311
|
+
const r = num % den;
|
|
312
|
+
const twice = 2n * r;
|
|
313
|
+
if (twice > den || (twice === den && q % 2n === 1n)) q += 1n;
|
|
314
|
+
const digitsText = q.toString();
|
|
315
|
+
const padded = digitsText.length <= n ? `0.${'0'.repeat(n - digitsText.length)}${digitsText}`
|
|
316
|
+
: `${digitsText.slice(0, digitsText.length - n)}.${digitsText.slice(digitsText.length - n)}`;
|
|
317
|
+
const out = Number(padded);
|
|
318
|
+
return neg ? -out : out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Python `int(x)`:bool→0/1、float 向零截断(NaN/±inf 抛)、str 支持两侧空白与 `1_0`。
|
|
323
|
+
*
|
|
324
|
+
* 已知残余:只认 ASCII 数字(Python 还认 Unicode 十进制数字)。
|
|
325
|
+
*/
|
|
326
|
+
export function pyInt(value) {
|
|
327
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
328
|
+
if (typeof value === 'number') {
|
|
329
|
+
if (Number.isNaN(value)) throw new ValueError('cannot convert float NaN to integer');
|
|
330
|
+
if (!Number.isFinite(value)) {
|
|
331
|
+
throw new OverflowError(`cannot convert float ${value > 0 ? 'infinity' : '-infinity'} to integer`);
|
|
332
|
+
}
|
|
333
|
+
return Math.trunc(value);
|
|
334
|
+
}
|
|
335
|
+
if (typeof value === 'string') {
|
|
336
|
+
const text = pyStrStrip(value);
|
|
337
|
+
const m = /^([+-]?)([0-9]+(?:_[0-9]+)*)$/.exec(text);
|
|
338
|
+
if (!m) {
|
|
339
|
+
throw new ValueError(`invalid literal for int() with base 10: ${pyReprString(value)}`);
|
|
340
|
+
}
|
|
341
|
+
const digits = m[2].replace(/_/g, '');
|
|
342
|
+
const n = Number(`${m[1]}${digits}`);
|
|
343
|
+
return m[1] === '-' ? -n : n;
|
|
344
|
+
}
|
|
345
|
+
throw new TypeError('int() argument must be a string, a bytes-like object or a real number, '
|
|
346
|
+
+ `not '${pyTypeName(value)}'`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Python `dict.get(k[, d])`:只有**键不存在**才给默认值(键在但为 None 仍返回 None) */
|
|
350
|
+
function pyGet(value, key, fallback = undefined) {
|
|
351
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
352
|
+
throw new AttributeError(`'${pyTypeName(value)}' object has no attribute 'get'`);
|
|
353
|
+
}
|
|
354
|
+
return Object.hasOwn(value, key) ? value[key] : fallback;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Python `d[k]`(键不存在抛 `KeyError`,消息是键的 repr) */
|
|
358
|
+
function pyGetItem(value, key) {
|
|
359
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
360
|
+
throw new TypeError(`'${pyTypeName(value)}' object is not subscriptable`);
|
|
361
|
+
}
|
|
362
|
+
if (!Object.hasOwn(value, key)) throw new KeyError(pyRepr(key));
|
|
363
|
+
return value[key];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Python `seq[i]`(list 越界抛 IndexError、dict 缺键抛 KeyError) */
|
|
367
|
+
function pyIndex(seq, i) {
|
|
368
|
+
if (Array.isArray(seq)) {
|
|
369
|
+
if (!Number.isInteger(i) || i < 0 || i >= seq.length) {
|
|
370
|
+
throw new IndexError('list index out of range');
|
|
371
|
+
}
|
|
372
|
+
return seq[i];
|
|
373
|
+
}
|
|
374
|
+
if (seq !== null && typeof seq === 'object') return pyGetItem(seq, i);
|
|
375
|
+
throw new TypeError(`'${pyTypeName(seq)}' object is not subscriptable`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Python `list(x or [])`:str → 码点列表、dict → 键列表、falsy → 空表 */
|
|
379
|
+
function pyListOf(value) {
|
|
380
|
+
if (!pyTruthy(value)) return [];
|
|
381
|
+
if (typeof value === 'string') return Array.from(value);
|
|
382
|
+
if (Array.isArray(value)) return [...value];
|
|
383
|
+
if (typeof value === 'object') return Object.keys(value);
|
|
384
|
+
throw new TypeError(`'${pyTypeName(value)}' object is not iterable`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Python `json.dumps` 默认形态(`ensure_ascii=True`)——只用于嵌入请求体 */
|
|
388
|
+
export function pyJsonDumpsAscii(value) {
|
|
389
|
+
if (value === null) return 'null';
|
|
390
|
+
if (typeof value === 'string') return asciiString(value);
|
|
391
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
392
|
+
if (typeof value === 'number') return pyJsonDumps(value);
|
|
393
|
+
if (Array.isArray(value)) return `[${value.map(pyJsonDumpsAscii).join(', ')}]`;
|
|
394
|
+
if (typeof value === 'object') {
|
|
395
|
+
const items = Object.keys(value).map((k) => `${asciiString(k)}: ${pyJsonDumpsAscii(value[k])}`);
|
|
396
|
+
return `{${items.join(', ')}}`;
|
|
397
|
+
}
|
|
398
|
+
throw new TypeError(`pyJsonDumpsAscii: 不能序列化 ${typeof value}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 控制字符转义表(与 CPython `py_encode_basestring_ascii` 相同的命名转义) */
|
|
402
|
+
const ASCII_NAMED_ESCAPES = new Map([
|
|
403
|
+
['"', '\\"'], ['\\', '\\\\'], ['\b', '\\b'], ['\f', '\\f'], ['\n', '\\n'], ['\r', '\\r'],
|
|
404
|
+
['\t', '\\t'],
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
/** 单个字符串的 `ensure_ascii=True` 编码(≥ U+007F 一律 `\uXXXX`,星平面拆代理对) */
|
|
408
|
+
function asciiString(value) {
|
|
409
|
+
let out = '"';
|
|
410
|
+
for (const ch of String(value)) {
|
|
411
|
+
const named = ASCII_NAMED_ESCAPES.get(ch);
|
|
412
|
+
if (named !== undefined) {
|
|
413
|
+
out += named;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const cp = ch.codePointAt(0);
|
|
417
|
+
if (cp > 0x7e) {
|
|
418
|
+
if (cp <= 0xffff) out += `\\u${cp.toString(16).padStart(4, '0')}`;
|
|
419
|
+
else {
|
|
420
|
+
const v = cp - 0x10000;
|
|
421
|
+
out += `\\u${(0xd800 + (v >> 10)).toString(16).padStart(4, '0')}`;
|
|
422
|
+
out += `\\u${(0xdc00 + (v & 0x3ff)).toString(16).padStart(4, '0')}`;
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (cp < 0x20) {
|
|
427
|
+
out += `\\u${cp.toString(16).padStart(4, '0')}`;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
out += ch;
|
|
431
|
+
}
|
|
432
|
+
return `${out}"`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Python 的 `x < y`(类型不可比时抛 TypeError,与 `sorted` 的行为同) */
|
|
436
|
+
function pyLess(a, b) {
|
|
437
|
+
const na = typeof a === 'boolean' ? Number(a) : a;
|
|
438
|
+
const nb = typeof b === 'boolean' ? Number(b) : b;
|
|
439
|
+
if (typeof na === 'number' && typeof nb === 'number') return na < nb;
|
|
440
|
+
if (typeof na === 'string' && typeof nb === 'string') return pyStrCompare(na, nb) < 0;
|
|
441
|
+
throw new TypeError(`'<' not supported between instances of '${pyTypeName(a)}' and '${pyTypeName(b)}'`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Python `sorted(items, key=...)`(稳定;排序键不可比时抛 TypeError,同 Python) */
|
|
445
|
+
function pySortBy(items, keyFn) {
|
|
446
|
+
return [...items].sort((a, b) => {
|
|
447
|
+
const ka = keyFn(a);
|
|
448
|
+
const kb = keyFn(b);
|
|
449
|
+
if (pyLess(ka, kb)) return -1;
|
|
450
|
+
if (pyLess(kb, ka)) return 1;
|
|
451
|
+
return 0;
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Python `sorted(x, key=..., reverse=True)` 的稳定降序(比较器取反,**不是**升序再反转) */
|
|
456
|
+
function pySortByDesc(items, keyFn) {
|
|
457
|
+
return [...items].sort((a, b) => {
|
|
458
|
+
const ka = keyFn(a);
|
|
459
|
+
const kb = keyFn(b);
|
|
460
|
+
if (pyLess(ka, kb)) return 1;
|
|
461
|
+
if (pyLess(kb, ka)) return -1;
|
|
462
|
+
return 0;
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 是不是「Python 的 OSError」:Node 的 fs 错误都带 `code` */
|
|
467
|
+
function isFsError(exc) {
|
|
468
|
+
return Boolean(exc && typeof exc === 'object' && typeof exc.code === 'string');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --------------------------------------------------------------------------
|
|
472
|
+
// 配置 IO
|
|
473
|
+
// --------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/** `rag.json` 路径:环境变量 `TS_RAG_CONFIG` 覆盖,默认 `~/.touchstone/rag.json`。 */
|
|
476
|
+
export function config_path() {
|
|
477
|
+
return process.env.TS_RAG_CONFIG || path.join(os.homedir(), '.touchstone', 'rag.json');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* 读 RAG 配置;文件不存在/解析失败/关键字段缺失返回 `null`(=RAG 未启用)。
|
|
482
|
+
*
|
|
483
|
+
* `api_key` 仅存于返回 dict(内存),调用方不得写入日志或任何落盘内容。
|
|
484
|
+
*/
|
|
485
|
+
export function load_config() {
|
|
486
|
+
const raw = readText(config_path());
|
|
487
|
+
if (!raw) return null;
|
|
488
|
+
let cfg;
|
|
489
|
+
try {
|
|
490
|
+
cfg = JSON.parse(raw);
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
if (!(cfg !== null && typeof cfg === 'object' && !Array.isArray(cfg)
|
|
495
|
+
&& pyTruthy(pyGet(cfg, 'api_base')) && pyTruthy(pyGet(cfg, 'api_key'))
|
|
496
|
+
&& pyTruthy(pyGet(cfg, 'model')))) {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
if (!Object.hasOwn(cfg, 'timeout_s')) cfg.timeout_s = DEF_TIMEOUT_S;
|
|
500
|
+
if (!Object.hasOwn(cfg, 'batch_size')) cfg.batch_size = DEF_BATCH_SIZE;
|
|
501
|
+
return cfg;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 读 rag.json 原始 dict(不校验关键字段),文件缺失/解析失败/非 dict 返回 `null`。
|
|
506
|
+
*
|
|
507
|
+
* 供管理页回显:`load_config` 校验失败(半填配置)时页面上仍要能展示已填字段值。
|
|
508
|
+
*/
|
|
509
|
+
export function raw_config() {
|
|
510
|
+
const raw = readText(config_path());
|
|
511
|
+
if (!raw) return null;
|
|
512
|
+
let cfg;
|
|
513
|
+
try {
|
|
514
|
+
cfg = JSON.parse(raw);
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return cfg !== null && typeof cfg === 'object' && !Array.isArray(cfg) ? cfg : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 配置整数字段规整:None/空串→默认值;非法或越界抛 ValueError(上限防误填拖垮请求)。 */
|
|
522
|
+
export function _cfg_int(value, def, upper, name) {
|
|
523
|
+
if (value === null || value === undefined || value === '') return def;
|
|
524
|
+
let n;
|
|
525
|
+
try {
|
|
526
|
+
n = pyInt(value);
|
|
527
|
+
} catch (exc) {
|
|
528
|
+
if (exc instanceof TypeError || exc instanceof ValueError) {
|
|
529
|
+
throw new ValueError(`${name} 须为正整数`);
|
|
530
|
+
}
|
|
531
|
+
throw exc;
|
|
532
|
+
}
|
|
533
|
+
if (n < 1 || n > upper) throw new ValueError(`${name} 须在 1~${upper} 之间`);
|
|
534
|
+
return n;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 校验并原子写入 rag.json(0600 权限——文件含 api_key 明文)。
|
|
539
|
+
*
|
|
540
|
+
* 关键字段缺失/非法抛 ValueError,由管理页端点转 400;`api_base` 须 http(s) 开头。
|
|
541
|
+
*/
|
|
542
|
+
export function save_config(cfg) {
|
|
543
|
+
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
544
|
+
throw new AttributeError(`'${pyTypeName(cfg)}' object has no attribute 'get'`);
|
|
545
|
+
}
|
|
546
|
+
const api_base = pyStrStrip(pyStr(pyOr(pyGet(cfg, 'api_base'), ''))).replace(/\/+$/, '');
|
|
547
|
+
const api_key = pyStrStrip(pyStr(pyOr(pyGet(cfg, 'api_key'), '')));
|
|
548
|
+
const model = pyStrStrip(pyStr(pyOr(pyGet(cfg, 'model'), '')));
|
|
549
|
+
const lower = api_base.toLowerCase();
|
|
550
|
+
if (!(lower.startsWith('http://') || lower.startsWith('https://'))) {
|
|
551
|
+
throw new ValueError(
|
|
552
|
+
'api_base 须为 http(s):// 开头的 OpenAI 兼容服务地址(含 /v1,不含 /embeddings)');
|
|
553
|
+
}
|
|
554
|
+
if (!api_key) throw new ValueError('api_key 不能为空');
|
|
555
|
+
if (!model) throw new ValueError('model 不能为空(须为嵌入模型,非对话模型)');
|
|
556
|
+
const target = config_path();
|
|
557
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
558
|
+
const payload = {
|
|
559
|
+
api_base,
|
|
560
|
+
api_key,
|
|
561
|
+
model,
|
|
562
|
+
timeout_s: _cfg_int(pyGet(cfg, 'timeout_s'), DEF_TIMEOUT_S, 300, 'timeout_s'),
|
|
563
|
+
batch_size: _cfg_int(pyGet(cfg, 'batch_size'), DEF_BATCH_SIZE, 256, 'batch_size'),
|
|
564
|
+
};
|
|
565
|
+
const tmp = `${target}.tmp`;
|
|
566
|
+
fs.writeFileSync(tmp, `${pyJsonDumpsIndent(payload, 2)}\n`, 'utf8');
|
|
567
|
+
fs.renameSync(tmp, target);
|
|
568
|
+
// 收紧权限到属主可读写(api_key 明文);Windows 无 POSIX 权限位,此调用在该平台
|
|
569
|
+
// 不报错也无实际效果,机密性依赖 %USERPROFILE% 的默认 ACL
|
|
570
|
+
fs.chmodSync(target, 0o600);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** 停用 RAG:删除 rag.json(存在才删)。返回是否实际删除了文件。 */
|
|
574
|
+
export function disable() {
|
|
575
|
+
try {
|
|
576
|
+
fs.unlinkSync(config_path());
|
|
577
|
+
return true;
|
|
578
|
+
} catch (exc) {
|
|
579
|
+
if (exc && exc.code === 'ENOENT') return false;
|
|
580
|
+
throw exc;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* 配置连通性探测:按给定配置发一次单文本嵌入请求(管理页「测试连接」用)。
|
|
586
|
+
*
|
|
587
|
+
* 返回 `{ok: true, dim}` 或 `{ok: false, error}`,绝不抛出;error 文本不含 api_key。
|
|
588
|
+
* **异步**(见文件头 ①)。
|
|
589
|
+
*/
|
|
590
|
+
export async function test_connection(cfg) {
|
|
591
|
+
try {
|
|
592
|
+
const probe = {
|
|
593
|
+
api_base: pyStrStrip(pyStr(pyOr(pyGet(cfg, 'api_base'), ''))),
|
|
594
|
+
model: pyStrStrip(pyStr(pyOr(pyGet(cfg, 'model'), ''))),
|
|
595
|
+
api_key: pyStrStrip(pyStr(pyOr(pyGet(cfg, 'api_key'), ''))),
|
|
596
|
+
};
|
|
597
|
+
if (!(probe.api_base && probe.model && probe.api_key)) {
|
|
598
|
+
throw new ValueError('api_base / model / api_key 尚未填写完整');
|
|
599
|
+
}
|
|
600
|
+
probe.timeout_s = _cfg_int(pyGet(cfg, 'timeout_s'), DEF_TIMEOUT_S, 300, 'timeout_s');
|
|
601
|
+
probe.batch_size = _cfg_int(pyGet(cfg, 'batch_size'), DEF_BATCH_SIZE, 256, 'batch_size');
|
|
602
|
+
const vecs = await _post_embeddings(probe, ['ping']);
|
|
603
|
+
return { ok: true, dim: vecs.length && pyTruthy(vecs[0]) ? pyLen(vecs[0]) : 0 };
|
|
604
|
+
} catch (exc) { // 网络/鉴权/响应形态异常统一转为可展示文本
|
|
605
|
+
const text = exc && exc.message !== undefined ? String(exc.message) : String(exc);
|
|
606
|
+
return { ok: false, error: text || (exc && exc.constructor ? exc.constructor.name : 'Error') };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* 嵌入源文本 = 用例相对目录路径 + case.md 原文(截断**码点**)。
|
|
612
|
+
*
|
|
613
|
+
* 不含 status——相似度只反映「测的是什么」,不随通过/失败漂移。
|
|
614
|
+
*/
|
|
615
|
+
export function embedding_text(root, rec) {
|
|
616
|
+
const rel = pyGetItem(rec, 'dir');
|
|
617
|
+
const body = readText(pyJoin(root, rel, 'case.md'));
|
|
618
|
+
return pySliceCodePoints(`${String(rel).replace(/\\/g, '/')}\n${body}`, EMBED_TEXT_MAX);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** 嵌入内容指纹:文本+模型 双因子 sha256,索引差量判定与模型变更重建的依据。 */
|
|
622
|
+
export function text_hash(text, model) {
|
|
623
|
+
return createHash('sha256').update(`${model}\n${text}`, 'utf8').digest('hex');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 调 OpenAI 兼容 `/v1/embeddings` 批量嵌入。
|
|
628
|
+
*
|
|
629
|
+
* 返回与 texts 等长同序的向量列表(按响应 `index` 字段还原顺序);响应缺 data/条数不符
|
|
630
|
+
* (HTTP 200+error 体)/网络/解析失败一律抛异常(重试 1 次),绝不静默返回可能错位的向量。
|
|
631
|
+
* **异步**(见文件头 ①)。
|
|
632
|
+
*/
|
|
633
|
+
export async function _post_embeddings(cfg, texts) {
|
|
634
|
+
const url = `${pyGetItem(cfg, 'api_base').replace(/\/+$/, '')}/embeddings`;
|
|
635
|
+
const body = pyJsonDumpsAscii({ model: pyGetItem(cfg, 'model'), input: texts });
|
|
636
|
+
const timeoutMs = Number(pyGetItem(cfg, 'timeout_s')) * 1000;
|
|
637
|
+
for (let attempt = 0; attempt <= HTTP_RETRY; attempt += 1) {
|
|
638
|
+
try {
|
|
639
|
+
const controller = new AbortController();
|
|
640
|
+
const timer = setTimeout(() => controller.abort(new Error('embeddings 请求超时')), timeoutMs);
|
|
641
|
+
let resp;
|
|
642
|
+
try {
|
|
643
|
+
resp = await fetch(url, {
|
|
644
|
+
method: 'POST',
|
|
645
|
+
headers: {
|
|
646
|
+
'Content-Type': 'application/json',
|
|
647
|
+
Authorization: `Bearer ${pyGetItem(cfg, 'api_key')}`,
|
|
648
|
+
},
|
|
649
|
+
body,
|
|
650
|
+
signal: controller.signal,
|
|
651
|
+
});
|
|
652
|
+
} finally {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
}
|
|
655
|
+
if (!resp.ok) throw new Error(`HTTP Error ${resp.status}: ${resp.statusText}`);
|
|
656
|
+
const raw = Buffer.from(await resp.arrayBuffer()).toString('utf8');
|
|
657
|
+
const data = JSON.parse(raw);
|
|
658
|
+
let items = data !== null && typeof data === 'object' && !Array.isArray(data)
|
|
659
|
+
? pyGet(data, 'data') : null;
|
|
660
|
+
if (!Array.isArray(items)) throw new ValueError('embeddings 响应缺 data');
|
|
661
|
+
items = pySortBy(items, (d) => pyGet(d, 'index', 0));
|
|
662
|
+
if (items.length !== texts.length) throw new ValueError('embeddings 返回条数不符');
|
|
663
|
+
return items.map((item) => pyGetItem(item, 'embedding'));
|
|
664
|
+
} catch (exc) { // HTTP/超时/解析失败统一计数重试
|
|
665
|
+
if (attempt >= HTTP_RETRY) throw exc;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return []; // 不可达(HTTP_RETRY >= 0 时最后一次必抛);仅为让控制流闭合
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --------------------------------------------------------------------------
|
|
672
|
+
// 分词与 BM25(纯标准库兜底检索,永远可用)
|
|
673
|
+
// --------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
/** ASCII/数字/下划线段(照 `WORD_RE`;`\d` 口径见文件头「残余差异」) */
|
|
676
|
+
const WORD_RE = /[A-Za-z0-9_]+/g;
|
|
677
|
+
/** CJK 连续段(照 `CJK_RE`;区间在 BMP,JS 码元遍历与 Python 码点等价) */
|
|
678
|
+
const CJK_RE = /[\u4e00-\u9fff]+/g;
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 查询/文档统一分词:ASCII/数字下划线段小写词;CJK 连续段字符 bigram(单字保底)。
|
|
682
|
+
*
|
|
683
|
+
* 「登录失败」→ `[登录, 录失, 失败]`。非 str 的真值入参照 Python 抛 TypeError。
|
|
684
|
+
*/
|
|
685
|
+
export function tokenize(text) {
|
|
686
|
+
const raw = pyOr(text, '');
|
|
687
|
+
if (typeof raw !== 'string') {
|
|
688
|
+
throw new TypeError(`expected string or bytes-like object, got '${pyTypeName(raw)}'`);
|
|
689
|
+
}
|
|
690
|
+
const tokens = [];
|
|
691
|
+
for (const m of raw.matchAll(WORD_RE)) tokens.push(m[0].toLowerCase());
|
|
692
|
+
for (const m of raw.matchAll(CJK_RE)) {
|
|
693
|
+
const seg = m[0];
|
|
694
|
+
if (seg.length === 1) tokens.push(seg);
|
|
695
|
+
else {
|
|
696
|
+
for (let i = 0; i < seg.length - 1; i += 1) tokens.push(seg[i] + seg[i + 1]);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return tokens;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* 扫描签名 = `(用例数, 全库最大 mtime)`:BM25 缓存与差量刷新的失效判定。
|
|
704
|
+
*
|
|
705
|
+
* 返回值照 Python 元组形态给**数组** `[count, latest]`。mtime 覆盖用例目录与 case.md。
|
|
706
|
+
*/
|
|
707
|
+
export function _scan_signature(root) {
|
|
708
|
+
let count = 0;
|
|
709
|
+
let latest = 0.0;
|
|
710
|
+
for (const rec of scanCases(root)) {
|
|
711
|
+
count += 1;
|
|
712
|
+
for (const p of [pyJoin(root, rec.dir), pyJoin(root, rec.dir, 'case.md')]) {
|
|
713
|
+
try {
|
|
714
|
+
latest = Math.max(latest, pyMtimeFloat(fs.statSync(p, { bigint: true }).mtimeNs));
|
|
715
|
+
} catch {
|
|
716
|
+
/* OSError → pass */
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return [count, latest];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** 由语料文本列表构建 BM25 内存倒排(词 → {文档序号: 词频})。 */
|
|
724
|
+
export function _bm25_build(texts) {
|
|
725
|
+
const inv = {};
|
|
726
|
+
const doc_len = [];
|
|
727
|
+
for (let i = 0; i < texts.length; i += 1) {
|
|
728
|
+
const toks = tokenize(texts[i]);
|
|
729
|
+
doc_len.push(toks.length);
|
|
730
|
+
for (const t of toks) {
|
|
731
|
+
if (!Object.hasOwn(inv, t)) inv[t] = {};
|
|
732
|
+
const postings = inv[t];
|
|
733
|
+
postings[i] = (postings[i] || 0) + 1;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
const total = doc_len.reduce((acc, v) => acc + v, 0);
|
|
737
|
+
return {
|
|
738
|
+
inv,
|
|
739
|
+
doc_len,
|
|
740
|
+
n: texts.length,
|
|
741
|
+
avgdl: doc_len.length ? total / doc_len.length : 0.0,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* CPython `math.log(x)`(正确舍入的 libm 对数)。
|
|
747
|
+
*
|
|
748
|
+
* **为什么不能直接用 `Math.log`**:V8 的 `Math.log` 是 fdlibm 移植(误差 < 1 ULP,**不保证**
|
|
749
|
+
* 正确舍入),而 oracle 侧是这个环境里 Python 的 `math.log`(=系统 libm 的 `log`,已用
|
|
750
|
+
* ctypes 对拍 2,001,000 个可达参数确认逐位相同)。在 BM25 的 IDF 可达参数
|
|
751
|
+
* `1 + (n-df+0.5)/(df+0.5)` 上实测(n ≤ 2000 全枚举 2,001,000 个参数):
|
|
752
|
+
* - `Math.log` 有 **150,065 个(7.5%)**与 oracle 差 1 ULP;
|
|
753
|
+
* - 本函数只有 **1,915 个(0.096%)**差 1 ULP,且 **n ≤ 8 的小语料 36 个参数全中**。
|
|
754
|
+
* 这 1 ULP 会渗进每个 BM25 得分 ⇒ 直接比原始浮点时会红,且「几乎同分」的两个文档排序
|
|
755
|
+
* 可能与 Python 相反。故这里自己算一个**正确舍入**的 log(比 V8 的实现少 78 倍分歧,
|
|
756
|
+
* 且小语料上完全一致)。
|
|
757
|
+
*
|
|
758
|
+
* 实现(不依赖任何浮点库的误差假设):把 double 精确拆成 `M·2^E` → 归约到 `m' = M/2^k ∈ [1,2)`
|
|
759
|
+
* → `ln(x) = ln(m') + (E+k)·ln2`,其中 `ln(m') = 2·atanh((m'-1)/(m'+1))` 的级数、以及 `ln2`
|
|
760
|
+
* 本身,都在 **256 位 BigInt 定点**下算(ratio 用整数除法取整、逐项截断,总误差 ≪ 2^-200),
|
|
761
|
+
* 最后按 **round-half-even** 精确舍入到 53 位有效数字并直接拼 IEEE 位模式(不经二次浮点运算)。
|
|
762
|
+
* 该函数只被 `_bm25_search` 调用(每个查询词一次),性能不是问题。
|
|
763
|
+
*
|
|
764
|
+
* 域行为照 CPython:`log(0)`/`log(负数)` 抛 `ValueError: math domain error`、
|
|
765
|
+
* `log(nan)=nan`、`log(inf)=inf`。
|
|
766
|
+
*/
|
|
767
|
+
export function pyLog(x) {
|
|
768
|
+
if (Number.isNaN(x)) return NaN;
|
|
769
|
+
if (x === Infinity) return Infinity;
|
|
770
|
+
if (!(x > 0)) throw new ValueError('math domain error');
|
|
771
|
+
const P = 256;
|
|
772
|
+
const view = new DataView(new ArrayBuffer(8));
|
|
773
|
+
view.setFloat64(0, x);
|
|
774
|
+
const raw = view.getBigUint64(0);
|
|
775
|
+
const expBits = Number((raw >> 52n) & 0x7ffn);
|
|
776
|
+
let M;
|
|
777
|
+
let E;
|
|
778
|
+
if (expBits === 0) {
|
|
779
|
+
M = raw & ((1n << 52n) - 1n); // 次正规
|
|
780
|
+
E = -1074;
|
|
781
|
+
} else {
|
|
782
|
+
M = (raw & ((1n << 52n) - 1n)) | (1n << 52n);
|
|
783
|
+
E = expBits - 1075;
|
|
784
|
+
}
|
|
785
|
+
const k = M.toString(2).length - 1; // M ∈ [2^k, 2^(k+1))
|
|
786
|
+
const twoK = 1n << BigInt(k);
|
|
787
|
+
const num = M - twoK;
|
|
788
|
+
const den = M + twoK; // f = num/den ∈ [0, 1/3]
|
|
789
|
+
const shift = BigInt(P);
|
|
790
|
+
let sum = 0n;
|
|
791
|
+
if (num !== 0n) {
|
|
792
|
+
let term = (num << shift) / den;
|
|
793
|
+
sum = term;
|
|
794
|
+
const f2 = (term * term) >> shift;
|
|
795
|
+
for (let i = 1n; term !== 0n; i += 1n) {
|
|
796
|
+
term = (term * f2) >> shift;
|
|
797
|
+
if (term === 0n) break;
|
|
798
|
+
sum += term / (2n * i + 1n);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const total = 2n * sum + BigInt(E + k) * ln2Fixed(P);
|
|
802
|
+
return fixedToDouble(total, P);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** `ln2` 的 P 位定点值(`2·atanh(1/3)` 级数,结果缓存) */
|
|
806
|
+
function ln2Fixed(P) {
|
|
807
|
+
if (ln2Fixed.cache !== null && ln2Fixed.cacheP === P) return ln2Fixed.cache;
|
|
808
|
+
const scale = 1n << BigInt(P);
|
|
809
|
+
let term = scale / 3n;
|
|
810
|
+
let sum = term;
|
|
811
|
+
const f2 = (term * term) >> BigInt(P);
|
|
812
|
+
for (let i = 1n; ; i += 1n) {
|
|
813
|
+
term = (term * f2) >> BigInt(P);
|
|
814
|
+
if (term === 0n) break;
|
|
815
|
+
sum += term / (2n * i + 1n);
|
|
816
|
+
}
|
|
817
|
+
ln2Fixed.cache = 2n * sum;
|
|
818
|
+
ln2Fixed.cacheP = P;
|
|
819
|
+
return ln2Fixed.cache;
|
|
820
|
+
}
|
|
821
|
+
ln2Fixed.cache = null;
|
|
822
|
+
ln2Fixed.cacheP = 0;
|
|
823
|
+
|
|
824
|
+
/** 定点值(`value / 2^P`)→ 最近 double(半偶;直接拼 IEEE 位,避免二次舍入) */
|
|
825
|
+
function fixedToDouble(value, P) {
|
|
826
|
+
if (value === 0n) return 0;
|
|
827
|
+
const neg = value < 0n;
|
|
828
|
+
const a = neg ? -value : value;
|
|
829
|
+
const bits = a.toString(2).length;
|
|
830
|
+
let e = bits - 1 - P; // |value/2^P| ∈ [2^e, 2^(e+1))
|
|
831
|
+
let sh = BigInt(bits - 53);
|
|
832
|
+
let m;
|
|
833
|
+
if (sh >= 0n) {
|
|
834
|
+
m = a >> sh;
|
|
835
|
+
const rem = a - (m << sh);
|
|
836
|
+
const half = 1n << (sh - 1n);
|
|
837
|
+
if (rem > half || (rem === half && (m & 1n) === 1n)) m += 1n;
|
|
838
|
+
if (m === (1n << 53n)) {
|
|
839
|
+
m >>= 1n;
|
|
840
|
+
e += 1;
|
|
841
|
+
}
|
|
842
|
+
} else {
|
|
843
|
+
m = a << -sh; // 有效位不足 53 位:左移补零(本模块不可达)
|
|
844
|
+
}
|
|
845
|
+
const out = new DataView(new ArrayBuffer(8));
|
|
846
|
+
out.setBigUint64(0, (BigInt(e + 1023) << 52n) | (m - (1n << 52n)));
|
|
847
|
+
return neg ? -out.getFloat64(0) : out.getFloat64(0);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* BM25 打分(k1/b 标准值 + 概率 IDF),返回 `[[文档序号, 得分], …]` 降序前 top。
|
|
852
|
+
*
|
|
853
|
+
* `set(tokenize(query))` 的**去重**照 Python;但迭代次序 JS 是插入序、Python 是哈希序
|
|
854
|
+
* (见文件头「残余差异」)——同一文档被多个查询词命中时浮点累加次序可能不同,用例需避开。
|
|
855
|
+
*/
|
|
856
|
+
export function _bm25_search(idx, query, top) {
|
|
857
|
+
const scores = new Map();
|
|
858
|
+
const n = pyGetItem(idx, 'n');
|
|
859
|
+
const avgdl = pyOr(pyGetItem(idx, 'avgdl'), 1.0);
|
|
860
|
+
const seen = new Set();
|
|
861
|
+
for (const t of tokenize(query)) {
|
|
862
|
+
if (seen.has(t)) continue;
|
|
863
|
+
seen.add(t);
|
|
864
|
+
const postings = pyOr(pyGet(pyGetItem(idx, 'inv'), t), null);
|
|
865
|
+
if (!postings) continue;
|
|
866
|
+
const keys = Object.keys(postings);
|
|
867
|
+
const idf = pyLog(1.0 + (n - keys.length + 0.5) / (keys.length + 0.5));
|
|
868
|
+
for (const key of keys) {
|
|
869
|
+
const i = Number(key);
|
|
870
|
+
const tf = postings[key];
|
|
871
|
+
const dl = pyIndex(pyGetItem(idx, 'doc_len'), i) || 1;
|
|
872
|
+
const gain = tf * (BM25_K1 + 1)
|
|
873
|
+
/ (tf + BM25_K1 * (1 - BM25_B + BM25_B * dl / avgdl));
|
|
874
|
+
scores.set(i, (scores.has(i) ? scores.get(i) : 0.0) + idf * gain);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return pySortByDesc([...scores.entries()], (kv) => kv[1]).slice(0, top);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** BM25 内存缓存(进程级单例;签名+TTL 双重失效,同 server.StateCache 风格) */
|
|
881
|
+
const _BM25_CACHE = { root: null, sig: null, at: 0.0, idx: null };
|
|
882
|
+
|
|
883
|
+
/** 带缓存的 BM25 索引:案例库未变且 1s 内直接复用,重建 <100ms(百级库)。 */
|
|
884
|
+
export function _bm25_cached(root, texts) {
|
|
885
|
+
const sig = _scan_signature(root);
|
|
886
|
+
const now = Date.now() / 1000;
|
|
887
|
+
const cache = _BM25_CACHE;
|
|
888
|
+
const sameSig = cache.sig !== null && cache.sig !== undefined
|
|
889
|
+
&& cache.sig[0] === sig[0] && cache.sig[1] === sig[1];
|
|
890
|
+
if (cache.root === root && sameSig && now - (cache.at || 0.0) < CACHE_TTL) return cache.idx;
|
|
891
|
+
const idx = _bm25_build(texts);
|
|
892
|
+
cache.root = root;
|
|
893
|
+
cache.sig = sig;
|
|
894
|
+
cache.at = now;
|
|
895
|
+
cache.idx = idx;
|
|
896
|
+
return idx;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// --------------------------------------------------------------------------
|
|
900
|
+
// 派生文件读写与差量刷新
|
|
901
|
+
// --------------------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
/** `<root>/.live/rag_index.jsonl` */
|
|
904
|
+
export function _index_path(root) {
|
|
905
|
+
return pyJoin(root, '.live', RAG_INDEX_NAME);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** `<root>/.live/similar.jsonl` */
|
|
909
|
+
export function _similar_path(root) {
|
|
910
|
+
return pyJoin(root, '.live', SIMILAR_NAME);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** 原子写 jsonl(tmp + rename),读方永远看到完整旧文件或完整新文件。 */
|
|
914
|
+
export function _atomic_write_jsonl(target, rows) {
|
|
915
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
916
|
+
const tmp = `${target}.tmp`;
|
|
917
|
+
let body = '';
|
|
918
|
+
for (const row of rows) body += `${pyJsonDumps(row)}\n`;
|
|
919
|
+
fs.writeFileSync(tmp, body, 'utf8');
|
|
920
|
+
fs.renameSync(tmp, target);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** 读 rag_index.jsonl → `{id: 行}`;文件缺失/行损坏按缺失处理(重建自愈)。 */
|
|
924
|
+
export function _load_index(root) {
|
|
925
|
+
const out = {};
|
|
926
|
+
for (const raw of pySplitLines(readText(_index_path(root)))) {
|
|
927
|
+
const line = raw.trim();
|
|
928
|
+
if (!line) continue;
|
|
929
|
+
let rec;
|
|
930
|
+
try {
|
|
931
|
+
rec = JSON.parse(line);
|
|
932
|
+
} catch {
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (rec !== null && typeof rec === 'object' && !Array.isArray(rec)
|
|
936
|
+
&& pyTruthy(pyGet(rec, 'id'))) {
|
|
937
|
+
out[pyGet(rec, 'id')] = rec;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** 读通用 jsonl 文件为行 dict 列表(CLI 展示与测试用)。 */
|
|
944
|
+
export function _load_jsonl(target) {
|
|
945
|
+
const out = [];
|
|
946
|
+
for (const raw of pySplitLines(readText(target))) {
|
|
947
|
+
const line = raw.trim();
|
|
948
|
+
if (!line) continue;
|
|
949
|
+
try {
|
|
950
|
+
out.push(JSON.parse(line));
|
|
951
|
+
} catch {
|
|
952
|
+
/* ValueError → pass */
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** 向量 L2 归一化(零向量安全),归一化后点积即余弦。 */
|
|
959
|
+
export function _norm(vec) {
|
|
960
|
+
let sum = 0;
|
|
961
|
+
for (const x of vec) sum += x * x;
|
|
962
|
+
let n = Math.sqrt(sum);
|
|
963
|
+
if (n === 0) n = 1.0;
|
|
964
|
+
const out = [];
|
|
965
|
+
for (const x of vec) out.push(x / n);
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** `sum(x*y for x, y in zip(a, b))`(zip 截断到较短者) */
|
|
970
|
+
export function _dot(a, b) {
|
|
971
|
+
let total = 0;
|
|
972
|
+
const n = Math.min(a.length, b.length);
|
|
973
|
+
for (let i = 0; i < n; i += 1) total += a[i] * b[i];
|
|
974
|
+
return total;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* 全库两两余弦取 top-5 近邻(向量均归一化,点积即余弦)。
|
|
979
|
+
*
|
|
980
|
+
* 行格式 `{"id","name","similar":[{"id","name","score"}]}`;`score` = `round(s, 4)`
|
|
981
|
+
* (**半偶**,见文件头 2)。`scored.sort(reverse=True)` 按 `(score, id)` 元组降序 ⇒ 同分时
|
|
982
|
+
* id 大者在前(与 Python 一致,且与 `set` 迭代序无关)。
|
|
983
|
+
*/
|
|
984
|
+
export function _similar_rows(index) {
|
|
985
|
+
const ids = Object.keys(index).sort(pyStrCompare);
|
|
986
|
+
const rows = [];
|
|
987
|
+
for (const rid of ids) {
|
|
988
|
+
const vec = pyOr(pyGet(pyGetItem(index, rid), 'vec'), []);
|
|
989
|
+
const scored = [];
|
|
990
|
+
for (const other of ids) {
|
|
991
|
+
if (other === rid) continue;
|
|
992
|
+
scored.push([_dot(vec, pyOr(pyGet(pyGetItem(index, other), 'vec'), [])), other]);
|
|
993
|
+
}
|
|
994
|
+
scored.sort((a, b) => {
|
|
995
|
+
if (pyLess(a[0], b[0])) return 1;
|
|
996
|
+
if (pyLess(b[0], a[0])) return -1;
|
|
997
|
+
return pyStrCompare(b[1], a[1]);
|
|
998
|
+
});
|
|
999
|
+
rows.push({
|
|
1000
|
+
id: rid,
|
|
1001
|
+
name: pyGet(pyGetItem(index, rid), 'name', ''),
|
|
1002
|
+
similar: scored.slice(0, TOPK_SIMILAR).map(([s, other]) => ({
|
|
1003
|
+
id: other,
|
|
1004
|
+
name: pyGet(pyGetItem(index, other), 'name', ''),
|
|
1005
|
+
score: pyRound(s, 4),
|
|
1006
|
+
})),
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return rows;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* 索引增量维护:差量嵌入 + 近邻重算。RAG 未配置时 no-op(不产生任何文件)。
|
|
1014
|
+
*
|
|
1015
|
+
* 返回 `{enabled, embedded, total}`;存在 stale(索引不完整)时不动 similar.jsonl。
|
|
1016
|
+
* **异步**(`_post_embeddings` 是 Promise,见文件头 ①)。
|
|
1017
|
+
*
|
|
1018
|
+
* 照抄 Python 的一处**误导性日志**:`stale_left` 为空且本轮无变更、similar 已存在时,
|
|
1019
|
+
* 走的是 `elif log` 分支,会打出「RAG 索引不完整(待嵌 0 条)」——行为(不写盘)是对的,
|
|
1020
|
+
* 文案是错的,但既然是 oracle 就照搬(M6-3 报告已记)。
|
|
1021
|
+
*/
|
|
1022
|
+
export async function refresh(root, log = null) {
|
|
1023
|
+
const cfg = load_config();
|
|
1024
|
+
const records = scanCases(root);
|
|
1025
|
+
if (!cfg) return { enabled: false, embedded: 0, total: records.length };
|
|
1026
|
+
let index = _load_index(root);
|
|
1027
|
+
const rec_map = {};
|
|
1028
|
+
for (const rec of records) rec_map[pyGetItem(rec, 'id')] = rec;
|
|
1029
|
+
// 模型变更 → 全量重建(旧向量全部失配)
|
|
1030
|
+
if (Object.keys(index).length && Object.values(index).some((r) => pyGet(r, 'model') !== cfg.model)) {
|
|
1031
|
+
index = {};
|
|
1032
|
+
}
|
|
1033
|
+
const texts = {};
|
|
1034
|
+
const hashes = {};
|
|
1035
|
+
for (const rec of records) {
|
|
1036
|
+
const t = embedding_text(root, rec);
|
|
1037
|
+
texts[pyGetItem(rec, 'id')] = t;
|
|
1038
|
+
hashes[pyGetItem(rec, 'id')] = text_hash(t, cfg.model);
|
|
1039
|
+
}
|
|
1040
|
+
const stale = Object.keys(hashes).filter((rid) => !Object.hasOwn(index, rid)
|
|
1041
|
+
|| pyGet(pyGetItem(index, rid), 'hash') !== hashes[rid]);
|
|
1042
|
+
let embedded = 0;
|
|
1043
|
+
const batch = Math.max(1, pyInt(pyOr(pyGet(cfg, 'batch_size'), 16)));
|
|
1044
|
+
for (let start = 0; start < stale.length; start += batch) {
|
|
1045
|
+
const part = stale.slice(start, start + batch);
|
|
1046
|
+
let vecs;
|
|
1047
|
+
try {
|
|
1048
|
+
vecs = await _post_embeddings(cfg, part.map((rid) => texts[rid]));
|
|
1049
|
+
} catch (exc) {
|
|
1050
|
+
// Python 的 `f"{exc}"` 是 `str(exc)`:**没有类名前缀**(`str(Error("x"))` 在 JS 里是
|
|
1051
|
+
// `"Error: x"`)⇒ 必须取 message,否则日志文案与 Python 差一个 `Error: `。
|
|
1052
|
+
if (log) {
|
|
1053
|
+
const reason = exc && exc.message !== undefined ? String(exc.message) : String(exc);
|
|
1054
|
+
log(`RAG 嵌入失败(保留旧索引,待嵌 ${stale.length - embedded} 条):${reason}`);
|
|
1055
|
+
}
|
|
1056
|
+
break;
|
|
1057
|
+
}
|
|
1058
|
+
const n = Math.min(part.length, vecs.length);
|
|
1059
|
+
for (let j = 0; j < n; j += 1) {
|
|
1060
|
+
const rid = part[j];
|
|
1061
|
+
const vec = vecs[j];
|
|
1062
|
+
index[rid] = {
|
|
1063
|
+
id: rid,
|
|
1064
|
+
name: pyGetItem(rec_map[rid], 'name'),
|
|
1065
|
+
hash: hashes[rid],
|
|
1066
|
+
model: cfg.model,
|
|
1067
|
+
dim: pyLen(vec),
|
|
1068
|
+
vec: _norm(vec),
|
|
1069
|
+
};
|
|
1070
|
+
embedded += 1;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// 逐出已删除用例
|
|
1074
|
+
const removed = Object.keys(index).filter((rid) => !Object.hasOwn(hashes, rid));
|
|
1075
|
+
for (const rid of removed) delete index[rid];
|
|
1076
|
+
if (embedded || removed.length) {
|
|
1077
|
+
_atomic_write_jsonl(_index_path(root),
|
|
1078
|
+
Object.values(index).sort((a, b) => pyStrCompare(pyGetItem(a, 'id'), pyGetItem(b, 'id'))));
|
|
1079
|
+
}
|
|
1080
|
+
// 索引完整(无 stale)才重算 similar,防止嵌入失败期间输出残缺近邻;
|
|
1081
|
+
// 且本轮无任何变更(无新嵌、无删除)且 similar.jsonl 已存在时直接跳过(零无用开销)。
|
|
1082
|
+
const stale_left = Object.keys(hashes).filter((rid) => !Object.hasOwn(index, rid)
|
|
1083
|
+
|| pyGet(pyGetItem(index, rid), 'hash') !== hashes[rid]);
|
|
1084
|
+
if (!stale_left.length
|
|
1085
|
+
&& (embedded || removed.length || !fs.existsSync(_similar_path(root)))) {
|
|
1086
|
+
_atomic_write_jsonl(_similar_path(root), _similar_rows(index));
|
|
1087
|
+
} else if (log) {
|
|
1088
|
+
log(`RAG 索引不完整(待嵌 ${stale_left.length} 条),本轮不更新 similar.jsonl`);
|
|
1089
|
+
}
|
|
1090
|
+
return { enabled: true, embedded, total: records.length };
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// --------------------------------------------------------------------------
|
|
1094
|
+
// 混合检索(RRF 融合 + 多级降级,永不抛出)
|
|
1095
|
+
// --------------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* 倒数排名融合:`score = Σ 1/(RRF_K + rank)`;`via` 记录命中来源(**字典序**拼接)。
|
|
1099
|
+
*
|
|
1100
|
+
* `pools` 是普通对象(键序=池的插入序,照 Python dict 迭代序);返回 `[[i, score, via], …]`。
|
|
1101
|
+
*/
|
|
1102
|
+
export function _rrf_merge(pools, k) {
|
|
1103
|
+
const agg = new Map();
|
|
1104
|
+
for (const name of Object.keys(pools)) {
|
|
1105
|
+
const hits = pools[name];
|
|
1106
|
+
for (let rank = 0; rank < hits.length; rank += 1) {
|
|
1107
|
+
const i = hits[rank][0];
|
|
1108
|
+
const cur = agg.get(i) || [0.0, new Set()];
|
|
1109
|
+
cur[0] += 1.0 / (RRF_K + rank + 1);
|
|
1110
|
+
cur[1].add(name);
|
|
1111
|
+
agg.set(i, cur);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const out = [];
|
|
1115
|
+
for (const [i, [score, via]] of agg) {
|
|
1116
|
+
out.push([i, score, [...via].sort(pyStrCompare).join('+')]);
|
|
1117
|
+
}
|
|
1118
|
+
return pySortByDesc(out, (row) => row[1]).slice(0, k);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** 最终兜底:`scan_cases` 字段 substring 关键词过滤(连 RAG 底座都异常时仍可用)。 */
|
|
1122
|
+
export function _fallback(root, query, k) {
|
|
1123
|
+
const records = scanCases(root);
|
|
1124
|
+
const q = pyOr(query, '');
|
|
1125
|
+
if (typeof q !== 'string') {
|
|
1126
|
+
throw new AttributeError(`'${pyTypeName(q)}' object has no attribute 'strip'`);
|
|
1127
|
+
}
|
|
1128
|
+
const kw = pyStrStrip(q);
|
|
1129
|
+
let hits;
|
|
1130
|
+
if (kw) {
|
|
1131
|
+
hits = records.filter((r) => ['dir', 'name', 'interface', 'module']
|
|
1132
|
+
.some((f) => pyStr(pyOr(pyGet(r, f), '')).includes(kw)));
|
|
1133
|
+
} else {
|
|
1134
|
+
hits = [];
|
|
1135
|
+
}
|
|
1136
|
+
return hits.slice(0, k).map((r) => ({ ...r, score: 0.0, via: 'kw' }));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* 混合检索入口(带元数据版):BM25 恒可用;配置了 RAG 则向量路并入 RRF 融合。
|
|
1141
|
+
*
|
|
1142
|
+
* 返回 `[rows, meta]`(照 Python 的 `(rows, meta)` 元组用数组表达);`meta` 回答
|
|
1143
|
+
* 「这次检索到底用没用 RAG」。任何异常降级到关键词兜底,永不抛出。**异步**(文件头 ①)。
|
|
1144
|
+
*/
|
|
1145
|
+
export async function retrieve_detail(root, query, k = DEFAULT_K) {
|
|
1146
|
+
const meta = { configured: false, vec_ok: false, vec_error: null };
|
|
1147
|
+
try {
|
|
1148
|
+
try {
|
|
1149
|
+
await refresh(root); // 惰性差量:案例库被其他任务更新过则先补索引(未配置 no-op)
|
|
1150
|
+
} catch {
|
|
1151
|
+
/* pass */
|
|
1152
|
+
}
|
|
1153
|
+
const records = scanCases(root);
|
|
1154
|
+
if (!records.length) return [[], meta];
|
|
1155
|
+
const texts = records.map((rec) => embedding_text(root, rec));
|
|
1156
|
+
const pools = { bm25: _bm25_search(_bm25_cached(root, texts), query, CAND_TOP) };
|
|
1157
|
+
const cfg = load_config();
|
|
1158
|
+
if (cfg) {
|
|
1159
|
+
meta.configured = true;
|
|
1160
|
+
try {
|
|
1161
|
+
const qvec = _norm((await _post_embeddings(cfg,
|
|
1162
|
+
[pySliceCodePoints(pyOr(query, ''), EMBED_TEXT_MAX)]))[0]);
|
|
1163
|
+
const index = _load_index(root);
|
|
1164
|
+
const vec_hits = [];
|
|
1165
|
+
records.forEach((rec, i) => {
|
|
1166
|
+
const item = pyGet(index, pyGetItem(rec, 'id'));
|
|
1167
|
+
if (item && pyTruthy(pyGet(item, 'vec'))) vec_hits.push([i, _dot(qvec, pyGet(item, 'vec'))]);
|
|
1168
|
+
});
|
|
1169
|
+
pools.vec = pySortByDesc(vec_hits, (row) => row[1]).slice(0, CAND_TOP);
|
|
1170
|
+
meta.vec_ok = true;
|
|
1171
|
+
} catch (exc) {
|
|
1172
|
+
// 嵌入失败:向量路缺席,BM25 结果照常返回;原因截断后记入 meta
|
|
1173
|
+
const text = exc && exc.message !== undefined ? String(exc.message) : String(exc);
|
|
1174
|
+
meta.vec_error = pySliceCodePoints(text
|
|
1175
|
+
|| (exc && exc.constructor ? exc.constructor.name : 'Error'), 200);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const merged = _rrf_merge(pools, k);
|
|
1179
|
+
return [merged.map(([i, s, via]) => ({ ...records[i], score: pyRound(s, 4), via })), meta];
|
|
1180
|
+
} catch {
|
|
1181
|
+
try {
|
|
1182
|
+
return [_fallback(root, query, k), meta];
|
|
1183
|
+
} catch {
|
|
1184
|
+
return [[], meta]; // 连兜底都异常(如案例库根不可读)时返回空,保证永不抛出
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** 混合检索入口(兼容壳):只返回结果行;检索元数据见 `retrieve_detail`。 */
|
|
1190
|
+
export async function retrieve(root, query, k = DEFAULT_K) {
|
|
1191
|
+
return (await retrieve_detail(root, query, k))[0];
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** `git_log_changes` 结果 → 检索 query:变更文件路径 + 提交摘录(截 4000 **码点**)。 */
|
|
1195
|
+
export function change_query(log) {
|
|
1196
|
+
const parts = pyListOf(pyGet(log, 'files')).concat(pyListOf(pyGet(log, 'commits')));
|
|
1197
|
+
parts.forEach((p, i) => {
|
|
1198
|
+
if (typeof p !== 'string') {
|
|
1199
|
+
throw new TypeError(`sequence item ${i}: expected str instance, ${pyTypeName(p)} found`);
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
return pySliceCodePoints(parts.join('\n'), 4000);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** 强制全量重建:删索引文件使 refresh 全量 stale。 */
|
|
1206
|
+
export async function cmd_rebuild(args) {
|
|
1207
|
+
try {
|
|
1208
|
+
fs.unlinkSync(_index_path(args.root));
|
|
1209
|
+
} catch (exc) {
|
|
1210
|
+
if (!isFsError(exc)) throw exc;
|
|
1211
|
+
}
|
|
1212
|
+
const stat = await refresh(args.root, (m) => process.stderr.write(`${m}\n`));
|
|
1213
|
+
process.stdout.write(`${pyJsonDumps(stat)}\n`);
|
|
1214
|
+
const similar = _load_jsonl(_similar_path(args.root));
|
|
1215
|
+
process.stdout.write(`similar.jsonl ${similar.length} 条\n`);
|
|
1216
|
+
return 0;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** 检索试查:打印 top-k(`id [via] score 相对路径`);向量路降级时 stderr 提示。 */
|
|
1220
|
+
export async function cmd_search(args) {
|
|
1221
|
+
const [rows, meta] = await retrieve_detail(args.root, args.query, args.k);
|
|
1222
|
+
for (const r of rows) {
|
|
1223
|
+
process.stdout.write(`${r.id} [${r.via}] ${r.score} ${r.dir}\n`);
|
|
1224
|
+
}
|
|
1225
|
+
if (meta.configured && !meta.vec_ok) {
|
|
1226
|
+
process.stderr.write(`向量路失败,本次为纯 BM25 结果:${meta.vec_error}\n`);
|
|
1227
|
+
}
|
|
1228
|
+
return 0;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* CLI 主入口(退出码照 argparse:成功 0、用法错 2)。
|
|
1233
|
+
*
|
|
1234
|
+
* argparse 的 usage/help 文案**不逐字节复刻**(那是帮助文本,不是行为契约,与 M6-2 的
|
|
1235
|
+
* `export-cases.js` 同一条取舍);对拍用例只用 `rebuild` 的退出码与 stdout。
|
|
1236
|
+
*/
|
|
1237
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
1238
|
+
const usage = '用法:rag.py {rebuild,search} ...\n';
|
|
1239
|
+
const [cmd, ...rest] = argv;
|
|
1240
|
+
if (cmd === 'rebuild') {
|
|
1241
|
+
if (rest.length !== 1) {
|
|
1242
|
+
process.stderr.write(usage);
|
|
1243
|
+
process.stderr.write('rag.py: error: the following arguments are required: root\n');
|
|
1244
|
+
return 2;
|
|
1245
|
+
}
|
|
1246
|
+
return cmd_rebuild({ root: rest[0] });
|
|
1247
|
+
}
|
|
1248
|
+
if (cmd === 'search') {
|
|
1249
|
+
const args = { root: '', query: '', k: DEFAULT_K };
|
|
1250
|
+
const positional = [];
|
|
1251
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1252
|
+
if (rest[i] === '--k') {
|
|
1253
|
+
const raw = rest[i + 1];
|
|
1254
|
+
i += 1;
|
|
1255
|
+
if (!/^[+-]?[0-9]+$/.test(String(raw))) {
|
|
1256
|
+
process.stderr.write(usage);
|
|
1257
|
+
process.stderr.write(`rag.py: error: argument --k: invalid int value: '${raw}'\n`);
|
|
1258
|
+
return 2;
|
|
1259
|
+
}
|
|
1260
|
+
args.k = Number(raw);
|
|
1261
|
+
} else positional.push(rest[i]);
|
|
1262
|
+
}
|
|
1263
|
+
if (positional.length !== 2) {
|
|
1264
|
+
process.stderr.write(usage);
|
|
1265
|
+
process.stderr.write('rag.py: error: the following arguments are required: root, query\n');
|
|
1266
|
+
return 2;
|
|
1267
|
+
}
|
|
1268
|
+
[args.root, args.query] = positional;
|
|
1269
|
+
return cmd_search(args);
|
|
1270
|
+
}
|
|
1271
|
+
process.stderr.write(usage);
|
|
1272
|
+
process.stderr.write('rag.py: error: the following arguments are required: cmd\n');
|
|
1273
|
+
return 2;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// --------------------------------------------------------------------------
|
|
1277
|
+
// camelCase 别名(供 JS 调用方;对拍用例逐条用 nodeFn 指向**主名**,不依赖别名)
|
|
1278
|
+
// --------------------------------------------------------------------------
|
|
1279
|
+
|
|
1280
|
+
export {
|
|
1281
|
+
config_path as configPath,
|
|
1282
|
+
load_config as loadConfig,
|
|
1283
|
+
raw_config as rawConfig,
|
|
1284
|
+
_cfg_int as _cfgInt,
|
|
1285
|
+
save_config as saveConfig,
|
|
1286
|
+
test_connection as testConnection,
|
|
1287
|
+
embedding_text as embeddingText,
|
|
1288
|
+
text_hash as textHash,
|
|
1289
|
+
_post_embeddings as _postEmbeddings,
|
|
1290
|
+
_scan_signature as _scanSignature,
|
|
1291
|
+
_bm25_build as _bm25Build,
|
|
1292
|
+
_bm25_search as _bm25Search,
|
|
1293
|
+
_bm25_cached as _bm25Cached,
|
|
1294
|
+
_index_path as _indexPath,
|
|
1295
|
+
_similar_path as _similarPath,
|
|
1296
|
+
_atomic_write_jsonl as _atomicWriteJsonl,
|
|
1297
|
+
_load_index as _loadIndex,
|
|
1298
|
+
_load_jsonl as _loadJsonl,
|
|
1299
|
+
_similar_rows as _similarRows,
|
|
1300
|
+
_rrf_merge as _rrfMerge,
|
|
1301
|
+
retrieve_detail as retrieveDetail,
|
|
1302
|
+
change_query as changeQuery,
|
|
1303
|
+
cmd_rebuild as cmdRebuild,
|
|
1304
|
+
cmd_search as cmdSearch,
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
// 直接执行时才跑(被 import 时不动)——`node backend/lib/rag.js rebuild <root>`
|
|
1308
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1309
|
+
process.exitCode = await main();
|
|
1310
|
+
}
|