@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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 +92 -0
- package/README.en.md +225 -2
- package/README.md +180 -2
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +1 -1
|
@@ -1,529 +1,530 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 文档索引器 —— 一轮的编排(扫描面 → 差分 → 摘要 → 落库 → 清孤条 → 写 state → 日报一行)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// ①
|
|
5
|
-
// ②
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// 一轮里的顺序就是语义,别调换:
|
|
4
|
+
// ① 扫 docs 面(四类目录排除在外)+ 定本轮范围(首期十篇 / 全量 / 点名)
|
|
5
|
+
// ② 差分:state 里的 mtime/size + 库里有没有这一篇的条 ⇒ 没变的篇一个字节都不碰
|
|
6
|
+
// ③ 变了的篇:读正文 → 解析结构 → 切条 → 出摘要(规则确定性 / 模型受成本闸)
|
|
7
|
+
// ④ 分批 UPSERT(等锁 + 退避重试;批小一点好在锁竞争里让路)
|
|
8
|
+
// ⑤ 清掉本轮没再生成的分条(文档变短后留下的 path#§N 残留)
|
|
9
|
+
// ⑥ state 原子写 + 日报一行
|
|
10
|
+
// 任何一步失败都只记账、不往外抛:这条链出问题不该连累别的机制。
|
|
10
11
|
import fs from 'node:fs';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
|
-
import { textHash } from './hashes.mjs';
|
|
14
14
|
import {
|
|
15
|
-
DEFAULT_DOCS_ROOT,
|
|
16
|
-
|
|
17
|
-
FIRST_BATCH,
|
|
18
|
-
FIRST_BATCH_SOURCE,
|
|
19
|
-
parseStructure,
|
|
20
|
-
planRows,
|
|
21
|
-
readDoc,
|
|
22
|
-
scanDocs,
|
|
15
|
+
DEFAULT_DOCS_ROOT, DEFAULT_DSH_ROOT, FIRST_BATCH, FIRST_BATCH_SOURCE,
|
|
16
|
+
parseStructure, planRows, readDoc, scanDocs,
|
|
23
17
|
} from './doc-scan.mjs';
|
|
24
18
|
import { emptyCost, resolveApiKey, rolloverCost, summarizeAll } from './doc-summarize.mjs';
|
|
25
19
|
import {
|
|
26
|
-
META_KEYS,
|
|
27
|
-
|
|
28
|
-
hasDocRows,
|
|
29
|
-
listRows,
|
|
30
|
-
openDocIndexStore,
|
|
31
|
-
setMeta,
|
|
32
|
-
upsertDocs,
|
|
33
|
-
withBusyRetry,
|
|
20
|
+
META_KEYS, deleteStaleRows, hasDocRows, listRows,
|
|
21
|
+
openDocIndexStore, setMeta, upsertDocs, withBusyRetry,
|
|
34
22
|
} from './doc-index-store.mjs';
|
|
35
23
|
|
|
36
24
|
export const DOC_INDEX_STATE_SCHEMA = 1;
|
|
37
25
|
export const HISTORY_MAX = 20;
|
|
38
|
-
|
|
26
|
+
// 小批量:独立库各自加锁,一批五条就够
|
|
39
27
|
export const DEFAULT_BATCH = 5;
|
|
40
28
|
|
|
41
|
-
|
|
29
|
+
export const DAILY_SECTION_TITLE = '## 📄 文档索引(doc-index)';
|
|
30
|
+
|
|
31
|
+
// ── state ───────────────────────────────────────────────────────────────────
|
|
42
32
|
|
|
43
33
|
export function readState(file) {
|
|
34
|
+
let raw;
|
|
44
35
|
try {
|
|
45
|
-
|
|
46
|
-
return obj && typeof obj === 'object' ? obj : null;
|
|
36
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
47
37
|
} catch {
|
|
48
|
-
return null; //
|
|
38
|
+
return null; // 文件还没生成 ⇒ 当首次运行
|
|
49
39
|
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null; // 内容坏了同样当首次运行:state 是可重建载体,不该卡住整条链
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 原子写:同目录临时件 + rename(跨盘 rename 必炸,所以临时件必须落在目标旁边)
|
|
49
|
+
function writeAtomic(file, text) {
|
|
50
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
51
|
+
fs.writeFileSync(tmp, text, 'utf8');
|
|
52
|
+
fs.renameSync(tmp, file);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export function writeState(file, obj) {
|
|
53
56
|
try {
|
|
54
57
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
-
|
|
56
|
-
fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
|
|
57
|
-
fs.renameSync(tmp, file); // 同目录 rename:原子且不跨盘(跨盘 rename 必炸,铁律)
|
|
58
|
+
writeAtomic(file, `${JSON.stringify(obj, null, 2)}\n`);
|
|
58
59
|
return true;
|
|
59
60
|
} catch {
|
|
60
61
|
return false;
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// 历史只留最近 max 条,老的直接丢(不追加、不合并)
|
|
64
66
|
export function pushHistory(prev, entry, max = HISTORY_MAX) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
while (
|
|
68
|
-
return
|
|
67
|
+
const kept = Array.isArray(prev?.history) ? prev.history.slice(-(max - 1)) : [];
|
|
68
|
+
kept.push(entry);
|
|
69
|
+
while (kept.length > max) kept.shift();
|
|
70
|
+
return kept;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
//
|
|
73
|
+
// ── 范围与差分 ──────────────────────────────────────────────────────────────
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
// 本轮做哪些篇:--all 走全扫描面 / --only 点名 / 默认首期那十篇
|
|
74
76
|
export function resolveScope({ scanned, flags = {} }) {
|
|
75
|
-
const byRel = new Map(scanned.map((d) => [d.rel, d]));
|
|
76
77
|
if (flags.all || flags.scope === 'all') {
|
|
77
78
|
return { mode: 'all', source: '扫描面全量(--all)', docs: scanned, missing: [] };
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
80
|
+
|
|
81
|
+
const explicit = typeof flags.only === 'string' && flags.only.trim()
|
|
82
|
+
? flags.only.split(',').map((s) => s.trim()).filter(Boolean)
|
|
83
|
+
: null;
|
|
84
|
+
const wanted = explicit ?? FIRST_BATCH;
|
|
85
|
+
const byRel = new Map(scanned.map((doc) => [doc.rel, doc]));
|
|
86
|
+
|
|
84
87
|
const docs = [];
|
|
85
88
|
const missing = [];
|
|
86
|
-
for (const rel of
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
+
for (const rel of wanted) {
|
|
90
|
+
const found = byRel.get(rel);
|
|
91
|
+
if (found) docs.push(found);
|
|
89
92
|
else missing.push(rel);
|
|
90
93
|
}
|
|
94
|
+
|
|
91
95
|
return {
|
|
92
|
-
mode:
|
|
93
|
-
source:
|
|
96
|
+
mode: explicit ? 'only' : 'first-batch',
|
|
97
|
+
source: explicit ? '--only 显式指定' : FIRST_BATCH_SOURCE,
|
|
94
98
|
docs,
|
|
95
99
|
missing,
|
|
96
100
|
};
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
// 重建判据表:从上往下,命中第一条就重建(顺序有意义 —— 强制最高,其次没记录,再其次变了)
|
|
104
|
+
const REBUILD_RULES = [
|
|
105
|
+
[(ctx) => ctx.force, 'forced'],
|
|
106
|
+
[(ctx) => !ctx.prevEntry, 'new'],
|
|
107
|
+
[
|
|
108
|
+
(ctx) => Number(ctx.prevEntry.mtime) !== Number(ctx.doc.mtime) || Number(ctx.prevEntry.size) !== Number(ctx.doc.size),
|
|
109
|
+
'mtime-or-size-changed',
|
|
110
|
+
],
|
|
111
|
+
// 只有 failed 才强推重算:ok(本轮重建过)与 skipped(本轮确认过最新)都算「已同步」;
|
|
112
|
+
// 把 skipped 也当没同步,会让跳过轮和重建轮来回振荡。
|
|
113
|
+
[(ctx) => ctx.prevEntry.lastResult === 'failed', 'last-failed'],
|
|
114
|
+
[(ctx) => !ctx.hasRow, 'missing-in-db'],
|
|
115
|
+
];
|
|
116
|
+
|
|
104
117
|
export function needsRebuild({ doc, prevEntry, hasRow, force = false }) {
|
|
105
|
-
|
|
106
|
-
if (
|
|
107
|
-
if (Number(prevEntry.mtime) !== Number(doc.mtime) || Number(prevEntry.size) !== Number(doc.size)) {
|
|
108
|
-
return { rebuild: true, reason: 'mtime-or-size-changed' };
|
|
109
|
-
}
|
|
110
|
-
// ⚠️ 只有 **failed** 才强制重算;'ok'(本轮重建过)与 'skipped'(本轮已确认最新)都表示"已同步"。
|
|
111
|
-
// 实测踩过:把 'skipped' 也当"未同步" ⇒ 跳过轮与重建轮来回振荡(永远有一半的轮次在重算摘要)。
|
|
112
|
-
if (prevEntry.lastResult === 'failed') return { rebuild: true, reason: 'last-failed' };
|
|
113
|
-
if (!hasRow) return { rebuild: true, reason: 'missing-in-db' };
|
|
118
|
+
const ctx = { doc, prevEntry, hasRow, force };
|
|
119
|
+
for (const [test, reason] of REBUILD_RULES) if (test(ctx)) return { rebuild: true, reason };
|
|
114
120
|
return { rebuild: false, reason: 'unchanged' };
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
//
|
|
123
|
+
// ── 文案 ────────────────────────────────────────────────────────────────────
|
|
118
124
|
|
|
119
|
-
|
|
125
|
+
// 日报一行(纯函数:state 里存的和日报段里写的必须是同一份文本)
|
|
120
126
|
export function renderDailyLine(state) {
|
|
121
|
-
const
|
|
127
|
+
const run = state?.lastRun ?? {};
|
|
122
128
|
const cost = state?.cost ?? { calls: 0, tokens: 0 };
|
|
123
|
-
const failed = Number(
|
|
124
|
-
const
|
|
125
|
-
const detail = `本轮 重建 ${
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
return `${
|
|
129
|
+
const failed = Number(run.failedCount ?? (Array.isArray(run.failed) ? run.failed.length : 0));
|
|
130
|
+
const scale = `📄 文档索引(doc-index):${run.totalDocs ?? 0} 篇 / ${run.totalRows ?? 0} 条`;
|
|
131
|
+
const detail = `本轮 重建 ${run.rebuilt ?? 0} · 跳过 ${run.skipped ?? 0} · 写库 ${run.upserted ?? 0} · 清孤条 ${run.staleDeleted ?? 0}`;
|
|
132
|
+
const bill = `失败 ${failed} · 成本 ${cost.calls ?? 0} 次调用 ${cost.tokens ?? 0} token`;
|
|
133
|
+
const flag = failed ? ` ⚠️ ${String(run.failed?.[0]?.error ?? '').slice(0, 80)}` : '';
|
|
134
|
+
return `${scale} · ${detail} · ${bill}${flag}`;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
|
|
137
|
+
// 当日日报路径(与日报其它段同一口径:<docsDir>/管家报告/<YYYY-MM-DD>-<host>.md)
|
|
132
138
|
export function dailyReportPath({ docsDir = DEFAULT_DOCS_ROOT, host, now = Date.now() } = {}) {
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const date = `${
|
|
139
|
+
const when = new Date(now);
|
|
140
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
141
|
+
const date = `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())}`;
|
|
136
142
|
return path.join(docsDir, '管家报告', `${date}-${host || os.hostname()}.md`);
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
|
|
145
|
+
// 段体替换:从标题行之后一路换到「下一个二级标题」之前
|
|
146
|
+
function swapSection(rows, at, block) {
|
|
147
|
+
let stop = rows.length;
|
|
148
|
+
for (let i = at + 1; i < rows.length; i += 1) {
|
|
149
|
+
if (/^##\s/.test(rows[i])) {
|
|
150
|
+
stop = i;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...rows.slice(0, at), ...block, ...rows.slice(stop)].join('\n');
|
|
155
|
+
}
|
|
140
156
|
|
|
141
157
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
158
|
+
* 幂等更新当日日报里的「文档索引」段。**文件不存在就不新建** ——
|
|
159
|
+
* 日报是别的模块的产物,本模块只负责往里补一行。
|
|
144
160
|
* @returns {{ok:boolean, file:string, reason?:string, created?:boolean}}
|
|
145
161
|
*/
|
|
146
|
-
export function updateDailySection({
|
|
147
|
-
|
|
162
|
+
export function updateDailySection({
|
|
163
|
+
file,
|
|
164
|
+
line,
|
|
165
|
+
dryRun = false,
|
|
166
|
+
}) {
|
|
167
|
+
let text;
|
|
148
168
|
try {
|
|
149
|
-
|
|
169
|
+
text = fs.readFileSync(file, 'utf8');
|
|
150
170
|
} catch {
|
|
151
171
|
return { ok: false, file, reason: 'daily-missing' };
|
|
152
172
|
}
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
let end = lines.length;
|
|
164
|
-
for (let j = idx + 1; j < lines.length; j += 1) {
|
|
165
|
-
if (/^##\s/.test(lines[j])) {
|
|
166
|
-
end = j;
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
out = [...lines.slice(0, idx), ...block, ...lines.slice(end)].join('\n');
|
|
171
|
-
if (out === txt) return { ok: true, file, reason: 'unchanged', created: false };
|
|
172
|
-
}
|
|
173
|
+
|
|
174
|
+
const rows = text.split(/\r?\n/);
|
|
175
|
+
const sectionLines = [DAILY_SECTION_TITLE, '', `> ${line}`, ''];
|
|
176
|
+
const at = rows.findIndex((row) => row.trim() === DAILY_SECTION_TITLE);
|
|
177
|
+
const created = at < 0;
|
|
178
|
+
const out = created
|
|
179
|
+
? `${text.replace(/\s*$/, '')}\n\n${sectionLines.join('\n')}`
|
|
180
|
+
: swapSection(rows, at, sectionLines);
|
|
181
|
+
|
|
182
|
+
if (!created && out === text) return { ok: true, file, reason: 'unchanged', created: false };
|
|
173
183
|
if (dryRun) return { ok: true, file, reason: 'dry-run', created };
|
|
174
184
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
} catch (e) {
|
|
179
|
-
return { ok: false, file, reason: `write-failed:${String((e && e.message) || e)}` };
|
|
185
|
+
writeAtomic(file, out);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return { ok: false, file, reason: `write-failed:${String((err && err.message) || err)}` };
|
|
180
188
|
}
|
|
181
189
|
return { ok: true, file, created };
|
|
182
190
|
}
|
|
183
191
|
|
|
184
|
-
//
|
|
192
|
+
// ── 主流程 ──────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
// 注入点:单测把外部依赖整件换掉 ⇒ 零副作用、零网络
|
|
195
|
+
const resolveHooks = (deps) => ({
|
|
196
|
+
readState: deps.readState ?? readState,
|
|
197
|
+
writeState: deps.writeState ?? writeState,
|
|
198
|
+
openStore: deps.openStore ?? openDocIndexStore,
|
|
199
|
+
scanDocs: deps.scanDocs ?? scanDocs,
|
|
200
|
+
readDoc: deps.readDoc ?? readDoc,
|
|
201
|
+
summarize: deps.summarizeAll ?? summarizeAll,
|
|
202
|
+
upsert: deps.upsertDocs ?? upsertDocs,
|
|
203
|
+
purge: deps.deleteStaleRows ?? deleteStaleRows,
|
|
204
|
+
list: deps.listRows ?? listRows,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// 各类路径一律三档:flags > 环境变量 > 默认值
|
|
208
|
+
function resolvePaths(flags, env) {
|
|
209
|
+
const homeFlag = String(flags.home || env.DSH_HOME || path.join(os.homedir(), '.dsh'));
|
|
210
|
+
const docsFlag = String(flags['docs-root'] || env.DOC_INDEX_DOCS_ROOT || DEFAULT_DOCS_ROOT);
|
|
211
|
+
const dbFlag = String(flags['doc-index-db'] || env.DOC_INDEX_DB || path.join(homeFlag, 'memory-vectors', 'doc-index.db'));
|
|
212
|
+
const stateFlag = String(flags['state-file'] || path.join(homeFlag, 'butler-doc-index-state.json'));
|
|
213
|
+
return {
|
|
214
|
+
home: path.resolve(homeFlag),
|
|
215
|
+
docsRoot: path.resolve(docsFlag),
|
|
216
|
+
dshRoot: path.resolve(String(flags['dsh-root'] || DEFAULT_DSH_ROOT)),
|
|
217
|
+
dbPath: path.resolve(dbFlag),
|
|
218
|
+
stateFile: path.resolve(stateFlag),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 计数段拆两块:中间夹着三个「数组型」字段,这样字段顺序与既有 state 文件一致
|
|
223
|
+
const RUN_COUNTS_A = ['rebuilt', 'skipped', 'upserted', 'inserted', 'updated', 'unchanged', 'staleDeleted', 'longDocs'];
|
|
224
|
+
const RUN_COUNTS_B = ['batches', 'busyWaits', 'llmCalls', 'llmTokens'];
|
|
225
|
+
|
|
226
|
+
function freshStats({ docsRoot, dbPath, stateFile, startedAt }) {
|
|
227
|
+
const tally = (keys) => Object.fromEntries(keys.map((key) => [key, 0]));
|
|
228
|
+
return {
|
|
229
|
+
scope: null, scopeSource: null,
|
|
230
|
+
docsRoot, dbPath, stateFile,
|
|
231
|
+
scanned: 0, considered: 0, totalDocs: 0, totalRows: 0,
|
|
232
|
+
...tally(RUN_COUNTS_A),
|
|
233
|
+
failures: [], failed: [], failedCount: 0, missing: [],
|
|
234
|
+
...tally(RUN_COUNTS_B),
|
|
235
|
+
rowsByDoc: {}, decisions: {},
|
|
236
|
+
startedAt, finishedAt: 0, elapsedMs: 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 模型通路要的三件:key(flags / 环境 / 凭证文件三档)、端点、模型名
|
|
241
|
+
function buildLlmConfig(flags, env, home) {
|
|
242
|
+
const key = flags['llm-key'] || resolveApiKey({ env, credentialsFile: path.join(home, '.credentials.yaml') });
|
|
243
|
+
const endpoint = String(flags['llm-base-url'] || env.DOC_INDEX_LLM_BASE_URL || 'https://api.deepseek.com');
|
|
244
|
+
const model = String(flags['llm-model'] || env.DOC_INDEX_LLM_MODEL || 'deepseek-flash');
|
|
245
|
+
return { apiKey: key, baseUrl: endpoint, model };
|
|
246
|
+
}
|
|
185
247
|
|
|
186
248
|
/**
|
|
187
|
-
*
|
|
249
|
+
* 跑一轮文档索引(纯编排)。
|
|
188
250
|
* @returns {Promise<{ok:boolean, exitCode:number, status:string, stats:object, state:object, lines:string[]}>}
|
|
189
251
|
*/
|
|
190
|
-
export async function runDocIndexOnce({
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
);
|
|
205
|
-
const stateFile =
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const prev = readStateFn(stateFile) ?? {};
|
|
218
|
-
const d = new Date(startedAt);
|
|
219
|
-
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
220
|
-
const cost = rolloverCost(prev.cost ?? emptyCost(dateKey), dateKey);
|
|
221
|
-
|
|
222
|
-
const stats = {
|
|
223
|
-
scope: null,
|
|
224
|
-
scopeSource: null,
|
|
225
|
-
docsRoot,
|
|
226
|
-
dbPath,
|
|
227
|
-
stateFile,
|
|
228
|
-
scanned: 0,
|
|
229
|
-
considered: 0,
|
|
230
|
-
totalDocs: 0,
|
|
231
|
-
totalRows: 0,
|
|
232
|
-
rebuilt: 0,
|
|
233
|
-
skipped: 0,
|
|
234
|
-
upserted: 0,
|
|
235
|
-
inserted: 0,
|
|
236
|
-
updated: 0,
|
|
237
|
-
unchanged: 0,
|
|
238
|
-
staleDeleted: 0,
|
|
239
|
-
longDocs: 0,
|
|
240
|
-
failures: [],
|
|
241
|
-
failed: [],
|
|
242
|
-
failedCount: 0,
|
|
243
|
-
missing: [],
|
|
244
|
-
batches: 0,
|
|
245
|
-
busyWaits: 0,
|
|
246
|
-
llmCalls: 0,
|
|
247
|
-
llmTokens: 0,
|
|
248
|
-
rowsByDoc: {},
|
|
249
|
-
decisions: {},
|
|
250
|
-
startedAt,
|
|
251
|
-
finishedAt: 0,
|
|
252
|
-
elapsedMs: 0,
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const finish = (status, extra = {}) => {
|
|
252
|
+
export async function runDocIndexOnce({
|
|
253
|
+
flags = {},
|
|
254
|
+
env = process.env,
|
|
255
|
+
now = () => Date.now(),
|
|
256
|
+
deps = {},
|
|
257
|
+
} = {}) {
|
|
258
|
+
const notes = [];
|
|
259
|
+
const note = (msg) => notes.push(String(msg));
|
|
260
|
+
const impl = resolveHooks(deps);
|
|
261
|
+
|
|
262
|
+
const isDry = Boolean(flags['dry-run']);
|
|
263
|
+
const forceAll = Boolean(flags.rebuild) || Boolean(flags.force);
|
|
264
|
+
const perBatch = Number(flags.batch ?? DEFAULT_BATCH) || DEFAULT_BATCH;
|
|
265
|
+
const wantLlm = Boolean(flags.llm);
|
|
266
|
+
const startAt = now();
|
|
267
|
+
const { home, docsRoot, dshRoot, dbPath, stateFile } = resolvePaths(flags, env);
|
|
268
|
+
const hostTag = flags.host || os.hostname();
|
|
269
|
+
|
|
270
|
+
const prior = impl.readState(stateFile) ?? {};
|
|
271
|
+
const clock = new Date(startAt);
|
|
272
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
273
|
+
const dateKey = `${clock.getFullYear()}-${pad(clock.getMonth() + 1)}-${pad(clock.getDate())}`;
|
|
274
|
+
const cost = rolloverCost(prior.cost ?? emptyCost(dateKey), dateKey);
|
|
275
|
+
const stats = freshStats({ docsRoot, dbPath, stateFile, startedAt: startAt });
|
|
276
|
+
|
|
277
|
+
// 收尾:补耗时 → 拼 state → 落盘(dry-run 不写)→ 返回 state
|
|
278
|
+
const wrapUp = (status, extra = {}) => {
|
|
256
279
|
stats.finishedAt = now();
|
|
257
280
|
stats.elapsedMs = stats.finishedAt - stats.startedAt;
|
|
258
|
-
const
|
|
259
|
-
|
|
281
|
+
const stampIso = new Date(stats.finishedAt).toISOString();
|
|
282
|
+
const nextState = {
|
|
283
|
+
...prior,
|
|
260
284
|
schemaVersion: DOC_INDEX_STATE_SCHEMA,
|
|
261
|
-
hostId,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
stateFile,
|
|
265
|
-
runs: (prev.runs ?? 0) + 1,
|
|
266
|
-
lastRunAt: new Date(stats.finishedAt).toISOString(),
|
|
285
|
+
hostId: hostTag, docsRoot, dbPath, stateFile,
|
|
286
|
+
runs: (prior.runs ?? 0) + 1,
|
|
287
|
+
lastRunAt: stampIso,
|
|
267
288
|
lastStatus: status,
|
|
268
289
|
lastRun: stats,
|
|
269
|
-
paths: extra.paths ??
|
|
270
|
-
history: pushHistory(
|
|
271
|
-
at:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
skipped: stats.skipped,
|
|
275
|
-
upserted: stats.upserted,
|
|
276
|
-
failed: stats.failedCount,
|
|
290
|
+
paths: extra.paths ?? prior.paths ?? {},
|
|
291
|
+
history: pushHistory(prior, {
|
|
292
|
+
at: stampIso, status,
|
|
293
|
+
rebuilt: stats.rebuilt, skipped: stats.skipped,
|
|
294
|
+
upserted: stats.upserted, failed: stats.failedCount,
|
|
277
295
|
}),
|
|
278
296
|
cost: extra.cost ?? cost,
|
|
279
297
|
};
|
|
280
|
-
|
|
281
|
-
if (!
|
|
282
|
-
return
|
|
298
|
+
nextState.lastDailyLine = renderDailyLine(nextState);
|
|
299
|
+
if (!isDry) impl.writeState(stateFile, nextState);
|
|
300
|
+
return nextState;
|
|
283
301
|
};
|
|
284
302
|
|
|
285
303
|
// ① 扫描面 + 范围
|
|
286
|
-
const
|
|
287
|
-
stats.scanned =
|
|
288
|
-
const
|
|
289
|
-
stats.scope =
|
|
290
|
-
stats.scopeSource =
|
|
291
|
-
stats.considered =
|
|
292
|
-
stats.missing =
|
|
293
|
-
|
|
294
|
-
`[doc-index] 扫描面 ${
|
|
295
|
-
+ ` · 本轮范围 ${
|
|
304
|
+
const surface = impl.scanDocs(docsRoot, { dshRoot });
|
|
305
|
+
stats.scanned = surface.length;
|
|
306
|
+
const range = resolveScope({ scanned: surface, flags });
|
|
307
|
+
stats.scope = range.mode;
|
|
308
|
+
stats.scopeSource = range.source;
|
|
309
|
+
stats.considered = range.docs.length;
|
|
310
|
+
stats.missing = range.missing;
|
|
311
|
+
note(
|
|
312
|
+
`[doc-index] 扫描面 ${surface.length} 篇(已排除 变更流水 / 管家报告 / 坑库 / _archive)`
|
|
313
|
+
+ ` · 本轮范围 ${range.mode} = ${range.docs.length} 篇(${range.source})`,
|
|
296
314
|
);
|
|
297
|
-
if (
|
|
298
|
-
|
|
315
|
+
if (range.missing.length) {
|
|
316
|
+
note(`[doc-index] ⚠️ 清单里 ${range.missing.length} 篇不在扫描面内:${range.missing.join('、')}`);
|
|
299
317
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return { ok: true, exitCode: 0, status: 'empty', stats, state, lines };
|
|
318
|
+
if (!range.docs.length) {
|
|
319
|
+
const nextState = wrapUp('empty', { paths: prior.paths ?? {} });
|
|
320
|
+
return { ok: true, exitCode: 0, status: 'empty', stats, state: nextState, lines: notes };
|
|
304
321
|
}
|
|
305
322
|
|
|
306
|
-
//
|
|
307
|
-
let
|
|
308
|
-
if (!
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
let summaries = { byKey: new Map(), cost, calls: 0, tokens: 0, stopped: null, failures: [], skippedForFamily: [] };
|
|
323
|
+
// 独立库:dry-run 且库还不存在 ⇒ 不建库、不建目录
|
|
324
|
+
let handle = null;
|
|
325
|
+
if (!isDry || fs.existsSync(dbPath)) handle = impl.openStore(dbPath, { create: !isDry, hostId: hostTag });
|
|
326
|
+
const conn = handle?.db ?? null;
|
|
327
|
+
|
|
328
|
+
let digests = { byKey: new Map(), cost, calls: 0, tokens: 0, stopped: null, failures: [], skippedForFamily: [] };
|
|
313
329
|
|
|
314
330
|
try {
|
|
315
331
|
// ② 差分
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
for (const doc of
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
const priorPaths = prior.paths ?? {};
|
|
333
|
+
const dirty = [];
|
|
334
|
+
for (const doc of range.docs) {
|
|
335
|
+
const prevEntry = priorPaths[doc.rel] ?? null;
|
|
336
|
+
const hasRow = conn ? hasDocRows(conn, doc.rel) : Boolean(priorPaths[doc.rel]);
|
|
337
|
+
const verdict = needsRebuild({ doc, prevEntry, hasRow, force: forceAll });
|
|
338
|
+
stats.decisions[doc.rel] = verdict;
|
|
339
|
+
if (verdict.rebuild) {
|
|
340
|
+
dirty.push({ doc, reason: verdict.reason });
|
|
325
341
|
continue;
|
|
326
342
|
}
|
|
327
|
-
|
|
343
|
+
stats.skipped += 1;
|
|
344
|
+
note(`[doc-index] 跳过 ${doc.rel}(未变:mtime/size 与 state 一致,库里也已有条)`);
|
|
328
345
|
}
|
|
329
346
|
|
|
330
|
-
// ③
|
|
347
|
+
// ③ 读正文 → 解析 → 切条,顺带攒出要送去出摘要的那批
|
|
331
348
|
const planMap = new Map();
|
|
332
|
-
const
|
|
333
|
-
for (const item of
|
|
349
|
+
const toSummarize = [];
|
|
350
|
+
for (const item of dirty) {
|
|
334
351
|
try {
|
|
335
|
-
const { text, chars } =
|
|
336
|
-
const
|
|
337
|
-
const doc = { ...item.doc, chars, structure };
|
|
352
|
+
const { text, chars } = impl.readDoc(item.doc.abs);
|
|
353
|
+
const doc = { ...item.doc, chars, structure: parseStructure(text) };
|
|
338
354
|
planMap.set(doc.rel, { doc, rows: planRows(doc), reason: item.reason });
|
|
339
|
-
|
|
340
|
-
} catch (
|
|
341
|
-
stats.failures.push({ path: item.doc.rel, error: `读取/解析失败:${String((
|
|
355
|
+
toSummarize.push(doc);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
stats.failures.push({ path: item.doc.rel, error: `读取/解析失败:${String((err && err.message) || err)}` });
|
|
342
358
|
}
|
|
343
359
|
}
|
|
344
360
|
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
docs:
|
|
348
|
-
plans: new Map([...planMap].map(([rel,
|
|
349
|
-
mode:
|
|
361
|
+
if (toSummarize.length) {
|
|
362
|
+
digests = await impl.summarize({
|
|
363
|
+
docs: toSummarize,
|
|
364
|
+
plans: new Map([...planMap].map(([rel, entry]) => [rel, entry.rows])),
|
|
365
|
+
mode: wantLlm ? 'llm' : 'rule',
|
|
350
366
|
cost,
|
|
351
367
|
date: dateKey,
|
|
352
|
-
llm:
|
|
368
|
+
llm: wantLlm ? buildLlmConfig(flags, env, home) : null,
|
|
353
369
|
deps,
|
|
354
370
|
});
|
|
355
|
-
for (const
|
|
356
|
-
stats.llmCalls = Number(
|
|
357
|
-
stats.llmTokens = Number(
|
|
358
|
-
if (
|
|
371
|
+
for (const failure of digests.failures ?? []) stats.failures.push(failure);
|
|
372
|
+
stats.llmCalls = Number(digests.calls) || 0;
|
|
373
|
+
stats.llmTokens = Number(digests.tokens) || 0;
|
|
374
|
+
if (digests.stopped) note(`[doc-index] ⛔ 成本闸:${digests.stopped} ⇒ 本轮剩余文档停止(不重试)`);
|
|
359
375
|
}
|
|
360
376
|
|
|
361
|
-
// ④
|
|
362
|
-
const
|
|
377
|
+
// ④ 组 payload 并分批写库
|
|
378
|
+
const writes = [];
|
|
363
379
|
for (const [rel, entry] of planMap) {
|
|
364
|
-
const usable = entry.rows.filter((
|
|
365
|
-
|
|
380
|
+
const usable = entry.rows.filter((row) => digests.byKey.has(row.key));
|
|
381
|
+
const lost = entry.rows.length - usable.length;
|
|
382
|
+
if (lost > 0) {
|
|
366
383
|
stats.failures.push({
|
|
367
384
|
path: rel,
|
|
368
|
-
error: `摘要缺失 ${
|
|
385
|
+
error: `摘要缺失 ${lost}/${entry.rows.length} 条(成本闸中止或上游失败)⇒ 该篇跳过,下轮重试`,
|
|
369
386
|
});
|
|
370
387
|
}
|
|
371
388
|
if (!usable.length) continue;
|
|
372
389
|
stats.rebuilt += 1;
|
|
373
390
|
stats.rowsByDoc[rel] = usable.length;
|
|
374
391
|
if (entry.rows.length > 1) stats.longDocs += 1;
|
|
375
|
-
for (const
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
path:
|
|
392
|
+
for (const row of usable) {
|
|
393
|
+
const digest = digests.byKey.get(row.key);
|
|
394
|
+
writes.push({
|
|
395
|
+
path: row.key,
|
|
379
396
|
mtime: entry.doc.mtime,
|
|
380
397
|
size: entry.doc.size,
|
|
381
|
-
textHash:
|
|
382
|
-
summary:
|
|
398
|
+
textHash: digest.textHash,
|
|
399
|
+
summary: digest.summary,
|
|
383
400
|
ownerUser: flags['owner-user'] || 'LiJianXiao',
|
|
384
401
|
visibility: flags.visibility || 'private_owner',
|
|
385
|
-
|
|
402
|
+
// 索引条描述的是**这篇文档的 mtime**,不是写入时刻
|
|
403
|
+
updatedAt: entry.doc.mtime,
|
|
386
404
|
});
|
|
387
405
|
}
|
|
388
406
|
}
|
|
389
|
-
stats.totalRows = Object.values(stats.rowsByDoc).reduce((
|
|
407
|
+
stats.totalRows = Object.values(stats.rowsByDoc).reduce((sum, n) => sum + n, 0);
|
|
390
408
|
stats.totalDocs = Object.keys(stats.rowsByDoc).length;
|
|
391
409
|
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
410
|
+
if (writes.length && isDry) note(`[doc-index] --dry-run:本应写 ${writes.length} 条(未写库)`);
|
|
411
|
+
if (writes.length && !isDry) {
|
|
412
|
+
for (let at = 0; at < writes.length; at += perBatch) {
|
|
413
|
+
const batch = writes.slice(at, at + perBatch);
|
|
395
414
|
stats.batches += 1;
|
|
396
|
-
const
|
|
415
|
+
const outcome = await withBusyRetry(() => impl.upsert(conn, batch), {
|
|
397
416
|
sleep: deps.sleep,
|
|
398
417
|
delaysMs: deps.retryDelaysMs,
|
|
399
|
-
onRetry: (n,
|
|
418
|
+
onRetry: (n, err) => {
|
|
400
419
|
stats.busyWaits += 1;
|
|
401
|
-
|
|
420
|
+
note(`[doc-index] 库被锁,退避重试第 ${n} 次(${String((err && err.message) || err)})`);
|
|
402
421
|
},
|
|
403
422
|
});
|
|
404
|
-
if (!
|
|
405
|
-
//
|
|
406
|
-
for (const
|
|
407
|
-
const rel = String(
|
|
408
|
-
if (!stats.failures.some((f) => f.path === rel)) stats.failures.push({ path: rel, error:
|
|
423
|
+
if (!outcome.ok) {
|
|
424
|
+
// 超限只记失败、不抛;失败细到每篇,下一轮只重跑这些
|
|
425
|
+
for (const cell of batch) {
|
|
426
|
+
const rel = String(cell.path).split('#§')[0];
|
|
427
|
+
if (!stats.failures.some((f) => f.path === rel)) stats.failures.push({ path: rel, error: outcome.error });
|
|
409
428
|
}
|
|
410
|
-
|
|
429
|
+
note(`[doc-index] ❌ 批 ${stats.batches} 写库失败(放弃该批,下轮重试):${outcome.error}`);
|
|
411
430
|
continue;
|
|
412
431
|
}
|
|
413
|
-
stats.upserted +=
|
|
414
|
-
stats.inserted +=
|
|
415
|
-
stats.updated +=
|
|
416
|
-
stats.unchanged +=
|
|
432
|
+
stats.upserted += outcome.value.inserted + outcome.value.updated;
|
|
433
|
+
stats.inserted += outcome.value.inserted;
|
|
434
|
+
stats.updated += outcome.value.updated;
|
|
435
|
+
stats.unchanged += outcome.value.unchanged;
|
|
417
436
|
}
|
|
418
|
-
} else if (payload.length && dryRun) {
|
|
419
|
-
log(`[doc-index] --dry-run:本应写 ${payload.length} 条(未写库)`);
|
|
420
437
|
}
|
|
421
438
|
|
|
422
|
-
// ⑤
|
|
423
|
-
if (!
|
|
439
|
+
// ⑤ 清孤条:只清本轮重建过的篇,跳过与失败的一律不动
|
|
440
|
+
if (!isDry && conn) {
|
|
424
441
|
for (const [rel, entry] of planMap) {
|
|
425
|
-
const keep = entry.rows.filter((
|
|
442
|
+
const keep = entry.rows.filter((row) => digests.byKey.has(row.key)).map((row) => row.key);
|
|
426
443
|
if (!keep.length) continue;
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
444
|
+
const outcome = await withBusyRetry(() => impl.purge(conn, rel, keep), {
|
|
445
|
+
sleep: deps.sleep,
|
|
446
|
+
delaysMs: deps.retryDelaysMs,
|
|
447
|
+
});
|
|
448
|
+
if (outcome.ok) stats.staleDeleted += Number(outcome.value) || 0;
|
|
449
|
+
else stats.failures.push({ path: rel, error: `清孤条失败:${outcome.error}` });
|
|
430
450
|
}
|
|
431
451
|
}
|
|
432
452
|
|
|
433
|
-
// ⑥
|
|
434
|
-
if (!
|
|
453
|
+
// ⑥ 元数据 + 规模取库内真实值(跳过轮也要显示真实规模,不能按本轮写了多少算)
|
|
454
|
+
if (!isDry && conn) {
|
|
435
455
|
await withBusyRetry(
|
|
436
456
|
() => {
|
|
437
|
-
setMeta(
|
|
438
|
-
setMeta(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
META_KEYS.lastRunStats,
|
|
442
|
-
JSON.stringify({
|
|
443
|
-
rebuilt: stats.rebuilt,
|
|
444
|
-
skipped: stats.skipped,
|
|
445
|
-
upserted: stats.upserted,
|
|
446
|
-
failed: stats.failures.length,
|
|
447
|
-
}),
|
|
448
|
-
);
|
|
457
|
+
setMeta(conn, META_KEYS.hostId, hostTag);
|
|
458
|
+
setMeta(conn, META_KEYS.lastRunAt, new Date(stats.finishedAt || now()).toISOString());
|
|
459
|
+
const snapshotOfRun = { rebuilt: stats.rebuilt, skipped: stats.skipped, upserted: stats.upserted, failed: stats.failures.length };
|
|
460
|
+
setMeta(conn, META_KEYS.lastRunStats, JSON.stringify(snapshotOfRun));
|
|
449
461
|
},
|
|
450
462
|
{ sleep: deps.sleep },
|
|
451
463
|
);
|
|
452
|
-
// 规模以**库内真实值**为准:跳过轮(本轮没重建任何一篇)也必须显示真实规模,
|
|
453
|
-
// 不能按"本轮写了多少"算 —— 否则日报一行会在跳过轮显示 "0 篇 / 0 条"(实测踩过)。
|
|
454
464
|
try {
|
|
455
|
-
const all =
|
|
465
|
+
const all = impl.list(conn);
|
|
456
466
|
stats.totalRows = all.length;
|
|
457
|
-
stats.totalDocs = new Set(all.map((
|
|
467
|
+
stats.totalDocs = new Set(all.map((row) => String(row.path).split('#§')[0])).size;
|
|
458
468
|
} catch {
|
|
459
|
-
|
|
469
|
+
// 查不动就保持既有值
|
|
460
470
|
}
|
|
461
471
|
}
|
|
462
|
-
} catch (
|
|
463
|
-
stats.failures.push({ path: '(run)', error: `运行异常:${String((
|
|
464
|
-
|
|
472
|
+
} catch (err) {
|
|
473
|
+
stats.failures.push({ path: '(run)', error: `运行异常:${String((err && err.message) || err)}` });
|
|
474
|
+
note(`[doc-index] ❌ 运行异常(只记失败,不影响其它机制):${String((err && err.message) || err)}`);
|
|
465
475
|
} finally {
|
|
466
476
|
try {
|
|
467
|
-
if (
|
|
477
|
+
if (handle) handle.db.close();
|
|
468
478
|
} catch {
|
|
469
|
-
|
|
479
|
+
// 关不掉也没事,进程退出会收
|
|
470
480
|
}
|
|
471
481
|
}
|
|
472
482
|
|
|
473
|
-
//
|
|
474
|
-
// lastResult
|
|
475
|
-
const
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
for (const doc of
|
|
479
|
-
const
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
paths[doc.rel] = {
|
|
483
|
+
// 每篇的 state 条目:mtime / size / lastResult / failCount(四字段是验收口径)
|
|
484
|
+
// lastResult 含义:ok = 本轮重建过;skipped = 本轮确认未变;failed = 本轮失败(下轮强制重算)
|
|
485
|
+
const perPath = { ...(prior.paths ?? {}) };
|
|
486
|
+
const failByPath = new Map(stats.failures.map((failure) => [failure.path, failure.error]));
|
|
487
|
+
const stamp = new Date(stats.finishedAt || now()).toISOString();
|
|
488
|
+
for (const doc of range.docs) {
|
|
489
|
+
const before = perPath[doc.rel] ?? { failCount: 0 };
|
|
490
|
+
const rebuiltOK = Object.prototype.hasOwnProperty.call(stats.rowsByDoc, doc.rel);
|
|
491
|
+
let lastResult = rebuiltOK ? 'ok' : 'skipped';
|
|
492
|
+
if (failByPath.has(doc.rel)) lastResult = 'failed';
|
|
493
|
+
perPath[doc.rel] = {
|
|
485
494
|
mtime: doc.mtime,
|
|
486
495
|
size: doc.size,
|
|
487
496
|
lastResult,
|
|
488
|
-
failCount: lastResult === 'failed' ? Number(
|
|
489
|
-
rows: stats.rowsByDoc[doc.rel] ??
|
|
490
|
-
reason:
|
|
491
|
-
updatedAt:
|
|
492
|
-
error:
|
|
497
|
+
failCount: lastResult === 'failed' ? Number(before.failCount || 0) + 1 : 0,
|
|
498
|
+
rows: stats.rowsByDoc[doc.rel] ?? before.rows ?? 0,
|
|
499
|
+
reason: stats.decisions[doc.rel]?.reason ?? null,
|
|
500
|
+
updatedAt: stamp,
|
|
501
|
+
error: failByPath.get(doc.rel) ?? null,
|
|
493
502
|
};
|
|
494
503
|
}
|
|
495
|
-
|
|
504
|
+
|
|
505
|
+
stats.failed = stats.failures.filter((failure) => failure.path !== '(run)');
|
|
496
506
|
stats.failedCount = stats.failed.length;
|
|
497
507
|
const status = stats.failedCount ? 'failed' : stats.rebuilt === 0 ? 'noop' : 'ok';
|
|
498
|
-
const
|
|
499
|
-
paths,
|
|
500
|
-
cost: { date:
|
|
508
|
+
const nextState = wrapUp(status, {
|
|
509
|
+
paths: perPath,
|
|
510
|
+
cost: { date: digests.cost?.date ?? dateKey, calls: stats.llmCalls, tokens: stats.llmTokens },
|
|
501
511
|
});
|
|
502
512
|
|
|
503
|
-
//
|
|
504
|
-
const
|
|
505
|
-
const
|
|
506
|
-
file:
|
|
507
|
-
line:
|
|
508
|
-
dryRun:
|
|
513
|
+
// 日报一行(默认写;dry-run 或 --no-daily 只算不写;日报不存在则不新建)
|
|
514
|
+
const reportFile = dailyReportPath({ docsDir: docsRoot, host: hostTag, now: startAt });
|
|
515
|
+
const dailyResult = updateDailySection({
|
|
516
|
+
file: reportFile,
|
|
517
|
+
line: nextState.lastDailyLine,
|
|
518
|
+
dryRun: isDry || Boolean(flags['no-daily']),
|
|
509
519
|
});
|
|
510
|
-
stats.daily =
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
520
|
+
stats.daily = dailyResult;
|
|
521
|
+
|
|
522
|
+
note(`[doc-index] 日报一行:${nextState.lastDailyLine}`);
|
|
523
|
+
note(`[doc-index] 日报落点:${reportFile}(${dailyResult.ok ? dailyResult.reason ?? 'written' : dailyResult.reason})`);
|
|
524
|
+
note(
|
|
514
525
|
`[doc-index] ${status === 'failed' ? '⚠️' : '✅'} 重建 ${stats.rebuilt} / 跳过 ${stats.skipped}`
|
|
515
526
|
+ ` · 写库 ${stats.upserted}(新 ${stats.inserted} / 更 ${stats.updated} / 同哈希 ${stats.unchanged})`
|
|
516
527
|
+ ` · 清孤条 ${stats.staleDeleted} · 失败 ${stats.failedCount} · ${stats.elapsedMs}ms`,
|
|
517
528
|
);
|
|
518
|
-
return { ok: true, exitCode: 0, status, stats, state, lines };
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
function buildLlmConfig(flags, env, home) {
|
|
522
|
-
const apiKey =
|
|
523
|
-
flags['llm-key'] || resolveApiKey({ env, credentialsFile: path.join(home, '.credentials.yaml') });
|
|
524
|
-
return {
|
|
525
|
-
apiKey,
|
|
526
|
-
baseUrl: String(flags['llm-base-url'] || env.DOC_INDEX_LLM_BASE_URL || 'https://api.deepseek.com'),
|
|
527
|
-
model: String(flags['llm-model'] || env.DOC_INDEX_LLM_MODEL || 'deepseek-flash'),
|
|
528
|
-
};
|
|
529
|
+
return { ok: true, exitCode: 0, status, stats, state: nextState, lines: notes };
|
|
529
530
|
}
|