@nightpainters/dsh-ling 1.6.2 → 1.7.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/CHANGELOG.md +130 -4
- package/DESIGN.md +89 -6
- package/PHILOSOPHY.md +14 -4
- package/README.md +102 -18
- package/cordis.patch.yml +32 -2
- package/lib/client.js +2520 -55
- package/lib/host/agent-tools.js +286 -0
- package/lib/host/api.js +592 -59
- package/lib/host/archive.js +3 -3
- package/lib/host/autotree.js +2 -2
- package/lib/host/clock-skill.js +1 -1
- package/lib/host/config.js +38 -0
- package/lib/host/deepsummary.js +75 -2
- package/lib/host/dsweb-summary.js +6 -3
- package/lib/host/guard.js +53 -0
- package/lib/host/inject.js +12 -2
- package/lib/host/l1.js +13 -8
- package/lib/host/library-checker.js +552 -0
- package/lib/host/library-import-engine.js +211 -0
- package/lib/host/library-import.js +911 -0
- package/lib/host/library-runtime.js +199 -0
- package/lib/host/library-sqlite.js +344 -0
- package/lib/host/library-store.js +1714 -0
- package/lib/host/memory.js +73 -19
- package/lib/host/persona.js +13 -2
- package/lib/host/rawlog.js +1 -1
- package/lib/host/recall-search.js +45 -8
- package/lib/host/snapshot.js +2 -1
- package/lib/host/surgery.js +6 -4
- package/lib/host/tools.js +460 -19
- package/lib/host/util.js +1 -1
- package/lib/index.js +120 -81
- package/package.json +10 -5
- package/tools/deep-import-docs.mjs +1210 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7 图书馆 · **导入通道**(第一增量:CSV 表格 + 粘贴文本)
|
|
3
|
+
*
|
|
4
|
+
* 契约:[plans/1.7-library-import-contract.md]。本模块只做**两件事**:
|
|
5
|
+
* ① `analyzeImport` —— 源 → 候选(**纯函数式,绝不写库**;契约 §2.1)
|
|
6
|
+
* ② `commitImport` —— 人审后的候选 → **既有 `store.create`**(不另开写入口;契约 §2.2)
|
|
7
|
+
*
|
|
8
|
+
* 为什么分析阶段必须纯:同一份表格会被反复重跑(改分类、改粒度、换模型),
|
|
9
|
+
* 只要有一次"顺手写进去",人审就失去意义 —— 所以库里那一行的存在性**只能**由 commit 决定。
|
|
10
|
+
*
|
|
11
|
+
* ## 单位口径(别在别处再定一套)
|
|
12
|
+
* - `locator`:CSV = **行号,1-based,表头算第 1 行**(契约 §4);text = 行段 `"起点-终点"`(含两端);
|
|
13
|
+
* JSON = 数组 `[i]`(**含方括号**,1-based)/ JSONL 裸行号 / 单对象 `$`(选项文档 §11.2 逐字钉死)。
|
|
14
|
+
* - `evidence` 与 `body`:CSV = 该行的**原文行**(不是重新拼出来的 `cells.join(',')` ——
|
|
15
|
+
* 带引号的行重新拼会变样,而 evidence 的唯一用途是"落回原文";契约 §5)。
|
|
16
|
+
* - `import_key = sha256(sourceBytes).hex.slice(0,16) + ':' + locator`(契约 §6 逐字)。
|
|
17
|
+
*
|
|
18
|
+
* - `locator`:SQLite = `表名#键值`(主键值;无主键的表用 `rowid`;见选项文档 §11.3)。
|
|
19
|
+
* - `import_key`:SQLite 的"源"是**文件身份**(`realpath|size|mtimeMs`,D15),不是内容文本。
|
|
20
|
+
*
|
|
21
|
+
* ## 三道闸的分工(不许合并)
|
|
22
|
+
* - 第 1 道 **定位回查**(本模块 `verifyImportCandidate`):evidence 必须落回原文 locator 处,
|
|
23
|
+
* 落不回 ⇒ `locator_verified:false` + `issues:['evidence_not_found']` + **默认不勾选**。
|
|
24
|
+
* 这是拦"模型顺手编一个值"的**唯一机械手段**。
|
|
25
|
+
* - 第 2 道 **字段闸**(本模块,产出 `issues`):只是**提前告知**(省得填完才被拒)。
|
|
26
|
+
* - 第 3 道 **`create` 自己的校验**:最终判据。三条**不合并** —— create 那把尺不因为这里判过就松。
|
|
27
|
+
*
|
|
28
|
+
* ## 回执纪律(契约 §8)
|
|
29
|
+
* 空输入/空表/只有表头 ⇒ 如实说"**没有候选**",**不是**"导入成功 0 条":
|
|
30
|
+
* 所以分析回执里**没有** `committed` 这类入库词汇,只有 `candidates` / `stats`。
|
|
31
|
+
*/
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_LIBRARY_LIMITS, LIBRARY_CATEGORIES, LIBRARY_SOURCE_KINDS,
|
|
35
|
+
normalizeTextLf, utf8Bytes,
|
|
36
|
+
} from './library-store.js';
|
|
37
|
+
|
|
38
|
+
const HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
39
|
+
const SOURCE_HASH_HEX_RE = /^[0-9a-f]{16}$/;
|
|
40
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
41
|
+
const LOCATOR_LINE_RE = /^[1-9]\d*$/;
|
|
42
|
+
const LOCATOR_RANGE_RE = /^([1-9]\d*)-([1-9]\d*)$/;
|
|
43
|
+
/** JSON 数组元素:`[i]`(**含方括号**,1-based)—— 选项文档 §11.2 钉死的**唯一**写法。 */
|
|
44
|
+
const LOCATOR_JSON_INDEX_RE = /^\[[1-9]\d*\]$/;
|
|
45
|
+
/** JSON 单对象:整份一条。 */
|
|
46
|
+
const LOCATOR_JSON_WHOLE = '$';
|
|
47
|
+
/** JSON 嵌套深度上限(防 JSON 炸弹;正常资料 / 规格的 JSON 远低于此)。 */
|
|
48
|
+
export const JSON_MAX_DEPTH = 32;
|
|
49
|
+
/**
|
|
50
|
+
* **直通**的字段名:命中就直接当条目字段用。
|
|
51
|
+
* ⚠️ 直通**不等于绕过校验** —— 值原样交给 `create` 去判(`checked_state` 照样 `readonly_field`)。
|
|
52
|
+
*/
|
|
53
|
+
const JSON_PASSTHROUGH_KEYS = Object.freeze([
|
|
54
|
+
'title', 'body', 'category', 'source_kind', 'source_ref', 'as_of',
|
|
55
|
+
// ⚠️ 这三键**必须在场**(2026-10-15 复核抓出来的漏):它们是 `create` 认可的条目字段
|
|
56
|
+
// (`library-store.js` 的 `ITEM_INPUT_KEYS`),而且 `check_spec` 是**机器可核对**的那一处比较 ——
|
|
57
|
+
// 漏掉的后果不是报错,而是把它**静默降级成正文里的 prose**(机器可核对 ⇒ 变成人肉可读),
|
|
58
|
+
// 正好踩在本项目"核对只认证声明的那处比较"的要害上。
|
|
59
|
+
'version', 'conditions', 'check_spec',
|
|
60
|
+
]);
|
|
61
|
+
/** 拼正文时的"其他字段"分隔行(让"正文里哪一段是原样、哪一段是拼的"一眼可辨)。 */
|
|
62
|
+
const JSON_EXTRA_HEADER = '— 其他字段 —';
|
|
63
|
+
|
|
64
|
+
export const IMPORT_KINDS = Object.freeze(['csv', 'text', 'json', 'sqlite']);
|
|
65
|
+
export const IMPORT_DEFAULT_CATEGORY = 'standard-value';
|
|
66
|
+
export const IMPORT_DEFAULT_SOURCE_KIND = 'manual';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* **导入层**字段:进 `create` 之前必须剥掉(它们不是条目字段)。
|
|
70
|
+
* ⚠️ 只剥这一张表 —— 不许把"白名单拷贝"当净化:那样 `checked_state` 会被**静默剥掉**而条目照进,
|
|
71
|
+
* 正好绕过 §2.3(防伪认证)。其余字段一律**原样**交给 create 去判,该报 unknown_field/readonly_field 就报。
|
|
72
|
+
*/
|
|
73
|
+
export const IMPORT_ONLY_KEYS = Object.freeze([
|
|
74
|
+
'index', 'origin', 'issues', 'selected', 'locator_verified', 'locator', 'evidence',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** 会**阻止勾选**的 issues(全部都是阻断性的:一条不干净 ⇒ 默认不勾,要人工显式保留)。 */
|
|
78
|
+
const BLOCKING_ISSUES = Object.freeze([
|
|
79
|
+
'locator_missing', 'locator_out_of_range', 'evidence_not_found',
|
|
80
|
+
'empty_title', 'empty_body', 'too_large', 'bad_category', 'bad_source_kind',
|
|
81
|
+
'missing_source', 'missing_as_of', 'bad_as_of',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
85
|
+
const isoToday = () => new Date().toISOString().slice(0, 10);
|
|
86
|
+
|
|
87
|
+
// ── 幂等键 ────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
/** 全文摘要(`sha256:<64hex>`)—— 幂等键的一半,也是"同一份源"的身份。 */
|
|
89
|
+
export function importSourceHash(text) {
|
|
90
|
+
return 'sha256:' + createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 契约 §6 逐字:`sha256(sourceBytes).hex.slice(0,16) + ':' + locator`。
|
|
95
|
+
* 摘要前 16 位十六进制 = 64 bit —— 同一批导入里够用,它只需在**同一份源**内区分定位。
|
|
96
|
+
* 形状不对**抛**:静默拼出一个短键会让两个不同的源撞进同一格(那正是幂等要防的事)。
|
|
97
|
+
*/
|
|
98
|
+
export function importKey(sourceHash, locator) {
|
|
99
|
+
const hex = String(sourceHash ?? '').replace(/^sha256:/i, '').toLowerCase();
|
|
100
|
+
if (!SOURCE_HASH_HEX_RE.test(hex.slice(0, 16)) || hex.length < 16) {
|
|
101
|
+
throw new RangeError(`import_key 需要 sha256:<64hex> 形状的源摘要(实际 ${JSON.stringify(String(sourceHash))})`);
|
|
102
|
+
}
|
|
103
|
+
const loc = String(locator ?? '').trim();
|
|
104
|
+
if (loc === '') throw new RangeError('import_key 需要非空 locator(空定位无法区分同源的多个条目)');
|
|
105
|
+
return hex.slice(0, 16) + ':' + loc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── 源解析 ────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* RFC 4180 子集:引号包裹、`""` 转义、字段内换行。零依赖(契约 §1 拍板 ②)。
|
|
111
|
+
* 每个 row 记下自己的**起始行号**与**原文片段**(`raw`)—— 后者是 evidence 的落点:
|
|
112
|
+
* 行内带引号或换行时,`cells.join(',')` 跟原文不一致,拿它当 evidence 就会假红。
|
|
113
|
+
*/
|
|
114
|
+
function parseCsvRows(text) {
|
|
115
|
+
const src = normalizeTextLf(text);
|
|
116
|
+
const rows = [];
|
|
117
|
+
let cells = [];
|
|
118
|
+
let cell = '';
|
|
119
|
+
let inQuotes = false;
|
|
120
|
+
let line = 1;
|
|
121
|
+
let rowStartLine = 1;
|
|
122
|
+
let rowStartAt = 0;
|
|
123
|
+
const closeRow = (endAt) => {
|
|
124
|
+
cells.push(cell);
|
|
125
|
+
rows.push({ cells, raw: src.slice(rowStartAt, endAt), startLine: rowStartLine });
|
|
126
|
+
cells = [];
|
|
127
|
+
cell = '';
|
|
128
|
+
};
|
|
129
|
+
let i = 0;
|
|
130
|
+
while (i < src.length) {
|
|
131
|
+
const ch = src[i];
|
|
132
|
+
if (inQuotes) {
|
|
133
|
+
if (ch === '"') {
|
|
134
|
+
if (src[i + 1] === '"') { cell += '"'; i += 2; continue; }
|
|
135
|
+
inQuotes = false; i += 1; continue;
|
|
136
|
+
}
|
|
137
|
+
if (ch === '\n') line += 1;
|
|
138
|
+
cell += ch; i += 1; continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === '"' && cell === '') { inQuotes = true; i += 1; continue; }
|
|
141
|
+
if (ch === ',') { cells.push(cell); cell = ''; i += 1; continue; }
|
|
142
|
+
if (ch === '\n') {
|
|
143
|
+
closeRow(i);
|
|
144
|
+
i += 1;
|
|
145
|
+
line += 1;
|
|
146
|
+
rowStartLine = line;
|
|
147
|
+
rowStartAt = i;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
cell += ch; i += 1;
|
|
151
|
+
}
|
|
152
|
+
// 收尾:末行没有换行符时补上;已经用 '\n' 收过尾就不补(否则会多出一条空行)
|
|
153
|
+
if (cell !== '' || cells.length > 0) closeRow(src.length);
|
|
154
|
+
return rows;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── JSON 类(选项文档 §11.2)──────────────────────────────────────────────────────────
|
|
158
|
+
/**
|
|
159
|
+
* `JSON.parse` 报错 → `行:列`;**拿不到就返回空串**(调用方据此**不写坐标**)。
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ **绝不在这里造假位置**:新版 V8 对最常见的语法错只回一句贴片段
|
|
162
|
+
* (`Unexpected token '}', "…" is not valid JSON`)—— **消息里根本没有 position**。
|
|
163
|
+
* 老写法用 `at = 0` 回落 ⇒ 每条都印成 `(1:1)`,等于在回执里写一个自己不知道的坐标
|
|
164
|
+
* (2026-10-15 异源复核抓出来的,探针里 `{"a":}` 与多行坏 JSON 双双印 `(1:1)`)。
|
|
165
|
+
* 认得的两种措辞:① `at position N (line X column Y)`;② `(line X column Y)`。
|
|
166
|
+
*/
|
|
167
|
+
function jsonPos(text, err) {
|
|
168
|
+
const msg = String((err && err.message) || err || '');
|
|
169
|
+
const direct = /\(line (\d+) column (\d+)\)/.exec(msg);
|
|
170
|
+
if (direct) return `${Number(direct[1])}:${Number(direct[2])}`;
|
|
171
|
+
const m = /position (\d+)/.exec(msg);
|
|
172
|
+
if (!m) return '';
|
|
173
|
+
const at = Number(m[1]);
|
|
174
|
+
if (!Number.isFinite(at) || at < 0 || at > text.length) return '';
|
|
175
|
+
const head = text.slice(0, at);
|
|
176
|
+
return `${head.split('\n').length}:${at - (head.lastIndexOf('\n') + 1) + 1}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* **顶层元素边界扫描器**:按嵌套深度与字符串状态切出每个顶层元素的**原文区间**。
|
|
181
|
+
*
|
|
182
|
+
* 为什么不 `JSON.stringify(parsed)` 当原文:键序与空白会变样、数字样键还会被重排
|
|
183
|
+
* ⇒ evidence 回查(`span.includes(ev)`,本文件 `verifyWithIndex`)会**必然假红**。
|
|
184
|
+
* 这里只认字符、不做任何规范化;`raw` 是 `[start, end)` 的原样切片(两端的空白留给调用方 trim)。
|
|
185
|
+
*
|
|
186
|
+
* 迭代实现(不递归)—— 深度炸弹在这里也不会炸栈。
|
|
187
|
+
*/
|
|
188
|
+
function scanTopLevelElements(text) {
|
|
189
|
+
const open = text.indexOf('[');
|
|
190
|
+
if (open < 0) return { elements: [] };
|
|
191
|
+
const s = text;
|
|
192
|
+
const elements = [];
|
|
193
|
+
let i = open + 1;
|
|
194
|
+
let depth = 0;
|
|
195
|
+
let inStr = false;
|
|
196
|
+
let esc = false;
|
|
197
|
+
let start = -1;
|
|
198
|
+
for (; i < s.length; i += 1) {
|
|
199
|
+
const ch = s[i];
|
|
200
|
+
if (inStr) {
|
|
201
|
+
if (esc) esc = false;
|
|
202
|
+
else if (ch === '\\') esc = true;
|
|
203
|
+
else if (ch === '"') inStr = false;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ch === '"') { if (start < 0) start = i; inStr = true; continue; }
|
|
207
|
+
if (ch === '{' || ch === '[') { if (start < 0) start = i; depth += 1; continue; }
|
|
208
|
+
if (ch === '}' || ch === ']') {
|
|
209
|
+
if (ch === ']' && depth === 0) { // 顶层数组收尾
|
|
210
|
+
// `start` 只在非空白字符处被设置(下面三处分支),所以 `start >= 0` 就等于"有内容"
|
|
211
|
+
// —— 原先那句 `s.slice(start, i).trim() !== ''` 是恒真的死代码(2026-10-15 复核指出)。
|
|
212
|
+
if (start >= 0) elements.push({ index: elements.length + 1, raw: s.slice(start, i) });
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
depth -= 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === ',') {
|
|
219
|
+
if (depth === 0) {
|
|
220
|
+
if (start >= 0) elements.push({ index: elements.length + 1, raw: s.slice(start, i) });
|
|
221
|
+
start = -1;
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (depth === 0 && start < 0 && ch.trim() !== '') start = i; // 标量元素(字符串/数字/布尔/null)
|
|
226
|
+
}
|
|
227
|
+
return { elements };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 嵌套深度是否**超过** `cap`(到顶就不再往下走 —— 防炸弹,不递归爆栈)。 */
|
|
231
|
+
function jsonTooDeep(value, cap) {
|
|
232
|
+
let deep = false;
|
|
233
|
+
const walk = (node, d) => {
|
|
234
|
+
if (deep || !node || typeof node !== 'object') return;
|
|
235
|
+
if (d > cap) { deep = true; return; }
|
|
236
|
+
for (const k of Object.keys(node)) walk(node[k], d + 1);
|
|
237
|
+
};
|
|
238
|
+
walk(value, 0);
|
|
239
|
+
return deep;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 非标量 ⇒ 紧凑 JSON(用于正文);标量 ⇒ 原样。**不许**静默丢键。 */
|
|
243
|
+
function jsonLiteral(v) {
|
|
244
|
+
if (v !== null && typeof v === 'object') {
|
|
245
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
246
|
+
}
|
|
247
|
+
return String(v);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* JSON 源 → **形态 + 内容单元**(纯函数,不碰库/文件/网络)。
|
|
252
|
+
*
|
|
253
|
+
* 三种形态自动判(选项文档 §11.2):数组 ⇒ 一元素一条 · 单对象 ⇒ 整份一条 · JSONL ⇒ 一行一条。
|
|
254
|
+
* 判不出来就**如实报**(带 `行:列`),绝不静默当成别的 `kind` 或"0 个候选"。
|
|
255
|
+
*/
|
|
256
|
+
function parseJsonSource(text) {
|
|
257
|
+
const src = normalizeTextLf(String(text ?? ''));
|
|
258
|
+
const trimmed = src.trim();
|
|
259
|
+
if (trimmed === '') return { ok: false, reason: 'empty_source', message: '源是空的(没有任何字符)' };
|
|
260
|
+
const first = trimmed[0];
|
|
261
|
+
if (!/[{\["\-0-9tfn]/.test(first)) {
|
|
262
|
+
return {
|
|
263
|
+
ok: false, reason: 'kind_mismatch',
|
|
264
|
+
message: `自称 json,但开头是 ${JSON.stringify(first)}`
|
|
265
|
+
+ (first === '<' ? ' —— 像是 HTML/XML,不是 JSON' : ' —— 认不出是 JSON 的开头'),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// ① 整份解析得通 ⇒ 数组 / 单对象
|
|
269
|
+
let whole = null;
|
|
270
|
+
let wholeErr = null;
|
|
271
|
+
try { whole = JSON.parse(trimmed); } catch (e) { wholeErr = e; }
|
|
272
|
+
if (wholeErr === null) {
|
|
273
|
+
if (jsonTooDeep(whole, JSON_MAX_DEPTH)) {
|
|
274
|
+
return { ok: false, reason: 'json_too_deep', message: `JSON 嵌套超过 ${JSON_MAX_DEPTH} 层(拒绝解析,防 JSON 炸弹)` };
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(whole)) {
|
|
277
|
+
const { elements } = scanTopLevelElements(trimmed);
|
|
278
|
+
if (elements.length !== whole.length) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false, reason: 'json_scan_mismatch',
|
|
281
|
+
message: `顶层元素边界扫描得 ${elements.length} 个,而解析结果有 ${whole.length} 个`
|
|
282
|
+
+ '(这是本机扫描器的自检 —— 请把这份源报给我,别照进)',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
ok: true, form: 'array',
|
|
287
|
+
units: elements.map((el) => ({ locator: `[${el.index}]`, ordinal: el.index, raw: el.raw, value: whole[el.index - 1] })),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (isPlainObject(whole)) {
|
|
291
|
+
return { ok: true, form: 'object', units: [{ locator: LOCATOR_JSON_WHOLE, ordinal: 0, raw: trimmed, value: whole }] };
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
ok: false, reason: 'json_not_object',
|
|
295
|
+
message: `顶层是 ${whole === null ? 'null' : typeof whole} —— JSON 导入只认**对象**或**对象数组**(契约 §2.2:每条候选都得是一个条目)`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// ② JSONL:逐行一个**对象**(每行都合法才算)。
|
|
299
|
+
// ⚠️ 两处口径(2026-10-15 复核后定):
|
|
300
|
+
// ① 逐行 parse 前要 `trim()` —— 整份路径早在 `trimmed` 里剥掉了 BOM,逐行路径若不 trim,
|
|
301
|
+
// 首行带 `\uFEFF` 的 JSONL 会被硬拒,而同样带 BOM 的数组/单对象却照过(**同一条通道两种行为**);
|
|
302
|
+
// ② **只收对象**:数组/标量行 ⇒ 当坏行。要导"一串标量"用数组形态(那里每个元素都能承载)。
|
|
303
|
+
// 口径统一在"一行一个对象"这一句上,不做两种写法(spec §11.2 同话)。
|
|
304
|
+
const rawLines = src.split('\n');
|
|
305
|
+
const lines = [];
|
|
306
|
+
let badLine = 0;
|
|
307
|
+
let badErr = null;
|
|
308
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
309
|
+
if (rawLines[i].trim() === '') continue;
|
|
310
|
+
let v = null;
|
|
311
|
+
try { v = JSON.parse(rawLines[i].trim()); } catch (e) { badLine = i + 1; badErr = e; break; }
|
|
312
|
+
if (!isPlainObject(v)) { badLine = i + 1; badErr = new SyntaxError(`这一行不是对象(是 ${Array.isArray(v) ? 'array' : typeof v})`); break; }
|
|
313
|
+
if (jsonTooDeep(v, JSON_MAX_DEPTH)) {
|
|
314
|
+
return { ok: false, reason: 'json_too_deep', message: `第 ${i + 1} 行的 JSON 嵌套超过 ${JSON_MAX_DEPTH} 层(拒绝解析)` };
|
|
315
|
+
}
|
|
316
|
+
lines.push({ line: i + 1, raw: rawLines[i], value: v });
|
|
317
|
+
}
|
|
318
|
+
if (badLine === 0 && lines.length > 0) {
|
|
319
|
+
return {
|
|
320
|
+
ok: true, form: 'jsonl',
|
|
321
|
+
units: lines.map((l) => ({ locator: String(l.line), ordinal: l.line, raw: l.raw, value: l.value })),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// ③ 都不是 ⇒ 报整份的错(拿得到坐标才写坐标,拿不到就不写),并把 JSONL 那一遍的错也如实带上
|
|
325
|
+
const where = jsonPos(trimmed, wholeErr);
|
|
326
|
+
const eof = /end of (JSON|input)|Unexpected end|Unterminated/i.test(String((wholeErr && wholeErr.message) || ''));
|
|
327
|
+
return {
|
|
328
|
+
ok: false, reason: 'invalid_json',
|
|
329
|
+
message: `JSON 解析失败${where === '' ? '' : `(${where})`}:${String((wholeErr && wholeErr.message) || wholeErr)}`
|
|
330
|
+
+ (badLine > 0 ? `;按 JSONL 逐行看,第 ${badLine} 行也不合法(${String((badErr && badErr.message) || badErr)})` : '')
|
|
331
|
+
+ (eof ? '(像是没写完 —— 整份被截断了?)' : ''),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 把源建成一次性的索引(行表 + 行号 → 原文)。CSV 的"行"以**起始行号**为准。 */
|
|
336
|
+
function buildSourceIndex(payload) {
|
|
337
|
+
const text = normalizeTextLf(payload.text ?? '');
|
|
338
|
+
const lines = text.split('\n');
|
|
339
|
+
// 末尾换行会产生一个空尾元素 —— 它是"行的分隔符"而不是"第 N 行",不参与定位
|
|
340
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
341
|
+
const index = { kind: payload.kind, name: payload.name, text, lines, csvRows: null, csvByLine: null, json: null, jsonByLocator: null };
|
|
342
|
+
if (payload.kind === 'csv') {
|
|
343
|
+
index.csvRows = parseCsvRows(text);
|
|
344
|
+
index.csvByLine = new Map();
|
|
345
|
+
for (const r of index.csvRows) if (!index.csvByLine.has(r.startLine)) index.csvByLine.set(r.startLine, r);
|
|
346
|
+
}
|
|
347
|
+
if (payload.kind === 'json') {
|
|
348
|
+
index.json = parseJsonSource(text);
|
|
349
|
+
if (index.json.ok === true) {
|
|
350
|
+
index.jsonByLocator = new Map();
|
|
351
|
+
for (const u of index.json.units) index.jsonByLocator.set(u.locator, u.raw);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return index;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** locator → 原文片段(找不到 ⇒ null)。CSV:该行(含跨行的引号字段);text:闭区间行段;JSON:见 §11.2。 */
|
|
358
|
+
function resolveLocator(index, locator) {
|
|
359
|
+
const loc = String(locator ?? '').trim();
|
|
360
|
+
if (index.kind === 'json') {
|
|
361
|
+
if (!index.json || index.json.ok !== true || !index.jsonByLocator) return null;
|
|
362
|
+
if (loc === LOCATOR_JSON_WHOLE) return index.json.form === 'object' ? index.json.units[0].raw : null;
|
|
363
|
+
if (LOCATOR_JSON_INDEX_RE.test(loc)) return index.jsonByLocator.get(loc) ?? null;
|
|
364
|
+
// JSONL 的裸行号(与文本段落同形,但只有 JSONL 这场才认它)
|
|
365
|
+
if (LOCATOR_LINE_RE.test(loc) && index.json.form === 'jsonl') return index.jsonByLocator.get(loc) ?? null;
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
if (index.kind === 'sqlite') {
|
|
369
|
+
// SQLite 的 locator 形状是 `表名#键值`,**不是**行号 —— 只有这一条分支认它(选项文档 §11.3)。
|
|
370
|
+
if (!index.sqliteByLocator) return null;
|
|
371
|
+
return index.sqliteByLocator.get(loc) ?? null;
|
|
372
|
+
}
|
|
373
|
+
if (LOCATOR_LINE_RE.test(loc)) {
|
|
374
|
+
const n = Number(loc);
|
|
375
|
+
if (index.csvByLine && index.csvByLine.has(n)) return index.csvByLine.get(n).raw;
|
|
376
|
+
return n >= 1 && n <= index.lines.length ? index.lines[n - 1] : null;
|
|
377
|
+
}
|
|
378
|
+
const m = LOCATOR_RANGE_RE.exec(loc);
|
|
379
|
+
if (m) {
|
|
380
|
+
const a = Number(m[1]);
|
|
381
|
+
const b = Number(m[2]);
|
|
382
|
+
if (a > b || b > index.lines.length) return null;
|
|
383
|
+
return index.lines.slice(a - 1, b).join('\n');
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── 第 1 + 第 2 道闸 ──────────────────────────────────────────────────────────────────
|
|
389
|
+
/**
|
|
390
|
+
* 定位回查 + 字段闸(**纯函数**)。回执 = 原候选 + `locator_verified` / `issues` / `selected`。
|
|
391
|
+
* `selected` 的语义是"**默认**要不要勾":定位通过**且**没有阻断性 issues ⇒ 勾。
|
|
392
|
+
* 它是一个可被人工覆盖的**默认值**,不是判据 —— 人工可以显式保留一条 `evidence_not_found` 的候选。
|
|
393
|
+
*/
|
|
394
|
+
export function verifyImportCandidate(candidate, source) {
|
|
395
|
+
// 公开入口只有 payload(没有索引)⇒ 现建一个;`analyzeImport` 走 verifyWithIndex 复用同一个索引。
|
|
396
|
+
const index = buildSourceIndex(isPlainObject(source) ? source : {});
|
|
397
|
+
return verifyWithIndex(candidate, index, source);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function verifyWithIndex(candidate, index, source) {
|
|
401
|
+
const limits = (isPlainObject(source) && source.limits) || DEFAULT_LIBRARY_LIMITS;
|
|
402
|
+
const cand = isPlainObject(candidate) ? { ...candidate } : {};
|
|
403
|
+
const issues = [];
|
|
404
|
+
|
|
405
|
+
// 第 1 道闸:evidence 必须落回原文
|
|
406
|
+
const locator = cand.locator === undefined || cand.locator === null ? '' : String(cand.locator).trim();
|
|
407
|
+
let locatorOk = false;
|
|
408
|
+
let evidenceOk = false;
|
|
409
|
+
if (locator === '') {
|
|
410
|
+
issues.push('locator_missing');
|
|
411
|
+
} else {
|
|
412
|
+
const span = resolveLocator(index, locator);
|
|
413
|
+
if (span === null) {
|
|
414
|
+
issues.push('locator_out_of_range');
|
|
415
|
+
} else {
|
|
416
|
+
locatorOk = true;
|
|
417
|
+
const ev = typeof cand.evidence === 'string' ? normalizeTextLf(cand.evidence) : '';
|
|
418
|
+
// ⚠️ **两侧都按 LF 归一**:`ev` 上面已归一,`span` 若不归一 ⇒ 源值里含 `\r\n` 时**必假红**
|
|
419
|
+
// (SQLite 的 TEXT 单元格里放 CRLF 文本太正常了 —— 那不是"证据找不到",是我们自己口径没对齐)。
|
|
420
|
+
evidenceOk = ev.trim() !== '' && normalizeTextLf(String(span)).includes(ev);
|
|
421
|
+
if (!evidenceOk) issues.push('evidence_not_found');
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 第 2 道闸:字段面(提前告知)
|
|
426
|
+
if (typeof cand.title !== 'string' || cand.title.trim() === '') issues.push('empty_title');
|
|
427
|
+
if (typeof cand.body !== 'string' || cand.body === '') issues.push('empty_body');
|
|
428
|
+
else if (utf8Bytes(cand.body) > Number(limits.importCandidateMaxBytes ?? limits.itemMaxBytes)) issues.push('too_large');
|
|
429
|
+
if (typeof cand.category !== 'string' || !LIBRARY_CATEGORIES.includes(String(cand.category).trim())) issues.push('bad_category');
|
|
430
|
+
if (typeof cand.source_kind !== 'string' || !LIBRARY_SOURCE_KINDS.includes(String(cand.source_kind).trim())) issues.push('bad_source_kind');
|
|
431
|
+
if (typeof cand.source_ref !== 'string' || cand.source_ref.trim() === '') issues.push('missing_source');
|
|
432
|
+
if (typeof cand.as_of !== 'string' || cand.as_of.trim() === '') issues.push('missing_as_of');
|
|
433
|
+
else if (!DATE_RE.test(cand.as_of.trim()) && !/^\d{4}-\d{2}-\d{2}T/.test(cand.as_of.trim())) issues.push('bad_as_of');
|
|
434
|
+
|
|
435
|
+
const locator_verified = locatorOk && evidenceOk;
|
|
436
|
+
const selected = locator_verified && !issues.some((i) => BLOCKING_ISSUES.includes(i));
|
|
437
|
+
return { ...cand, locator_verified, issues, selected };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── JSON 单元 → 候选(字段映射:选项文档 §11.2)──────────────────────────────────────────
|
|
441
|
+
/**
|
|
442
|
+
* 一个 JSON 内容单元 → 一条候选。
|
|
443
|
+
* - 键命中图书馆字段名 ⇒ **直通**(值原样用,校验仍归字段闸与 `create`,这里不放行);
|
|
444
|
+
* - 其余键 ⇒ 拼进正文(每行 `键: 值`,非标量用紧凑 JSON)—— **一个键都不许静默丢**;
|
|
445
|
+
* - 对象本来就有 `body` 时,其余键仍拼在正文后面(用分隔行切开),同样不丢。
|
|
446
|
+
*/
|
|
447
|
+
function jsonUnitCandidate(name, defaults, form, unit) {
|
|
448
|
+
const span = String(unit.raw ?? '').trim();
|
|
449
|
+
const base = {
|
|
450
|
+
category: defaults.category,
|
|
451
|
+
source_kind: defaults.source_kind,
|
|
452
|
+
source_ref: `${name}#${unit.locator}`,
|
|
453
|
+
as_of: defaults.as_of,
|
|
454
|
+
locator: unit.locator,
|
|
455
|
+
evidence: span,
|
|
456
|
+
origin: 'rule',
|
|
457
|
+
};
|
|
458
|
+
const title = form === 'object' ? name
|
|
459
|
+
: form === 'jsonl' ? `${name} 第 ${unit.ordinal} 行`
|
|
460
|
+
: `${name} 第 ${unit.ordinal} 条`;
|
|
461
|
+
const obj = isPlainObject(unit.value) ? unit.value : null;
|
|
462
|
+
if (obj === null) {
|
|
463
|
+
// 标量元素(字符串/数字/布尔):正文取紧凑字面量;`null` 是**没有内容** ⇒ 空正文(字段闸会打 empty_body)
|
|
464
|
+
return { ...base, title, body: unit.value === null ? '' : jsonLiteral(unit.value) };
|
|
465
|
+
}
|
|
466
|
+
return objectCandidate(base, title, obj);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 对象 → 候选的**字段映射(只有这一份实现;JSON 与 SQLite 共用)**:
|
|
471
|
+
* - 键命中图书馆字段名 ⇒ **直通**(值原样用,校验仍归字段闸与 `create`,这里不放行);
|
|
472
|
+
* - 其余键 ⇒ 拼进正文(每行 `键: 值`,非标量用紧凑 JSON)—— **一个键都不许静默丢**;
|
|
473
|
+
* - 对象本来就有 `body` 时,其余键仍拼在正文后面(用分隔行切开),同样不丢。
|
|
474
|
+
*/
|
|
475
|
+
function objectCandidate(base, fallbackTitle, obj) {
|
|
476
|
+
const out = { ...base };
|
|
477
|
+
const extras = [];
|
|
478
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
479
|
+
// `title` / `body` 单独处理:**键缺省才补默认,键在场就原样保留** ——
|
|
480
|
+
// 把 `title: 123` 补齐成默认标题等于把源里的毛病藏起来,字段闸该报就让它报。
|
|
481
|
+
if (k === 'title') { out.title = v; continue; }
|
|
482
|
+
if (k === 'body') {
|
|
483
|
+
// 字符串就直接当正文;形状不对(数字/对象)时保留成正文里的一行 —— 既不假装能拼,也不静默丢
|
|
484
|
+
if (typeof v === 'string') out.body = v;
|
|
485
|
+
else extras.push(`body: ${jsonLiteral(v)}`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (JSON_PASSTHROUGH_KEYS.includes(k)) { out[k] = v; continue; }
|
|
489
|
+
extras.push(`${k}: ${jsonLiteral(v)}`);
|
|
490
|
+
}
|
|
491
|
+
const extraText = extras.join('\n');
|
|
492
|
+
if (typeof out.body === 'string') {
|
|
493
|
+
if (extraText !== '') out.body = `${out.body}\n\n${JSON_EXTRA_HEADER}\n${extraText}`;
|
|
494
|
+
} else {
|
|
495
|
+
out.body = extraText;
|
|
496
|
+
}
|
|
497
|
+
if (!Object.prototype.hasOwnProperty.call(obj, 'title')) out.title = fallbackTitle;
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── 分析 ──────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
/**
|
|
503
|
+
* 一个 SQLite 行 → 一条候选(选项文档 §11.3)。
|
|
504
|
+
* 列名命中图书馆字段名就直通 —— 映射**与 JSON 共用 `objectCandidate`**(只有一份实现)。
|
|
505
|
+
* `evidence` 用取源层预存的整行文本(`列=值` 拼成),逐字回查照旧成立。
|
|
506
|
+
*/
|
|
507
|
+
function sqliteUnitCandidate(name, defaults, table, unit) {
|
|
508
|
+
const span = String(unit.raw ?? '').trim();
|
|
509
|
+
const title = `${name} · ${table} 第 ${unit.ordinal} 行`;
|
|
510
|
+
const base = {
|
|
511
|
+
body: span,
|
|
512
|
+
category: defaults.category,
|
|
513
|
+
source_kind: defaults.source_kind,
|
|
514
|
+
source_ref: `${name}#${unit.locator}`,
|
|
515
|
+
as_of: defaults.as_of,
|
|
516
|
+
locator: unit.locator,
|
|
517
|
+
evidence: span,
|
|
518
|
+
origin: 'rule',
|
|
519
|
+
};
|
|
520
|
+
const obj0 = isPlainObject(unit.value) ? unit.value : null;
|
|
521
|
+
// SQL 的 `NULL` = **没有值** —— 与 JSON 的 `title: 123`(值在那儿、形状不对)不是一回事:
|
|
522
|
+
// 图书馆字段列为 NULL ⇒ 当**键缺省**,回退到通道默认(`title` 用可追溯的默认标题)。
|
|
523
|
+
// 否则一条 `title=NULL` 会变成 `title:null` ⇒ 整条被字段闸拦下(empty_title),而源里根本没毛病。
|
|
524
|
+
// 其余列的 NULL **如实写 NULL** 进正文 —— 一个都不许静默丢。
|
|
525
|
+
const obj = obj0 === null ? null : Object.fromEntries(
|
|
526
|
+
Object.entries(obj0).filter(([k, v]) => !(v === null && JSON_PASSTHROUGH_KEYS.includes(k))),
|
|
527
|
+
);
|
|
528
|
+
return obj === null ? { ...base, title } : objectCandidate(base, title, obj);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 源 → 候选。**纯函数式**:不碰库、不碰文件、不碰 network。
|
|
533
|
+
*
|
|
534
|
+
* `opts`:
|
|
535
|
+
* - `today` —— `as_of` 的默认值(面板给"今天"但可改;**绝不由创建时间推断**,契约 §4)
|
|
536
|
+
* - `category` / `source_kind` —— 规则段默认值
|
|
537
|
+
* - `model` —— **可选**的模型段:`async (ctx) => 原始候选[]`(只交 locator/evidence/title/body…;
|
|
538
|
+
* `index` 与 `origin` 由本函数补)。它是**候选来源之一**,不是判据。
|
|
539
|
+
* - `limits` —— 限额(默认 `DEFAULT_LIBRARY_LIMITS`)
|
|
540
|
+
* - `modelLabel` —— 回执里如实写出的模型名(契约 §3:不许把"两次结果不一样"藏起来)
|
|
541
|
+
*
|
|
542
|
+
* ⚠️ 模型段抛错 ⇒ 回 `{ok:false, reason:'model_failed'}` —— **不冒充成功**。
|
|
543
|
+
* 退回"只用规则段、ok:true"看着更友好,但那等于把"模型没跑成"藏进一个成功的回执里。
|
|
544
|
+
*/
|
|
545
|
+
export async function analyzeImport(input, opts = {}) {
|
|
546
|
+
const limits = opts.limits || DEFAULT_LIBRARY_LIMITS;
|
|
547
|
+
if (!isPlainObject(input)) return { ok: false, reason: 'invalid_input', message: 'analyze 入参必须是普通对象' };
|
|
548
|
+
const kind = String(input.kind ?? '').trim();
|
|
549
|
+
if (!IMPORT_KINDS.includes(kind)) {
|
|
550
|
+
return { ok: false, reason: 'invalid_kind', message: `kind 只能是 ${IMPORT_KINDS.join(' | ')}` };
|
|
551
|
+
}
|
|
552
|
+
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
553
|
+
if (name === '') return { ok: false, reason: 'invalid_name', message: 'name 必填非空(它是 source_ref 的前半截)' };
|
|
554
|
+
// SQLite 通道(选项文档 §11.3):源是**盘上的文件**,不是 text ⇒ 两条入口不许同时在场
|
|
555
|
+
// (D15:`text` 与 `path` 同给时,"这份源的摘要按谁算"没有答案 —— 参数错就当场说,不猜)。
|
|
556
|
+
const isSqlite = kind === 'sqlite';
|
|
557
|
+
const text = typeof input.text === 'string' ? input.text : (input.text == null ? '' : String(input.text));
|
|
558
|
+
if (isSqlite && text.trim() !== '') {
|
|
559
|
+
return { ok: false, reason: 'invalid_argument', message: 'kind=sqlite 只走 path:text 与 path 不许同时给(源摘要按谁算没有答案)' };
|
|
560
|
+
}
|
|
561
|
+
const bytes = utf8Bytes(text);
|
|
562
|
+
if (bytes > Number(limits.importTextMaxBytes)) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false, reason: 'text_too_large',
|
|
565
|
+
message: `文本 ${bytes} 字节,超过 importTextMaxBytes=${limits.importTextMaxBytes}(不做静默截断)`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const offset = Math.max(0, Math.floor(Number(opts.offset) || 0));
|
|
570
|
+
// `payload` 还要给 `vopts`(字段闸/回查那道)用:SQLite 的 `text` 恒为空串,
|
|
571
|
+
// 规则段与模型段吃的是**取源层给的单元**(`index.sqlite.units`)。
|
|
572
|
+
const payload = { kind, name, text };
|
|
573
|
+
let index;
|
|
574
|
+
if (isSqlite) {
|
|
575
|
+
// 取源**只能由运行时注入**(`analyzeImport` 是纯函数:不碰文件 —— 本文件头注与契约 §2.1)。
|
|
576
|
+
const reader = typeof opts.readSqlite === 'function' ? opts.readSqlite : null;
|
|
577
|
+
if (!reader) {
|
|
578
|
+
return { ok: false, reason: 'sqlite_unavailable', message: 'SQLite 通道缺少取源实现(运行时没注入 readSqlite:接线问题,不是源的问题)' };
|
|
579
|
+
}
|
|
580
|
+
const read = await reader({
|
|
581
|
+
path: input.path, table: input.table, offset, limit: limits.importBatchMaxItems, limits,
|
|
582
|
+
});
|
|
583
|
+
if (!isPlainObject(read) || read.ok !== true) {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
reason: String((read && read.reason) || 'sqlite_read_failed'),
|
|
587
|
+
message: String((read && read.message) || '读源库失败,而且没给出原因'),
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
index = {
|
|
591
|
+
kind: 'sqlite', name, text: '', lines: [], csvRows: null, csvByLine: null,
|
|
592
|
+
json: null, jsonByLocator: null, sqlite: read, sqliteByLocator: new Map(),
|
|
593
|
+
};
|
|
594
|
+
for (const u of read.units) index.sqliteByLocator.set(u.locator, u.raw);
|
|
595
|
+
} else {
|
|
596
|
+
index = buildSourceIndex(payload);
|
|
597
|
+
// JSON 这条通道的源**可能解析不了** —— 当场如实报(带 `行:列`),绝不静默当成"0 个候选"(契约 §8)。
|
|
598
|
+
if (kind === 'json' && index.json && index.json.ok !== true) {
|
|
599
|
+
return { ok: false, reason: index.json.reason, message: index.json.message };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// 源摘要(**按 kind 分支**,D15):普通通道 = 内容文本;SQLite = **文件身份串**
|
|
603
|
+
// (`realpath|size|mtimeMs`)—— 它不随"抽了哪一页"变,否则同一份源翻一页就换一个 import_key。
|
|
604
|
+
const source_hash = isSqlite ? importSourceHash(index.sqlite.source_identity) : importSourceHash(text);
|
|
605
|
+
const today = typeof opts.today === 'string' && opts.today.trim() !== '' ? opts.today.trim() : isoToday();
|
|
606
|
+
const defaults = {
|
|
607
|
+
category: typeof opts.category === 'string' && LIBRARY_CATEGORIES.includes(opts.category) ? opts.category : IMPORT_DEFAULT_CATEGORY,
|
|
608
|
+
source_kind: typeof opts.source_kind === 'string' && LIBRARY_SOURCE_KINDS.includes(opts.source_kind) ? opts.source_kind : IMPORT_DEFAULT_SOURCE_KIND,
|
|
609
|
+
as_of: today,
|
|
610
|
+
};
|
|
611
|
+
const vopts = { ...payload, limits };
|
|
612
|
+
|
|
613
|
+
// ── 规则段(确定性;与 D1/schema 无关)────────────────────────────────────────────
|
|
614
|
+
let dataRows = 0;
|
|
615
|
+
const units = [];
|
|
616
|
+
if (kind === 'csv') {
|
|
617
|
+
const parsed = index.csvRows;
|
|
618
|
+
const data = parsed.slice(1); // 表头 = 第 1 行(契约 §4)
|
|
619
|
+
dataRows = data.length;
|
|
620
|
+
for (const row of data) {
|
|
621
|
+
const span = row.raw.trim();
|
|
622
|
+
if (span === '' && row.cells.every((c) => c.trim() === '')) continue; // 纯空行不是条目
|
|
623
|
+
units.push({ m: row.raw, c: {
|
|
624
|
+
title: `${name} 第 ${row.startLine} 行`,
|
|
625
|
+
body: span,
|
|
626
|
+
category: defaults.category,
|
|
627
|
+
source_kind: defaults.source_kind,
|
|
628
|
+
source_ref: `${name}#${row.startLine}`,
|
|
629
|
+
as_of: defaults.as_of,
|
|
630
|
+
locator: String(row.startLine),
|
|
631
|
+
evidence: span,
|
|
632
|
+
origin: 'rule',
|
|
633
|
+
} });
|
|
634
|
+
}
|
|
635
|
+
} else if (kind === 'json') {
|
|
636
|
+
// JSON:内容单元 = 数组元素 / JSONL 一行 / 单对象(整份);locator 见 §11.2 的三种形态。
|
|
637
|
+
const j = index.json;
|
|
638
|
+
dataRows = j.units.length;
|
|
639
|
+
for (const u of j.units) {
|
|
640
|
+
units.push({ m: u.raw, c: jsonUnitCandidate(name, defaults, j.form, u) });
|
|
641
|
+
}
|
|
642
|
+
} else if (kind === 'sqlite') {
|
|
643
|
+
// SQLite:内容单元 = 表里**一行一单元**;窗口已在取源那一层用 SQL 的 LIMIT/OFFSET 切好。
|
|
644
|
+
const s = index.sqlite;
|
|
645
|
+
dataRows = s.units.length;
|
|
646
|
+
for (const u of s.units) units.push({ m: u.raw, c: sqliteUnitCandidate(name, defaults, s.table, u) });
|
|
647
|
+
} else if (kind === 'text') {
|
|
648
|
+
// 文本:空行分段;locator = 行段 `起点-终点`(含两端,1-based)
|
|
649
|
+
const lines = index.lines;
|
|
650
|
+
let start = null;
|
|
651
|
+
let buf = [];
|
|
652
|
+
const flush = (endLine) => {
|
|
653
|
+
if (start === null) return;
|
|
654
|
+
const body = buf.join('\n');
|
|
655
|
+
units.push({ m: body, c: {
|
|
656
|
+
title: `${name} 第 ${start}-${endLine} 行`,
|
|
657
|
+
body,
|
|
658
|
+
category: defaults.category,
|
|
659
|
+
source_kind: defaults.source_kind,
|
|
660
|
+
source_ref: `${name}#${start}-${endLine}`,
|
|
661
|
+
as_of: defaults.as_of,
|
|
662
|
+
locator: `${start}-${endLine}`,
|
|
663
|
+
evidence: body,
|
|
664
|
+
origin: 'rule',
|
|
665
|
+
} });
|
|
666
|
+
start = null;
|
|
667
|
+
buf = [];
|
|
668
|
+
};
|
|
669
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
670
|
+
if (lines[i].trim() === '') { flush(i); continue; }
|
|
671
|
+
if (start === null) start = i + 1;
|
|
672
|
+
buf.push(lines[i]);
|
|
673
|
+
}
|
|
674
|
+
flush(lines.length);
|
|
675
|
+
dataRows = units.length;
|
|
676
|
+
} else {
|
|
677
|
+
// 兜底 = **拒**,不是"落文本":将来给 `IMPORT_KINDS` 加了值却忘了加分支时,这里会**立刻炸出来**,
|
|
678
|
+
// 而不是把新形态的源静默按空行切成 raw 文本(选项文档 §11.6 A.1 的必改项)。
|
|
679
|
+
return {
|
|
680
|
+
ok: false, reason: 'invalid_kind',
|
|
681
|
+
message: `kind=${JSON.stringify(kind)} 还没有实现分支(别把它当文本处理)`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── 分页(2026-10-13,D12)───────────────────────────────────────────────────────────
|
|
686
|
+
// `units` = 这份源的**内容单元**序列(CSV 一行 / 文本一段)。分页的尺**只有这一把**:
|
|
687
|
+
// `offset` 切的就是这个序列,规则段与模型段吃**同一个窗口** —— 两段各切各的,会让"翻到第 2 页"
|
|
688
|
+
// 变成"规则段给了第 2 页、模型段给了第 3 页"这种没人能对上的账。
|
|
689
|
+
// 一页的单元数复用 `importBatchMaxItems`(默认 200)——**不是新开一把尺**:契约里"一批"与
|
|
690
|
+
// "一页"本来就是同一个数(它管的是"一次交给人的量")。
|
|
691
|
+
const pageMaxRaw = Number(limits.importBatchMaxItems ?? 0);
|
|
692
|
+
const pageMax = Number.isFinite(pageMaxRaw) && pageMaxRaw > 0 ? pageMaxRaw : units.length;
|
|
693
|
+
let unitsTotal;
|
|
694
|
+
let win;
|
|
695
|
+
let nextOffset;
|
|
696
|
+
if (isSqlite) {
|
|
697
|
+
// SQLite 的窗口**在取源那一层就切好了**(SQL 的 LIMIT/OFFSET)—— 这里再切一次等于切两刀。
|
|
698
|
+
// `units_total` 如实给 **null**:大表**不跑 `COUNT(*)`**(安全硬约束④)⇒ 总数这件事我不知道,
|
|
699
|
+
// 就不许编一个数出来(契约 §8)。客户端见 null 走"还有更多"而不是页码。
|
|
700
|
+
win = units;
|
|
701
|
+
unitsTotal = null;
|
|
702
|
+
nextOffset = index.sqlite.has_more === true ? offset + win.length : null;
|
|
703
|
+
} else {
|
|
704
|
+
unitsTotal = units.length;
|
|
705
|
+
win = units.slice(offset, offset + pageMax);
|
|
706
|
+
nextOffset = offset + win.length < unitsTotal ? offset + win.length : null;
|
|
707
|
+
}
|
|
708
|
+
const raw = win.map((u) => u.c);
|
|
709
|
+
|
|
710
|
+
const candidates = raw.map((c) => verifyWithIndex(c, index, vopts));
|
|
711
|
+
|
|
712
|
+
// ── 模型段(可选)────────────────────────────────────────────────────────────────
|
|
713
|
+
const model = typeof opts.model === 'function' ? opts.model : null;
|
|
714
|
+
let model_used = false;
|
|
715
|
+
let model_rows = 0;
|
|
716
|
+
let model_rows_capped = false;
|
|
717
|
+
// 降级回执(D11,2026-10-14 放行):模型段没跑成时的**显式标注**;全跑成时为 null。
|
|
718
|
+
let model_error = null;
|
|
719
|
+
// 分批数:由模型段自己记在 `ctx.model_stats` 上(引擎知道切了几批),这里只负责照实报。
|
|
720
|
+
// **只在 >1 批时才出这个字段** —— 单批与分批前的老回执逐字一致。
|
|
721
|
+
let model_batches = 0;
|
|
722
|
+
if (model) {
|
|
723
|
+
const cap = Number(limits.importModelMaxItems ?? 0);
|
|
724
|
+
// 成本闸:只把**前 cap 条**交给模型;超出的部分仍走规则段,并在回执里说明(契约 §11.1)
|
|
725
|
+
// 送模型的行**只取本页窗口**(与规则段同一个窗口);`m` 是单元里预存的原文,不再按 kind 重算。
|
|
726
|
+
const textRows = win.map((u) => ({ locator: u.c.locator, text: u.m }));
|
|
727
|
+
const offered = cap > 0 ? textRows.slice(0, cap) : [];
|
|
728
|
+
model_rows = offered.length;
|
|
729
|
+
model_rows_capped = offered.length < textRows.length;
|
|
730
|
+
const ctx = {
|
|
731
|
+
kind, name, source_hash, today,
|
|
732
|
+
rows: offered, rules: raw, limits,
|
|
733
|
+
max_items: cap, model: opts.modelLabel ?? null,
|
|
734
|
+
};
|
|
735
|
+
let produced = null;
|
|
736
|
+
let thrown = '';
|
|
737
|
+
try {
|
|
738
|
+
produced = await model(ctx);
|
|
739
|
+
} catch (e) {
|
|
740
|
+
thrown = `模型段抛错:${e?.message ?? e}`;
|
|
741
|
+
}
|
|
742
|
+
if (thrown === '' && !Array.isArray(produced)) thrown = '模型段必须回候选数组';
|
|
743
|
+
if (thrown !== '') produced = null;
|
|
744
|
+
const ms = isPlainObject(ctx.model_stats) ? ctx.model_stats : null;
|
|
745
|
+
if (ms && Number(ms.batches) > 0) model_batches = Number(ms.batches);
|
|
746
|
+
const batchFail = ms && Array.isArray(ms.failed) && ms.failed.length ? ms.failed : null;
|
|
747
|
+
// ── 降级回执(D11 放行,2026-10-14)──────────────────────────────────────────────
|
|
748
|
+
// 模型段没跑成**不再让整发失败**:规则段候选照常给,但**必须显式标注**(`model_error`),
|
|
749
|
+
// 且回执里不许出现任何声称模型成功的字段(`model_used:false`、`model:null`)。
|
|
750
|
+
if (thrown !== '' || batchFail) {
|
|
751
|
+
model_error = {
|
|
752
|
+
engine: opts.modelLabel ?? null,
|
|
753
|
+
message: thrown !== '' ? thrown : String(ms.error || '模型段有一批没跑成'),
|
|
754
|
+
rows_offered: model_rows,
|
|
755
|
+
...(batchFail
|
|
756
|
+
? {
|
|
757
|
+
batches_total: Number(ms.batches),
|
|
758
|
+
batches_ok: Number(ms.ok) || 0,
|
|
759
|
+
batches_failed: batchFail.map((x) => x.index),
|
|
760
|
+
}
|
|
761
|
+
: {}),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
// `model_used` 的语义 = **有模型产出进入这份回执**。全成功时即使是空产出也算跑过
|
|
765
|
+
// (那是"模型看了、没东西可提",与"没跑成"是两件事);一旦降级,就只有**真拿到候选**才算 true。
|
|
766
|
+
model_used = Array.isArray(produced) && (produced.length > 0 || model_error === null);
|
|
767
|
+
for (const item of (Array.isArray(produced) ? produced : [])) {
|
|
768
|
+
if (!isPlainObject(item)) continue;
|
|
769
|
+
const loc = item.locator === undefined || item.locator === null ? '' : String(item.locator).trim();
|
|
770
|
+
const filled = {
|
|
771
|
+
title: typeof item.title === 'string' && item.title.trim() !== '' ? item.title.trim() : `${name}#${loc || '?'}`,
|
|
772
|
+
body: typeof item.body === 'string' ? item.body : '',
|
|
773
|
+
category: typeof item.category === 'string' && item.category.trim() !== '' ? item.category.trim() : defaults.category,
|
|
774
|
+
source_kind: typeof item.source_kind === 'string' && item.source_kind.trim() !== '' ? item.source_kind.trim() : defaults.source_kind,
|
|
775
|
+
source_ref: typeof item.source_ref === 'string' && item.source_ref.trim() !== '' ? item.source_ref.trim() : `${name}#${loc}`,
|
|
776
|
+
as_of: typeof item.as_of === 'string' && item.as_of.trim() !== '' ? item.as_of.trim() : defaults.as_of,
|
|
777
|
+
locator: loc,
|
|
778
|
+
evidence: typeof item.evidence === 'string' ? item.evidence : '',
|
|
779
|
+
origin: 'model',
|
|
780
|
+
...(item.version !== undefined ? { version: item.version } : {}),
|
|
781
|
+
...(item.conditions !== undefined ? { conditions: item.conditions } : {}),
|
|
782
|
+
...(item.check_spec !== undefined ? { check_spec: item.check_spec } : {}),
|
|
783
|
+
};
|
|
784
|
+
candidates.push(verifyWithIndex(filled, index, vopts));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── 批次上限:超出部分**不静默丢**(回执里给出条数)────────────────────────────────
|
|
789
|
+
const batchMax = Number(limits.importBatchMaxItems ?? candidates.length);
|
|
790
|
+
const kept = candidates.slice(0, batchMax);
|
|
791
|
+
const dropped = candidates.length - kept.length;
|
|
792
|
+
kept.forEach((c, i) => { c.index = i + 1; });
|
|
793
|
+
|
|
794
|
+
return {
|
|
795
|
+
ok: true,
|
|
796
|
+
source_hash,
|
|
797
|
+
candidates: kept,
|
|
798
|
+
stats: {
|
|
799
|
+
rows: dataRows,
|
|
800
|
+
candidates: kept.length,
|
|
801
|
+
rejected: kept.filter((c) => c.selected !== true).length,
|
|
802
|
+
model_used,
|
|
803
|
+
model: model_used ? (opts.modelLabel ?? 'custom') : null,
|
|
804
|
+
...(model_used ? { model_rows, model_rows_capped } : {}),
|
|
805
|
+
...(model_used && model_batches > 1 ? { model_batches } : {}),
|
|
806
|
+
...(model_error ? { model_error } : {}),
|
|
807
|
+
...(dropped > 0 ? { dropped_by_batch_limit: dropped } : {}),
|
|
808
|
+
// JSON 这条通道**把认出来的形态写进回执**(选项文档 §11.2:"判错不静默")—— 数组 / 单对象 / JSONL。
|
|
809
|
+
...(kind === 'json' && index.json && index.json.ok === true ? { json_form: index.json.form } : {}),
|
|
810
|
+
// SQLite 把"源的身份 / 认的是哪张表 / 还有没有下一页"如实写进回执(§11.3;D15 的源摘要按文件身份算)。
|
|
811
|
+
...(isSqlite ? {
|
|
812
|
+
sqlite_table: index.sqlite.table ?? null,
|
|
813
|
+
sqlite_columns: index.sqlite.columns ?? [],
|
|
814
|
+
sqlite_key: index.sqlite.unit_kind ?? null,
|
|
815
|
+
sqlite_order_by: index.sqlite.order_by ?? null,
|
|
816
|
+
sqlite_sidecars_created: Array.isArray(index.sqlite.sidecars_created) ? index.sqlite.sidecars_created : [],
|
|
817
|
+
sqlite_identity_changed: index.sqlite.identity_changed === true,
|
|
818
|
+
sqlite_source: index.sqlite.source_identity ?? null,
|
|
819
|
+
sqlite_has_more: index.sqlite.has_more === true,
|
|
820
|
+
...(index.sqlite.views_total > 0 ? { sqlite_views: index.sqlite.views_total } : {}),
|
|
821
|
+
...(index.sqlite.locators_capped > 0 ? { sqlite_locators_capped: index.sqlite.locators_capped } : {}),
|
|
822
|
+
} : {}),
|
|
823
|
+
// 分页账(2026-10-13,D12):客户端据此判"还要不要继续加载",以及走渐进还是走页码
|
|
824
|
+
// (`units_total` 与 1000 比 —— 阈值在客户端,服务端只如实报数)。
|
|
825
|
+
offset,
|
|
826
|
+
units_total: unitsTotal,
|
|
827
|
+
next_offset: nextOffset,
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── 入库 ──────────────────────────────────────────────────────────────────────────────
|
|
833
|
+
/**
|
|
834
|
+
* 人审后的候选 → **既有 `store.create`**。逐条回执,总数自洽(契约 §8):
|
|
835
|
+
* `committed + skipped + rejected.length === items.length`。
|
|
836
|
+
*
|
|
837
|
+
* `ok:true` 的语义是"**这批处理完了、回执是完整的**",不是"每条都进去了" ——
|
|
838
|
+
* 被拒条目是**正常结果**,写在 `rejected` 里,不该把整批说成失败(那样前端只会看到一个红叉)。
|
|
839
|
+
* 只有"连批次都没法处理"(形状不对/超批上限)才回 `ok:false`。
|
|
840
|
+
*/
|
|
841
|
+
export async function commitImport(target, payload) {
|
|
842
|
+
if (!target || typeof target.create !== 'function') {
|
|
843
|
+
return { ok: false, reason: 'invalid_target', message: 'commit 需要一个至少实现 create 的 store' };
|
|
844
|
+
}
|
|
845
|
+
if (!isPlainObject(payload)) return { ok: false, reason: 'invalid_payload', message: 'commit 入参必须是普通对象' };
|
|
846
|
+
const sourceHash = typeof payload.source_hash === 'string' ? payload.source_hash.trim() : '';
|
|
847
|
+
if (!HASH_RE.test(sourceHash)) {
|
|
848
|
+
return { ok: false, reason: 'invalid_source_hash', message: 'source_hash 形状非法(期望 sha256:<64hex>)' };
|
|
849
|
+
}
|
|
850
|
+
const items = payload.items;
|
|
851
|
+
if (!Array.isArray(items)) return { ok: false, reason: 'invalid_items', message: 'items 必须是数组' };
|
|
852
|
+
const limits = payload.limits || target.limits || DEFAULT_LIBRARY_LIMITS;
|
|
853
|
+
if (items.length > Number(limits.importBatchMaxItems)) {
|
|
854
|
+
return {
|
|
855
|
+
ok: false, reason: 'batch_too_large',
|
|
856
|
+
message: `单批 ${items.length} 条,超过 importBatchMaxItems=${limits.importBatchMaxItems}`,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const batchBytes = utf8Bytes(JSON.stringify(items));
|
|
860
|
+
if (batchBytes > Number(limits.importBatchMaxBytes)) {
|
|
861
|
+
return {
|
|
862
|
+
ok: false, reason: 'batch_too_large',
|
|
863
|
+
message: `单批 ${batchBytes} 字节,超过 importBatchMaxBytes=${limits.importBatchMaxBytes}`,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const committed = [];
|
|
868
|
+
const rejected = [];
|
|
869
|
+
let skipped = 0;
|
|
870
|
+
let no_key = 0;
|
|
871
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
872
|
+
const idx = i + 1; // 契约 §4:index 是**本批序号**,1-based(与回执对得上)
|
|
873
|
+
const raw = items[i];
|
|
874
|
+
if (!isPlainObject(raw)) { rejected.push({ index: idx, reason: 'invalid_item' }); continue; }
|
|
875
|
+
const locator = raw.locator === undefined || raw.locator === null ? '' : String(raw.locator).trim();
|
|
876
|
+
const item = {};
|
|
877
|
+
// ⚠️ 只剥**导入层**字段,其余原样交给 create —— 静默剥掉 checked_state 就等于放行伪造认证
|
|
878
|
+
for (const [k, v] of Object.entries(raw)) if (!IMPORT_ONLY_KEYS.includes(k)) item[k] = v;
|
|
879
|
+
if (locator === '') {
|
|
880
|
+
no_key += 1; // 没有定位就没有幂等键 ⇒ 如实记账(重复导入它挡不住)
|
|
881
|
+
} else {
|
|
882
|
+
item.import_key = importKey(sourceHash, locator);
|
|
883
|
+
}
|
|
884
|
+
let res = null;
|
|
885
|
+
try {
|
|
886
|
+
res = await target.create(item);
|
|
887
|
+
} catch (e) {
|
|
888
|
+
rejected.push({ index: idx, reason: 'create_threw', message: String(e?.message ?? e) });
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (res && res.ok === true) {
|
|
892
|
+
committed.push(res.item?.id ?? null);
|
|
893
|
+
} else if (res && res.reason === 'duplicate_import') {
|
|
894
|
+
skipped += 1; // 幂等命中:同一份源、同一个定位 —— 这是**成功**,不是失败
|
|
895
|
+
} else {
|
|
896
|
+
rejected.push({
|
|
897
|
+
index: idx,
|
|
898
|
+
reason: (res && res.reason) || 'create_failed',
|
|
899
|
+
...(res && res.message ? { message: res.message } : {}),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return {
|
|
904
|
+
ok: true,
|
|
905
|
+
committed: committed.length,
|
|
906
|
+
skipped,
|
|
907
|
+
rejected,
|
|
908
|
+
ids: committed,
|
|
909
|
+
...(no_key > 0 ? { no_key } : {}),
|
|
910
|
+
};
|
|
911
|
+
}
|