@dsh-libing/knowledge-base 0.1.0 → 0.1.4
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 +20 -0
- package/README.md +12 -2
- package/lib/agent-access.d.ts +23 -0
- package/lib/agent-access.js +28 -0
- package/lib/chunks.js +27 -5
- package/lib/client-types/client/JobDetails.d.ts +16 -0
- package/lib/client-types/client/JobsPanel.d.ts +6 -1
- package/lib/client-types/client/QualityReport.d.ts +3 -2
- package/lib/client-types/client/api.d.ts +83 -52
- package/lib/client-types/client/locales.d.ts +2 -0
- package/lib/client-types/client/useKnowledge.d.ts +30 -15
- package/lib/client-types/contract.d.ts +83 -52
- package/lib/client-types/data.d.ts +11 -0
- package/lib/client-types/settings.d.ts +4 -0
- package/lib/client-types/shared.d.ts +17 -0
- package/lib/client.js +40 -40
- package/lib/config.js +9 -1
- package/lib/contract.d.ts +33 -2
- package/lib/contract.js +2 -1
- package/lib/data.d.ts +11 -0
- package/lib/data.js +2 -0
- package/lib/document/convert.js +52 -16
- package/lib/document/pdf-layout.js +45 -10
- package/lib/document/pdf-session.js +10 -4
- package/lib/document/pdf-vision.js +8 -3
- package/lib/document/pdf-worker.js +76 -21
- package/lib/document/pdf.js +39 -9
- package/lib/document/zip.js +14 -6
- package/lib/figures/image-client.d.ts +5 -0
- package/lib/figures/image-client.js +63 -0
- package/lib/figures/image-gate.d.ts +12 -0
- package/lib/figures/image-gate.js +39 -0
- package/lib/figures/image-protocol.d.ts +19 -0
- package/lib/figures/image-protocol.js +9 -0
- package/lib/figures/image-worker.d.ts +1 -0
- package/lib/figures/image-worker.js +69 -0
- package/lib/figures/process.d.ts +1 -1
- package/lib/figures/process.js +19 -38
- package/lib/index.js +6 -2
- package/lib/indexes.js +17 -6
- package/lib/job-storage.d.ts +264 -0
- package/lib/job-storage.js +118 -0
- package/lib/jobs.d.ts +782 -1489
- package/lib/jobs.js +64 -31
- package/lib/models.d.ts +1 -1
- package/lib/models.js +15 -9
- package/lib/retrieval.js +44 -13
- package/lib/revision-cache.d.ts +24 -0
- package/lib/revision-cache.js +100 -0
- package/lib/routes.js +66 -20
- package/lib/runtime.d.ts +4 -0
- package/lib/runtime.js +15 -4
- package/lib/settings.d.ts +4 -0
- package/lib/settings.js +5 -2
- package/lib/shared.d.ts +17 -0
- package/lib/shared.js +3 -0
- package/lib/skills.js +6 -3
- package/lib/store.d.ts +101 -5
- package/lib/store.js +78 -29
- package/lib/tools.js +12 -4
- package/lib/util.js +2 -1
- package/package.json +1 -1
package/lib/jobs.js
CHANGED
|
@@ -14,6 +14,7 @@ import { convertPdf } from "./document/pdf.js";
|
|
|
14
14
|
import { PdfSession, pageRange } from "./document/pdf-session.js";
|
|
15
15
|
import { PDF_PARSER_VERSION } from "./document/pdf-layout.js";
|
|
16
16
|
import { BudgetError } from "./models.js";
|
|
17
|
+
import { loadJob, saveCheckpoint, saveReport, readReport, recoverUsage, summarizeReport, usagePage } from "./job-storage.js";
|
|
17
18
|
const RESUMABLE = new Set(['failed', 'needs-review', 'paused-budget', 'cancelled']);
|
|
18
19
|
export class Jobs {
|
|
19
20
|
store;
|
|
@@ -50,33 +51,51 @@ export class Jobs {
|
|
|
50
51
|
}
|
|
51
52
|
const visible = new Set((await this.store.list()).map(k => k.kbId));
|
|
52
53
|
for (const dir of await this.store.jobDirectories()) {
|
|
53
|
-
const record = await
|
|
54
|
+
const record = await loadJob(dir);
|
|
54
55
|
if (!record || !visible.has(record.kbId))
|
|
55
56
|
continue;
|
|
57
|
+
if (record.jobId !== basename(dir))
|
|
58
|
+
throw new Error('任务目录与身份不一致');
|
|
56
59
|
const job = this.attach(record, dir);
|
|
57
60
|
this.jobs.set(job.jobId, job);
|
|
61
|
+
if (job.pendingCalls.length)
|
|
62
|
+
await recoverUsage(job);
|
|
58
63
|
if (ACTIVE.has(job.state)) {
|
|
59
64
|
job.state = 'queued';
|
|
60
65
|
job.recoveredAt = now();
|
|
61
66
|
await job.checkpoint();
|
|
62
67
|
}
|
|
63
68
|
}
|
|
69
|
+
await this.store.cleanUploads();
|
|
64
70
|
this.pump();
|
|
65
71
|
}
|
|
66
72
|
attach(record, directory) {
|
|
67
73
|
let tail = Promise.resolve();
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
const
|
|
74
|
+
const job = { ...record, directory, cacheDir: join(this.store.root, 'cache'), checkpoint: async () => {
|
|
75
|
+
const { report: _report, usage, ...value } = job;
|
|
76
|
+
const snapshot = jobSchema.parse({ ...value, usage: { ...usage, records: [] } });
|
|
77
|
+
const pending = tail.then(() => saveCheckpoint(directory, snapshot));
|
|
71
78
|
tail = pending.catch(() => { });
|
|
72
79
|
await pending;
|
|
73
80
|
} };
|
|
81
|
+
return job;
|
|
74
82
|
}
|
|
75
83
|
public(job) {
|
|
76
|
-
const { checkpoint: _checkpoint, directory: _directory, cacheDir: _cacheDir, schema: _schema, overlays: _overlays, ...value } = job;
|
|
84
|
+
const { checkpoint: _checkpoint, directory: _directory, cacheDir: _cacheDir, schema: _schema, overlays: _overlays, report: _report, ...value } = job;
|
|
77
85
|
return { ...value, outputPath: value.outputPath || null };
|
|
78
86
|
}
|
|
79
|
-
list(kbId
|
|
87
|
+
list(kbId, offset = 0, limit = 20) {
|
|
88
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 50)
|
|
89
|
+
throw new Error('任务分页参数无效');
|
|
90
|
+
const all = [...this.jobs.values()].filter(j => !kbId || j.kbId === kbId).sort((a, b) => Number(ACTIVE.has(b.state)) - Number(ACTIVE.has(a.state)) || b.createdAt.localeCompare(a.createdAt) || b.jobId.localeCompare(a.jobId));
|
|
91
|
+
const page = all.slice(offset, offset + limit).map(job => {
|
|
92
|
+
const { report: _report, events: _events, failures: _failures, ...value } = this.public(job);
|
|
93
|
+
return { ...value, events: [], failures: [], usage: { ...value.usage, records: [] } };
|
|
94
|
+
});
|
|
95
|
+
return { jobs: page, jobTotal: all.length, jobOffset: offset, activeJobs: [...this.jobs.values()].filter(j => ACTIVE.has(j.state)).length };
|
|
96
|
+
}
|
|
97
|
+
async details(jobId, includeReport = true) { const job = this.get(jobId); const { report: _report, ...value } = this.public(job); return { ...value, ...includeReport ? { report: await readReport(job) } : {} }; }
|
|
98
|
+
async usage(jobId, offset, limit, signal) { return usagePage(this.get(jobId), offset, limit, signal); }
|
|
80
99
|
get(jobId) {
|
|
81
100
|
const job = this.jobs.get(id(jobId, 'jobId'));
|
|
82
101
|
if (!job)
|
|
@@ -92,7 +111,8 @@ export class Jobs {
|
|
|
92
111
|
const kb = await this.store.kb(text(args.kbId, 'kbId', 120));
|
|
93
112
|
if (Boolean(args.sourcePath) === Boolean(args.receiptId))
|
|
94
113
|
throw new Error('sourcePath / receiptId 必须提供其中一个');
|
|
95
|
-
let source
|
|
114
|
+
let source;
|
|
115
|
+
let filename;
|
|
96
116
|
if (args.receiptId) {
|
|
97
117
|
const uploaded = await this.store.uploaded(args.receiptId);
|
|
98
118
|
source = uploaded.data;
|
|
@@ -135,12 +155,14 @@ export class Jobs {
|
|
|
135
155
|
await session.close();
|
|
136
156
|
await rm(temp, { force: true });
|
|
137
157
|
}
|
|
138
|
-
const current = await this.store.current(kb.kbId)
|
|
158
|
+
const current = await this.store.current(kb.kbId);
|
|
159
|
+
const old = current ? (await this.store.revision(kb.kbId, current.revision)).manifest.documents.find(d => d.docId === docId) : null;
|
|
139
160
|
const previous = old?.selectedPages;
|
|
140
161
|
if (previous && (previous.partial || selectedPages.partial) && (previous.start !== selectedPages.start || previous.end !== selectedPages.end) && args.confirmRangeChange !== true)
|
|
141
162
|
throw new Error('PDF 导入范围已变化,本次发布会替换该文档当前内容;请明确确认范围变化,历史版本保留');
|
|
142
163
|
}
|
|
143
|
-
const schema = await this.store.schema(kb.kbId)
|
|
164
|
+
const schema = await this.store.schema(kb.kbId);
|
|
165
|
+
const schemaHash = hash(schema);
|
|
144
166
|
const routes = { vision: config.visionProvider ? { provider: config.visionProvider, model: config.visionModel } : null, summary: config.summaryProvider ? { provider: config.summaryProvider, model: config.summaryModel } : null };
|
|
145
167
|
if (!routes.summary) {
|
|
146
168
|
const defaultRoute = this.models.ctx.get('agentDefaultModel')?.currentSelection() || execution.route;
|
|
@@ -154,7 +176,8 @@ export class Jobs {
|
|
|
154
176
|
const existing = [...this.jobs.values()].find(j => j.kbId === kb.kbId && j.buildKey === optionsKey && (ACTIVE.has(j.state) || j.state === 'ready' && current?.revision === j.revision));
|
|
155
177
|
if (existing)
|
|
156
178
|
return { ...this.public(existing), reused: true };
|
|
157
|
-
const jobId = `job-${randomUUID()}
|
|
179
|
+
const jobId = `job-${randomUUID()}`;
|
|
180
|
+
const directory = join(this.store.root, 'jobs', jobId);
|
|
158
181
|
await mkdir(directory, { recursive: true });
|
|
159
182
|
await atomicWrite(join(directory, 'input' + extname(filename).toLowerCase()), source);
|
|
160
183
|
const job = this.attach(jobSchema.parse({ jobId, kbId: kb.kbId, docId, title: text(args.title || filename.replace(/\.(?:docx?|pdf)$/i, ''), '文档标题', 300), sourceVersion: args.sourceVersion ? text(args.sourceVersion, '文档版本', 120) : '', filename, sourceFormat, ...selectedPages ? { pageStart: selectedPages.start, pageEnd: selectedPages.end, pdf: { pageCount: selectedPages.total, selectedPages } } : {}, sourceOriginalHash, buildKey: optionsKey, config, routes, schema, schemaHash, overlays, sessionId: execution.sessionId || null, state: 'queued', createdAt: now(), progress: {}, usage: { calls: 0, tokens: 0, reportedCalls: 0, records: [] }, failures: [] }), directory);
|
|
@@ -198,12 +221,13 @@ export class Jobs {
|
|
|
198
221
|
const { nodes, chunks } = createChunks(document, info, job.config);
|
|
199
222
|
for (const chunk of chunks)
|
|
200
223
|
chunk.summary = clip(chunk.segments.filter(s => ['paragraph', 'list', 'note'].includes(s.type)).map(s => s.text).join(' ').replace(/\s+/g, ' ') || chunk.title, job.config.summaryMaxChars);
|
|
201
|
-
manifest = { formatVersion: 1, revision: required(job.revision, '草稿版本'), buildKey: job.buildKey, createdAt: job.createdAt, config: { chunkTargetChars: job.config.chunkTargetChars }, catalogs: {}, entities: {}, report: job
|
|
224
|
+
manifest = { formatVersion: 1, revision: required(job.revision, '草稿版本'), buildKey: job.buildKey, createdAt: job.createdAt, config: { chunkTargetChars: job.config.chunkTargetChars }, catalogs: {}, entities: {}, report: await readReport(job) ?? { revision: required(job.revision), sourceHash: required(job.sourceHash), issues: document.warnings, documents: 1, blocks: document.blocks.length, figures: document.assets.length, ignoredFigures: 0, unreviewedFigures: document.assets.length, chunks: chunks.length, withinTarget: chunks.filter(c => c.fullChars <= job.config.chunkTargetChars).length, oversized: [], usage: job.usage, targetChars: job.config.chunkTargetChars }, coverageIncomplete: true, documents: [{ ...info, nodes, chunks, assets: document.assets, figures: document.blocks.filter(b => b.type === 'figure').map(b => required(b.figure)) }] };
|
|
202
225
|
}
|
|
203
226
|
return { root, manifest, revision: required(job.revision), kbId: job.kbId, jobId };
|
|
204
227
|
}
|
|
205
228
|
async run(job, signal) {
|
|
206
|
-
const stage = join(job.directory, 'stage')
|
|
229
|
+
const stage = join(job.directory, 'stage');
|
|
230
|
+
const sourceDir = join(stage, 'sources', job.docId);
|
|
207
231
|
try {
|
|
208
232
|
await this.phase(job, 'preflight');
|
|
209
233
|
checkAbort(signal);
|
|
@@ -249,20 +273,25 @@ export class Jobs {
|
|
|
249
273
|
const { catalogs, entities } = await writeIndexes(stage, docs, job.config, job.revision);
|
|
250
274
|
await this.phase(job, 'validating');
|
|
251
275
|
checkAbort(signal);
|
|
252
|
-
|
|
276
|
+
const indexedChunks = new Set(Object.values(catalogs).flatMap(c => c.pages.flatMap(p => p.entries.flatMap(e => e.chunkId ? [e.chunkId] : []))));
|
|
277
|
+
for (const doc of docs) {
|
|
278
|
+
const assetIds = new Set(doc.assets.map(a => a.assetId));
|
|
253
279
|
for (const chunk of doc.chunks) {
|
|
254
280
|
if (hash(await readFile(await safePath(stage, chunk.path), 'utf8')) !== chunk.contentHash)
|
|
255
281
|
throw new Error('生成片段哈希不一致');
|
|
256
|
-
if (!
|
|
282
|
+
if (!indexedChunks.has(chunk.chunkId))
|
|
257
283
|
throw new Error('索引遗漏片段');
|
|
258
284
|
for (const assetId of chunk.assetRefs)
|
|
259
|
-
if (!
|
|
285
|
+
if (!assetIds.has(assetId))
|
|
260
286
|
throw new Error('片段引用不存在的图片');
|
|
261
287
|
}
|
|
288
|
+
}
|
|
262
289
|
const report = { revision: job.revision, sourceHash: required(job.sourceHash, '原件哈希'), issues, documents: docs.length, blocks: document.blocks.length, figures: figureResults.length, ignoredFigures: figureResults.filter(f => f.status === 'ignored').length, unreviewedFigures: figureResults.filter(f => !f.humanReviewed && f.status !== 'decoration').length, chunks: chunks.length, withinTarget: chunks.filter(c => c.fullChars <= job.config.chunkTargetChars).length, oversized: chunks.filter(c => c.oversizedReason).map(c => ({ chunkId: c.chunkId, chars: c.fullChars, reason: required(c.oversizedReason) })), usage: job.usage, targetChars: job.config.chunkTargetChars, ...document.sourceFormat === 'pdf' ? { pdf: { pageCount: required(document.pageCount), selectedPages: required(document.selectedPages), outlineCount: document.audit.outlineCount, matchedBookmarks: document.audit.matchedBookmarks, ocrPages: document.audit.pages.filter(p => p.ocr).length, pages: document.audit.pages } } : {} };
|
|
263
290
|
await atomicWrite(join(stage, 'reports/quality.json'), report);
|
|
264
291
|
await atomicWrite(join(stage, 'reports/quality.md'), `# 构建质量报告\n\n文档 ${report.documents};本次片段 ${report.chunks};目标内 ${report.withinTarget};图片 ${report.figures};已忽略 ${report.ignoredFigures};未人工核验 ${report.unreviewedFigures}。\n\n` + issues.map(i => `- ${i.blocking ? '需要处理' : '提示'}:${i.message}`).join('\n') + '\n');
|
|
265
|
-
job.
|
|
292
|
+
await saveReport(job.directory, report);
|
|
293
|
+
delete job.report;
|
|
294
|
+
job.reportSummary = summarizeReport(report);
|
|
266
295
|
await job.checkpoint();
|
|
267
296
|
const documents = docs.map(d => ({ ...d, chunks: d.chunks.map(({ content: _content, body: _body, ...chunk }) => chunk) }));
|
|
268
297
|
const manifest = { formatVersion: 1, revision: job.revision, buildKey: job.buildKey, createdAt: now(), documents, catalogs, entities, coverageIncomplete: docs.some(d => d.coverageIncomplete), config: { chunkTargetChars: job.config.chunkTargetChars }, report };
|
|
@@ -292,7 +321,7 @@ export class Jobs {
|
|
|
292
321
|
async actionUnlocked(jobId, action, options = {}) {
|
|
293
322
|
const job = this.get(jobId);
|
|
294
323
|
if (action === 'status')
|
|
295
|
-
return this.
|
|
324
|
+
return this.details(jobId);
|
|
296
325
|
if (action === 'cancel') {
|
|
297
326
|
if (job.state === 'ready')
|
|
298
327
|
throw new Error('已发布任务不能取消');
|
|
@@ -307,18 +336,18 @@ export class Jobs {
|
|
|
307
336
|
else if (action === 'retry') {
|
|
308
337
|
if (!RESUMABLE.has(job.state) || this.controllers.has(jobId))
|
|
309
338
|
throw new Error('任务尚未退出或不能重试');
|
|
339
|
+
if (options.maxModelCalls !== undefined && (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1 || options.maxModelCalls > this.config.maxModelCallsLimit))
|
|
340
|
+
throw new Error('模型调用预算超过宿主上限或无效');
|
|
341
|
+
if (options.maxModelTokens !== undefined && (!Number.isSafeInteger(options.maxModelTokens) || options.maxModelTokens < 1 || options.maxModelTokens > this.config.maxModelTokensLimit))
|
|
342
|
+
throw new Error('模型 token 预算超过宿主上限或无效');
|
|
343
|
+
job.config.maxModelCallsLimit = this.config.maxModelCallsLimit;
|
|
344
|
+
job.config.maxModelTokensLimit = this.config.maxModelTokensLimit;
|
|
310
345
|
if (options.acceptPartial === true)
|
|
311
346
|
job.config.acceptPartial = true;
|
|
312
|
-
if (options.maxModelCalls !== undefined)
|
|
313
|
-
if (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1)
|
|
314
|
-
throw new Error('模型调用预算无效');
|
|
347
|
+
if (options.maxModelCalls !== undefined)
|
|
315
348
|
job.config.maxModelCalls = options.maxModelCalls;
|
|
316
|
-
|
|
317
|
-
if (options.maxModelTokens !== undefined) {
|
|
318
|
-
if (!Number.isSafeInteger(options.maxModelTokens) || options.maxModelTokens < 1)
|
|
319
|
-
throw new Error('模型 token 预算无效');
|
|
349
|
+
if (options.maxModelTokens !== undefined)
|
|
320
350
|
job.config.maxModelTokens = options.maxModelTokens;
|
|
321
|
-
}
|
|
322
351
|
job.cancelRequested = false;
|
|
323
352
|
job.error = null;
|
|
324
353
|
await this.phase(job, 'queued');
|
|
@@ -334,7 +363,9 @@ export class Jobs {
|
|
|
334
363
|
async reviewUnlocked(kbId, revision, docId, occurrenceId, value, expectedHash, options = {}) {
|
|
335
364
|
if (Boolean(options.visionProvider) !== Boolean(options.visionModel))
|
|
336
365
|
throw new Error('visionProvider / visionModel 必须成对配置');
|
|
337
|
-
const snapshot = await this.store.revision(kbId, revision)
|
|
366
|
+
const snapshot = await this.store.revision(kbId, revision);
|
|
367
|
+
const doc = snapshot.manifest.documents.find(d => d.docId === docId);
|
|
368
|
+
const figure = doc?.figures.find(f => f.occurrenceId === occurrenceId);
|
|
338
369
|
if (!figure)
|
|
339
370
|
throw new Error('图片不存在');
|
|
340
371
|
const current = await this.store.current(kbId);
|
|
@@ -343,7 +374,8 @@ export class Jobs {
|
|
|
343
374
|
const original = await this.store.rawSource(kbId, required(doc));
|
|
344
375
|
const previous = [...this.jobs.values()].find(j => j.revision === revision && j.docId === docId);
|
|
345
376
|
const vision = options.visionProvider && options.visionModel ? { provider: options.visionProvider, model: options.visionModel } : previous?.routes.vision || required(doc).figures.find(f => f.model)?.model || (this.config.visionProvider ? { provider: this.config.visionProvider, model: this.config.visionModel } : null);
|
|
346
|
-
const overlays = await this.store.overlays(kbId)
|
|
377
|
+
const overlays = await this.store.overlays(kbId);
|
|
378
|
+
const previousOverlay = overlays[figure.overlayKey] || null;
|
|
347
379
|
if (options.restore) {
|
|
348
380
|
if (!previousOverlay?.ignore)
|
|
349
381
|
throw new Error('仅已忽略的图片可以恢复导入');
|
|
@@ -360,7 +392,8 @@ export class Jobs {
|
|
|
360
392
|
}
|
|
361
393
|
async trash(kbId, expectedName) {
|
|
362
394
|
return this.mutate(async () => {
|
|
363
|
-
const kb = await this.store.kb(kbId)
|
|
395
|
+
const kb = await this.store.kb(kbId);
|
|
396
|
+
const related = [...this.jobs.values()].filter(j => j.kbId === kb.kbId);
|
|
364
397
|
if (this.work.has(kb.kbId) || related.some(j => ACTIVE.has(j.state) || this.controllers.has(j.jobId)))
|
|
365
398
|
throw new Error('知识库仍有构建任务未退出,请先取消或等待完成后删除');
|
|
366
399
|
const removed = await this.store.trash(kb.kbId, expectedName);
|
|
@@ -373,7 +406,7 @@ export class Jobs {
|
|
|
373
406
|
return this.mutate(async () => {
|
|
374
407
|
const kb = await this.store.restore(kbId);
|
|
375
408
|
for (const dir of await this.store.jobDirectories()) {
|
|
376
|
-
const record = await
|
|
409
|
+
const record = await loadJob(dir);
|
|
377
410
|
if (record?.kbId === kb.kbId)
|
|
378
411
|
this.jobs.set(record.jobId, this.attach(record, dir));
|
|
379
412
|
}
|
|
@@ -395,7 +428,7 @@ export class Jobs {
|
|
|
395
428
|
const jobIds = related.map(j => j.jobId);
|
|
396
429
|
if (!kb.purge)
|
|
397
430
|
for (const dir of await this.store.jobDirectories()) {
|
|
398
|
-
const record = await
|
|
431
|
+
const record = await loadJob(dir);
|
|
399
432
|
if (record?.kbId !== kb.kbId)
|
|
400
433
|
continue;
|
|
401
434
|
const key = id(basename(dir), 'jobId');
|
|
@@ -417,7 +450,7 @@ export class Jobs {
|
|
|
417
450
|
else
|
|
418
451
|
await new Promise(r => setTimeout(r, 0));
|
|
419
452
|
}
|
|
420
|
-
return this.
|
|
453
|
+
return this.details(jobId);
|
|
421
454
|
}
|
|
422
455
|
stop() {
|
|
423
456
|
this.stopped = true;
|
package/lib/models.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
|
|
3
|
-
import { z } from 'zod';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
4
|
import type { Settings } from './config.ts';
|
|
5
5
|
import type { Job, ModelRoute } from './data.ts';
|
|
6
6
|
export type PreparedImage = {
|
package/lib/models.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
2
|
-
import { z } from 'zod';
|
|
3
2
|
import { atomicWrite, checkAbort, chars, message, now, readJson, tokensUsed } from "./util.js";
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
export class BudgetError extends Error {
|
|
@@ -27,13 +26,20 @@ export class Models {
|
|
|
27
26
|
const route = job.routes[kind];
|
|
28
27
|
if (!route)
|
|
29
28
|
throw new Error(`${kind} 模型不可用`);
|
|
30
|
-
if (job.usage.calls >= job.config.maxModelCalls || job.usage.tokens >= job.config.maxModelTokens)
|
|
29
|
+
if (job.usage.calls >= Math.min(job.config.maxModelCalls, this.config.maxModelCallsLimit) || job.usage.tokens >= Math.min(job.config.maxModelTokens, this.config.maxModelTokensLimit))
|
|
31
30
|
throw new BudgetError('已达到本次构建模型调用/实际 token 预算;调整预算后重试可继续');
|
|
32
|
-
const number = ++job.usage.calls
|
|
33
|
-
|
|
31
|
+
const number = ++job.usage.calls;
|
|
32
|
+
job.pendingCalls.push(number);
|
|
33
|
+
const file = join(job.directory, 'calls', `${String(number).padStart(6, '0')}.json`);
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => controller.abort(new Error('模型调用超时')), job.config.modelTimeoutMs);
|
|
34
36
|
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
35
37
|
const request = { kind, route, prompt, system, images, startedAt: now(), maxTokens: job.config.modelMaxOutputTokens };
|
|
36
|
-
let response = ''
|
|
38
|
+
let response = '';
|
|
39
|
+
let complete = '';
|
|
40
|
+
let usage;
|
|
41
|
+
let finish;
|
|
42
|
+
let accounted = false;
|
|
37
43
|
const account = () => {
|
|
38
44
|
if (accounted)
|
|
39
45
|
return;
|
|
@@ -41,8 +47,8 @@ export class Models {
|
|
|
41
47
|
job.usage.tokens += tokensUsed(usage);
|
|
42
48
|
if (usage) {
|
|
43
49
|
job.usage.reportedCalls++;
|
|
44
|
-
job.usage.records.push(z.json().parse(JSON.parse(JSON.stringify(usage))));
|
|
45
50
|
}
|
|
51
|
+
job.pendingCalls = job.pendingCalls.filter(n => n !== number);
|
|
46
52
|
};
|
|
47
53
|
try {
|
|
48
54
|
await job.checkpoint();
|
|
@@ -68,14 +74,14 @@ export class Models {
|
|
|
68
74
|
throw new Error(finish && 'failure' in finish ? finish.failure.message : `模型未正常完成:${finish?.kind ?? '缺少结束事件'}`);
|
|
69
75
|
if (!response.trim())
|
|
70
76
|
throw new Error('模型返回空内容');
|
|
71
|
-
account();
|
|
72
77
|
await atomicWrite(file, { request, state: 'done', response, usage, finishedAt: now() });
|
|
78
|
+
account();
|
|
73
79
|
await job.checkpoint();
|
|
74
80
|
return response;
|
|
75
81
|
}
|
|
76
82
|
catch (error) {
|
|
77
|
-
account();
|
|
78
83
|
await atomicWrite(file, { request, state: 'failed', response, usage, error: message(error), finishedAt: now() });
|
|
84
|
+
account();
|
|
79
85
|
await job.checkpoint();
|
|
80
86
|
throw error;
|
|
81
87
|
}
|
|
@@ -101,7 +107,7 @@ export class Models {
|
|
|
101
107
|
if (job.config.skipImages)
|
|
102
108
|
return;
|
|
103
109
|
const info = await this.resolve(job.routes.vision, 'vision', signal);
|
|
104
|
-
const
|
|
110
|
+
const data = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAQ0lEQVR4AcXBAQEAAAiAIPP/5+qFMPsISUxiEpOYxCQmMYlJTGISk5jEJCYxiUlMYhKTmMQkJjGJSUxiEpOYxCQmsQNNiAQ8n/3A9AAAAABJRU5ErkJggg==', 'base64');
|
|
105
111
|
const attachment = await this.ctx.attachments.saveImage({ data, mediaType: 'image/png', name: 'kb-vision-preflight.png' });
|
|
106
112
|
const response = await this.call(job, 'vision', '这是图片接口连通性检查。能读取图像时只返回 READY。', [{ attachment, coordinates: { kind: 'preflight' } }], signal, '检查是否实际收到图片,简短回答。');
|
|
107
113
|
if (response.trim() !== 'READY')
|
package/lib/retrieval.js
CHANGED
|
@@ -37,10 +37,12 @@ function windows(chunk, content, terms, config, options = {}) {
|
|
|
37
37
|
}
|
|
38
38
|
const ordered = [...indices].sort((a, b) => a - b);
|
|
39
39
|
const read = [];
|
|
40
|
-
let body = ''
|
|
40
|
+
let body = '';
|
|
41
|
+
let continuation = null;
|
|
41
42
|
const max = Math.max(128, Math.min(config.evidenceMaxChars - 2000, options.windowBudget || config.evidenceMaxChars - 2000));
|
|
42
43
|
for (const i of ordered) {
|
|
43
|
-
const s = required(chunk.segments[i], '语义块')
|
|
44
|
+
const s = required(chunk.segments[i], '语义块');
|
|
45
|
+
const heading = s.startLine === undefined || s.endLine === undefined ? `[${s.type} · segment ${i}]\n` : `[L${s.startLine}–L${s.endLine} · ${s.type}]\n`;
|
|
44
46
|
let value = s.text;
|
|
45
47
|
const start = options.segmentIndex === i ? options.offset || 0 : 0;
|
|
46
48
|
if (start > chars(value))
|
|
@@ -51,11 +53,31 @@ function windows(chunk, content, terms, config, options = {}) {
|
|
|
51
53
|
continuation = { segmentIndex: i, offset: 0 };
|
|
52
54
|
break;
|
|
53
55
|
}
|
|
54
|
-
const characters = Array.from(s.text)
|
|
55
|
-
const
|
|
56
|
+
const characters = Array.from(s.text);
|
|
57
|
+
const hit = terms.map(t => value.toLowerCase().indexOf(t)).filter(x => x >= 0).sort((a, b) => a - b)[0];
|
|
58
|
+
let offset;
|
|
59
|
+
if (options.segmentIndex !== undefined) {
|
|
60
|
+
offset = start;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
if (hit === undefined) {
|
|
64
|
+
offset = 0;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
offset = Math.max(0, chars(value.slice(0, hit)) - 250);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
56
70
|
const available = max - chars(heading) - 50;
|
|
57
71
|
value = clip(characters.slice(offset).join(''), available);
|
|
58
|
-
continuation =
|
|
72
|
+
continuation = (() => {
|
|
73
|
+
if (offset + chars(value) < characters.length) {
|
|
74
|
+
return { segmentIndex: i, offset: offset + chars(value) };
|
|
75
|
+
}
|
|
76
|
+
if (i + 1 < chunk.segments.length) {
|
|
77
|
+
return { segmentIndex: i + 1, offset: 0 };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
})();
|
|
59
81
|
read.push({ segmentIndex: i, startLine: s.startLine, endLine: s.endLine, charOffset: offset, partial: true });
|
|
60
82
|
body += heading + value + '\n[此语义块仅返回部分,请按 continuation 续读]\n';
|
|
61
83
|
break;
|
|
@@ -76,7 +98,8 @@ export class Retrieval {
|
|
|
76
98
|
}
|
|
77
99
|
async catalog(args = {}) {
|
|
78
100
|
if (!args.kbId) {
|
|
79
|
-
const kbs = await this.store.list()
|
|
101
|
+
const kbs = await this.store.list();
|
|
102
|
+
const entries = [];
|
|
80
103
|
for (const kb of kbs) {
|
|
81
104
|
const current = await this.store.current(kb.kbId);
|
|
82
105
|
entries.push({ ...kb, revision: current?.revision || null, coverageIncomplete: current?.coverageIncomplete || false });
|
|
@@ -88,7 +111,7 @@ export class Retrieval {
|
|
|
88
111
|
}
|
|
89
112
|
return bounded(value, this.config.catalogMaxChars);
|
|
90
113
|
}
|
|
91
|
-
const
|
|
114
|
+
const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
|
|
92
115
|
const catalogId = args.docId ? `${id(args.docId)}-${args.nodeId ? id(args.nodeId) : 'root'}` : 'root';
|
|
93
116
|
const catalog = snapshot.manifest.catalogs[catalogId];
|
|
94
117
|
if (!catalog)
|
|
@@ -102,14 +125,17 @@ export class Retrieval {
|
|
|
102
125
|
return bounded(value, this.config.catalogMaxChars + 1000);
|
|
103
126
|
}
|
|
104
127
|
async search(args) {
|
|
105
|
-
const
|
|
106
|
-
const terms = queryTerms(args.query, args.keywords)
|
|
128
|
+
const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
|
|
129
|
+
const terms = queryTerms(args.query, args.keywords);
|
|
130
|
+
const scored = [];
|
|
107
131
|
for (const doc of snapshot.manifest.documents) {
|
|
108
132
|
if (args.docId && doc.docId !== args.docId)
|
|
109
133
|
continue;
|
|
110
134
|
for (const chunk of doc.chunks) {
|
|
111
|
-
const content = await this.store.
|
|
112
|
-
const hits = terms.filter(t => body.includes(t))
|
|
135
|
+
const { content, lower: body } = await this.store.chunkView(snapshot, chunk);
|
|
136
|
+
const hits = terms.filter(t => body.includes(t));
|
|
137
|
+
const title = chunk.headingPath.join('/').toLowerCase();
|
|
138
|
+
const keys = (chunk.keywords || []).map(k => k.toLowerCase());
|
|
113
139
|
if (!hits.length)
|
|
114
140
|
continue;
|
|
115
141
|
const score = hits.reduce((n, t) => n + (keys.includes(t) ? 12 : 0) + (title.includes(t) ? 6 : 0) + Math.min(5, body.split(t).length - 1), 0);
|
|
@@ -122,7 +148,9 @@ export class Retrieval {
|
|
|
122
148
|
throw new Error('limit 必须为 1–30');
|
|
123
149
|
const value = { kbId: kb.kbId, revision: snapshot.revision, query: args.query, terms, candidates: [], evidence: [], warnings: [], truncated: scored.length > limit, totalCandidates: scored.length, continuation: [] };
|
|
124
150
|
const top = scored.slice(0, limit);
|
|
125
|
-
for (const { doc, chunk, score, hits
|
|
151
|
+
for (const { doc, chunk, score, hits } of top) {
|
|
152
|
+
// Cached scoring is navigation only; returned evidence is always reread and hash-verified.
|
|
153
|
+
const content = await this.store.chunkContent(snapshot, chunk);
|
|
126
154
|
const candidate = { chunkId: chunk.chunkId, score, matchedTerms: hits, title: chunk.title, summary: chunk.summary, fullChars: chunk.fullChars, reviewStatus: chunk.reviewStatus, source: source(required(doc), chunk, snapshot.revision) };
|
|
127
155
|
value.candidates.push(candidate);
|
|
128
156
|
const remaining = this.config.evidenceMaxChars - chars(JSON.stringify(value)) - 800;
|
|
@@ -145,10 +173,13 @@ export class Retrieval {
|
|
|
145
173
|
break;
|
|
146
174
|
}
|
|
147
175
|
}
|
|
176
|
+
await this.store.kb(kb.kbId); // Recheck visibility after potentially long scoring/evidence reads.
|
|
148
177
|
return bounded(value, this.config.evidenceMaxChars);
|
|
149
178
|
}
|
|
150
179
|
async read(args) {
|
|
151
|
-
const kb = await this.store.kb(args.kbId)
|
|
180
|
+
const kb = await this.store.kb(args.kbId);
|
|
181
|
+
const snapshot = await this.store.readChunk(kb.kbId, args.revision, args.chunkId);
|
|
182
|
+
await this.store.kb(kb.kbId);
|
|
152
183
|
return this.readSnapshot(kb.kbId, snapshot, args);
|
|
153
184
|
}
|
|
154
185
|
readSnapshot(kbId, snapshot, args) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Manifest } from './data.ts';
|
|
2
|
+
/** Bounded hot revision payloads. Every lookup checks file identity; evidence reads bypass reuse. */
|
|
3
|
+
export declare class RevisionCache {
|
|
4
|
+
readonly maxBytes: number;
|
|
5
|
+
readonly maxEntries: number;
|
|
6
|
+
private entries;
|
|
7
|
+
bytes: number;
|
|
8
|
+
reads: number;
|
|
9
|
+
hits: number;
|
|
10
|
+
parses: number;
|
|
11
|
+
get size(): number;
|
|
12
|
+
constructor(maxBytes?: number, maxEntries?: number);
|
|
13
|
+
clear(): void;
|
|
14
|
+
private drop;
|
|
15
|
+
private put;
|
|
16
|
+
private stamp;
|
|
17
|
+
private lookup;
|
|
18
|
+
private read;
|
|
19
|
+
manifest(file: string): Promise<Manifest>;
|
|
20
|
+
chunk(file: string, digest: string, fresh?: boolean): Promise<{
|
|
21
|
+
content: string;
|
|
22
|
+
lower: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { stat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { manifestSchema } from "./data.js";
|
|
3
|
+
import { hash } from "./util.js";
|
|
4
|
+
function freeze(value) { if (value && typeof value === 'object') {
|
|
5
|
+
for (const child of Object.values(value))
|
|
6
|
+
freeze(child);
|
|
7
|
+
Object.freeze(value);
|
|
8
|
+
} return value; }
|
|
9
|
+
/** Bounded hot revision payloads. Every lookup checks file identity; evidence reads bypass reuse. */
|
|
10
|
+
export class RevisionCache {
|
|
11
|
+
maxBytes;
|
|
12
|
+
maxEntries;
|
|
13
|
+
entries = new Map();
|
|
14
|
+
bytes = 0;
|
|
15
|
+
reads = 0;
|
|
16
|
+
hits = 0;
|
|
17
|
+
parses = 0;
|
|
18
|
+
get size() { return this.entries.size; }
|
|
19
|
+
constructor(maxBytes = 32 * 1024 * 1024, maxEntries = 256) {
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
this.maxEntries = maxEntries;
|
|
22
|
+
}
|
|
23
|
+
clear() { this.entries.clear(); this.bytes = 0; }
|
|
24
|
+
drop(file) { const old = this.entries.get(file); if (old)
|
|
25
|
+
this.bytes -= old.cost; this.entries.delete(file); }
|
|
26
|
+
put(file, entry) {
|
|
27
|
+
this.drop(file);
|
|
28
|
+
if (entry.cost > this.maxBytes || this.maxEntries < 1)
|
|
29
|
+
return;
|
|
30
|
+
while (this.entries.size >= this.maxEntries || this.bytes + entry.cost > this.maxBytes) {
|
|
31
|
+
const first = this.entries.keys().next().value;
|
|
32
|
+
if (first === undefined)
|
|
33
|
+
break;
|
|
34
|
+
this.drop(first);
|
|
35
|
+
}
|
|
36
|
+
this.entries.set(file, entry);
|
|
37
|
+
this.bytes += entry.cost;
|
|
38
|
+
}
|
|
39
|
+
async stamp(file) {
|
|
40
|
+
const info = await stat(file, { bigint: true });
|
|
41
|
+
if (!info.isFile())
|
|
42
|
+
throw new Error('版本文件必须是普通文件');
|
|
43
|
+
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
|
|
44
|
+
}
|
|
45
|
+
async lookup(file) {
|
|
46
|
+
let stamp;
|
|
47
|
+
try {
|
|
48
|
+
stamp = await this.stamp(file);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.drop(file);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const entry = this.entries.get(file);
|
|
55
|
+
if (entry?.stamp === stamp) {
|
|
56
|
+
this.drop(file);
|
|
57
|
+
this.entries.set(file, entry);
|
|
58
|
+
this.bytes += entry.cost;
|
|
59
|
+
return { stamp, entry };
|
|
60
|
+
}
|
|
61
|
+
this.drop(file);
|
|
62
|
+
return { stamp, entry: undefined };
|
|
63
|
+
}
|
|
64
|
+
async read(file, stamp) {
|
|
65
|
+
this.reads++;
|
|
66
|
+
const content = await readFile(file, 'utf8');
|
|
67
|
+
if (await this.stamp(file) !== stamp) {
|
|
68
|
+
this.drop(file);
|
|
69
|
+
throw new Error('版本文件读取期间变化,请重试');
|
|
70
|
+
}
|
|
71
|
+
return content;
|
|
72
|
+
}
|
|
73
|
+
async manifest(file) {
|
|
74
|
+
const { stamp, entry } = await this.lookup(file);
|
|
75
|
+
if (entry?.kind === 'manifest') {
|
|
76
|
+
this.hits++;
|
|
77
|
+
return entry.value;
|
|
78
|
+
}
|
|
79
|
+
const raw = await this.read(file, stamp);
|
|
80
|
+
this.parses++;
|
|
81
|
+
const value = freeze(manifestSchema.parse(JSON.parse(raw)));
|
|
82
|
+
this.put(file, { kind: 'manifest', stamp, value, cost: raw.length * 8 + 512 });
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
async chunk(file, digest, fresh = false) {
|
|
86
|
+
const { stamp, entry } = await this.lookup(file);
|
|
87
|
+
if (!fresh && entry?.kind === 'chunk' && entry.digest === digest) {
|
|
88
|
+
this.hits++;
|
|
89
|
+
return { content: entry.value, lower: entry.lower };
|
|
90
|
+
}
|
|
91
|
+
const content = await this.read(file, stamp);
|
|
92
|
+
if (hash(content) !== digest) {
|
|
93
|
+
this.drop(file);
|
|
94
|
+
throw new Error('片段内容与清单不一致,请重建');
|
|
95
|
+
}
|
|
96
|
+
const lower = content.toLowerCase();
|
|
97
|
+
this.put(file, { kind: 'chunk', stamp, value: content, lower, digest, cost: (content.length + lower.length) * 2 + 512 });
|
|
98
|
+
return { content, lower };
|
|
99
|
+
}
|
|
100
|
+
}
|