@dsh-libing/knowledge-base 0.1.0 → 0.1.3
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 +16 -0
- package/README.md +8 -2
- 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/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/figures/image-client.d.ts +5 -0
- package/lib/figures/image-client.js +58 -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 +60 -0
- package/lib/figures/process.d.ts +1 -1
- package/lib/figures/process.js +6 -35
- package/lib/indexes.js +9 -2
- package/lib/job-storage.d.ts +264 -0
- package/lib/job-storage.js +115 -0
- package/lib/jobs.d.ts +782 -1489
- package/lib/jobs.js +47 -23
- package/lib/models.d.ts +1 -1
- package/lib/models.js +8 -7
- package/lib/retrieval.js +8 -4
- package/lib/revision-cache.d.ts +24 -0
- package/lib/revision-cache.js +100 -0
- package/lib/routes.js +10 -7
- package/lib/runtime.d.ts +4 -0
- package/lib/runtime.js +6 -1
- 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/store.d.ts +101 -5
- package/lib/store.js +54 -20
- 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), { 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)
|
|
@@ -198,7 +217,7 @@ export class Jobs {
|
|
|
198
217
|
const { nodes, chunks } = createChunks(document, info, job.config);
|
|
199
218
|
for (const chunk of chunks)
|
|
200
219
|
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
|
|
220
|
+
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
221
|
}
|
|
203
222
|
return { root, manifest, revision: required(job.revision), kbId: job.kbId, jobId };
|
|
204
223
|
}
|
|
@@ -249,20 +268,25 @@ export class Jobs {
|
|
|
249
268
|
const { catalogs, entities } = await writeIndexes(stage, docs, job.config, job.revision);
|
|
250
269
|
await this.phase(job, 'validating');
|
|
251
270
|
checkAbort(signal);
|
|
252
|
-
|
|
271
|
+
const indexedChunks = new Set(Object.values(catalogs).flatMap(c => c.pages.flatMap(p => p.entries.flatMap(e => e.chunkId ? [e.chunkId] : []))));
|
|
272
|
+
for (const doc of docs) {
|
|
273
|
+
const assetIds = new Set(doc.assets.map(a => a.assetId));
|
|
253
274
|
for (const chunk of doc.chunks) {
|
|
254
275
|
if (hash(await readFile(await safePath(stage, chunk.path), 'utf8')) !== chunk.contentHash)
|
|
255
276
|
throw new Error('生成片段哈希不一致');
|
|
256
|
-
if (!
|
|
277
|
+
if (!indexedChunks.has(chunk.chunkId))
|
|
257
278
|
throw new Error('索引遗漏片段');
|
|
258
279
|
for (const assetId of chunk.assetRefs)
|
|
259
|
-
if (!
|
|
280
|
+
if (!assetIds.has(assetId))
|
|
260
281
|
throw new Error('片段引用不存在的图片');
|
|
261
282
|
}
|
|
283
|
+
}
|
|
262
284
|
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
285
|
await atomicWrite(join(stage, 'reports/quality.json'), report);
|
|
264
286
|
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.
|
|
287
|
+
await saveReport(job.directory, report);
|
|
288
|
+
delete job.report;
|
|
289
|
+
job.reportSummary = summarizeReport(report);
|
|
266
290
|
await job.checkpoint();
|
|
267
291
|
const documents = docs.map(d => ({ ...d, chunks: d.chunks.map(({ content: _content, body: _body, ...chunk }) => chunk) }));
|
|
268
292
|
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 +316,7 @@ export class Jobs {
|
|
|
292
316
|
async actionUnlocked(jobId, action, options = {}) {
|
|
293
317
|
const job = this.get(jobId);
|
|
294
318
|
if (action === 'status')
|
|
295
|
-
return this.
|
|
319
|
+
return this.details(jobId);
|
|
296
320
|
if (action === 'cancel') {
|
|
297
321
|
if (job.state === 'ready')
|
|
298
322
|
throw new Error('已发布任务不能取消');
|
|
@@ -307,18 +331,18 @@ export class Jobs {
|
|
|
307
331
|
else if (action === 'retry') {
|
|
308
332
|
if (!RESUMABLE.has(job.state) || this.controllers.has(jobId))
|
|
309
333
|
throw new Error('任务尚未退出或不能重试');
|
|
334
|
+
if (options.maxModelCalls !== undefined && (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1 || options.maxModelCalls > this.config.maxModelCallsLimit))
|
|
335
|
+
throw new Error('模型调用预算超过宿主上限或无效');
|
|
336
|
+
if (options.maxModelTokens !== undefined && (!Number.isSafeInteger(options.maxModelTokens) || options.maxModelTokens < 1 || options.maxModelTokens > this.config.maxModelTokensLimit))
|
|
337
|
+
throw new Error('模型 token 预算超过宿主上限或无效');
|
|
338
|
+
job.config.maxModelCallsLimit = this.config.maxModelCallsLimit;
|
|
339
|
+
job.config.maxModelTokensLimit = this.config.maxModelTokensLimit;
|
|
310
340
|
if (options.acceptPartial === true)
|
|
311
341
|
job.config.acceptPartial = true;
|
|
312
|
-
if (options.maxModelCalls !== undefined)
|
|
313
|
-
if (!Number.isSafeInteger(options.maxModelCalls) || options.maxModelCalls < 1)
|
|
314
|
-
throw new Error('模型调用预算无效');
|
|
342
|
+
if (options.maxModelCalls !== undefined)
|
|
315
343
|
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 预算无效');
|
|
344
|
+
if (options.maxModelTokens !== undefined)
|
|
320
345
|
job.config.maxModelTokens = options.maxModelTokens;
|
|
321
|
-
}
|
|
322
346
|
job.cancelRequested = false;
|
|
323
347
|
job.error = null;
|
|
324
348
|
await this.phase(job, 'queued');
|
|
@@ -373,7 +397,7 @@ export class Jobs {
|
|
|
373
397
|
return this.mutate(async () => {
|
|
374
398
|
const kb = await this.store.restore(kbId);
|
|
375
399
|
for (const dir of await this.store.jobDirectories()) {
|
|
376
|
-
const record = await
|
|
400
|
+
const record = await loadJob(dir);
|
|
377
401
|
if (record?.kbId === kb.kbId)
|
|
378
402
|
this.jobs.set(record.jobId, this.attach(record, dir));
|
|
379
403
|
}
|
|
@@ -395,7 +419,7 @@ export class Jobs {
|
|
|
395
419
|
const jobIds = related.map(j => j.jobId);
|
|
396
420
|
if (!kb.purge)
|
|
397
421
|
for (const dir of await this.store.jobDirectories()) {
|
|
398
|
-
const record = await
|
|
422
|
+
const record = await loadJob(dir);
|
|
399
423
|
if (record?.kbId !== kb.kbId)
|
|
400
424
|
continue;
|
|
401
425
|
const key = id(basename(dir), 'jobId');
|
|
@@ -417,7 +441,7 @@ export class Jobs {
|
|
|
417
441
|
else
|
|
418
442
|
await new Promise(r => setTimeout(r, 0));
|
|
419
443
|
}
|
|
420
|
-
return this.
|
|
444
|
+
return this.details(jobId);
|
|
421
445
|
}
|
|
422
446
|
stop() {
|
|
423
447
|
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,9 +26,11 @@ 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
|
|
31
|
+
const number = ++job.usage.calls;
|
|
32
|
+
job.pendingCalls.push(number);
|
|
33
|
+
const file = join(job.directory, 'calls', `${String(number).padStart(6, '0')}.json`);
|
|
33
34
|
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(new Error('模型调用超时')), job.config.modelTimeoutMs);
|
|
34
35
|
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
35
36
|
const request = { kind, route, prompt, system, images, startedAt: now(), maxTokens: job.config.modelMaxOutputTokens };
|
|
@@ -41,8 +42,8 @@ export class Models {
|
|
|
41
42
|
job.usage.tokens += tokensUsed(usage);
|
|
42
43
|
if (usage) {
|
|
43
44
|
job.usage.reportedCalls++;
|
|
44
|
-
job.usage.records.push(z.json().parse(JSON.parse(JSON.stringify(usage))));
|
|
45
45
|
}
|
|
46
|
+
job.pendingCalls = job.pendingCalls.filter(n => n !== number);
|
|
46
47
|
};
|
|
47
48
|
try {
|
|
48
49
|
await job.checkpoint();
|
|
@@ -68,14 +69,14 @@ export class Models {
|
|
|
68
69
|
throw new Error(finish && 'failure' in finish ? finish.failure.message : `模型未正常完成:${finish?.kind ?? '缺少结束事件'}`);
|
|
69
70
|
if (!response.trim())
|
|
70
71
|
throw new Error('模型返回空内容');
|
|
71
|
-
account();
|
|
72
72
|
await atomicWrite(file, { request, state: 'done', response, usage, finishedAt: now() });
|
|
73
|
+
account();
|
|
73
74
|
await job.checkpoint();
|
|
74
75
|
return response;
|
|
75
76
|
}
|
|
76
77
|
catch (error) {
|
|
77
|
-
account();
|
|
78
78
|
await atomicWrite(file, { request, state: 'failed', response, usage, error: message(error), finishedAt: now() });
|
|
79
|
+
account();
|
|
79
80
|
await job.checkpoint();
|
|
80
81
|
throw error;
|
|
81
82
|
}
|
|
@@ -101,7 +102,7 @@ export class Models {
|
|
|
101
102
|
if (job.config.skipImages)
|
|
102
103
|
return;
|
|
103
104
|
const info = await this.resolve(job.routes.vision, 'vision', signal);
|
|
104
|
-
const
|
|
105
|
+
const data = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAQ0lEQVR4AcXBAQEAAAiAIPP/5+qFMPsISUxiEpOYxCQmMYlJTGISk5jEJCYxiUlMYhKTmMQkJjGJSUxiEpOYxCQmsQNNiAQ8n/3A9AAAAABJRU5ErkJggg==', 'base64');
|
|
105
106
|
const attachment = await this.ctx.attachments.saveImage({ data, mediaType: 'image/png', name: 'kb-vision-preflight.png' });
|
|
106
107
|
const response = await this.call(job, 'vision', '这是图片接口连通性检查。能读取图像时只返回 READY。', [{ attachment, coordinates: { kind: 'preflight' } }], signal, '检查是否实际收到图片,简短回答。');
|
|
107
108
|
if (response.trim() !== 'READY')
|
package/lib/retrieval.js
CHANGED
|
@@ -88,7 +88,7 @@ export class Retrieval {
|
|
|
88
88
|
}
|
|
89
89
|
return bounded(value, this.config.catalogMaxChars);
|
|
90
90
|
}
|
|
91
|
-
const
|
|
91
|
+
const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
|
|
92
92
|
const catalogId = args.docId ? `${id(args.docId)}-${args.nodeId ? id(args.nodeId) : 'root'}` : 'root';
|
|
93
93
|
const catalog = snapshot.manifest.catalogs[catalogId];
|
|
94
94
|
if (!catalog)
|
|
@@ -102,13 +102,13 @@ export class Retrieval {
|
|
|
102
102
|
return bounded(value, this.config.catalogMaxChars + 1000);
|
|
103
103
|
}
|
|
104
104
|
async search(args) {
|
|
105
|
-
const
|
|
105
|
+
const { kb, snapshot } = await this.store.open(args.kbId, args.revision);
|
|
106
106
|
const terms = queryTerms(args.query, args.keywords), scored = [];
|
|
107
107
|
for (const doc of snapshot.manifest.documents) {
|
|
108
108
|
if (args.docId && doc.docId !== args.docId)
|
|
109
109
|
continue;
|
|
110
110
|
for (const chunk of doc.chunks) {
|
|
111
|
-
const content = await this.store.
|
|
111
|
+
const { content, lower: body } = await this.store.chunkView(snapshot, chunk);
|
|
112
112
|
const hits = terms.filter(t => body.includes(t)), title = chunk.headingPath.join('/').toLowerCase(), keys = (chunk.keywords || []).map(k => k.toLowerCase());
|
|
113
113
|
if (!hits.length)
|
|
114
114
|
continue;
|
|
@@ -122,7 +122,9 @@ export class Retrieval {
|
|
|
122
122
|
throw new Error('limit 必须为 1–30');
|
|
123
123
|
const value = { kbId: kb.kbId, revision: snapshot.revision, query: args.query, terms, candidates: [], evidence: [], warnings: [], truncated: scored.length > limit, totalCandidates: scored.length, continuation: [] };
|
|
124
124
|
const top = scored.slice(0, limit);
|
|
125
|
-
for (const { doc, chunk, score, hits
|
|
125
|
+
for (const { doc, chunk, score, hits } of top) {
|
|
126
|
+
// Cached scoring is navigation only; returned evidence is always reread and hash-verified.
|
|
127
|
+
const content = await this.store.chunkContent(snapshot, chunk);
|
|
126
128
|
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
129
|
value.candidates.push(candidate);
|
|
128
130
|
const remaining = this.config.evidenceMaxChars - chars(JSON.stringify(value)) - 800;
|
|
@@ -145,10 +147,12 @@ export class Retrieval {
|
|
|
145
147
|
break;
|
|
146
148
|
}
|
|
147
149
|
}
|
|
150
|
+
await this.store.kb(kb.kbId); // Recheck visibility after potentially long scoring/evidence reads.
|
|
148
151
|
return bounded(value, this.config.evidenceMaxChars);
|
|
149
152
|
}
|
|
150
153
|
async read(args) {
|
|
151
154
|
const kb = await this.store.kb(args.kbId), snapshot = await this.store.readChunk(kb.kbId, args.revision, args.chunkId);
|
|
155
|
+
await this.store.kb(kb.kbId);
|
|
152
156
|
return this.readSnapshot(kb.kbId, snapshot, args);
|
|
153
157
|
}
|
|
154
158
|
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
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { API, browserBuildSchema, jobActionSchema, catalogQuerySchema, readQuerySchema, searchSchema, assetQuerySchema, revisionQuerySchema } from "./shared.js";
|
|
4
|
+
import { API, jobListQuerySchema, jobDetailsQuerySchema, jobUsageQuerySchema, browserBuildSchema, jobActionSchema, catalogQuerySchema, readQuerySchema, searchSchema, assetQuerySchema, revisionQuerySchema } from "./shared.js";
|
|
5
5
|
import { figureDataSchema, overlaySchema } from "./data.js";
|
|
6
6
|
import { inspectPdf } from "./document/pdf-session.js";
|
|
7
7
|
import { hash, safePath, message, required, fileBytes, checkAbort } from "./util.js";
|
|
@@ -69,16 +69,19 @@ export function routes(runtime, ctx) {
|
|
|
69
69
|
throw new Error('PDF 原件不存在于所选版本中');
|
|
70
70
|
return binary((await store.rawSource(snapshot.kbId, doc)).data, 'application/pdf');
|
|
71
71
|
};
|
|
72
|
-
const image = async (asset, file) => {
|
|
72
|
+
const image = async (asset, file, verified) => {
|
|
73
73
|
if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/tiff'].includes(asset.mime))
|
|
74
74
|
throw new Error('不支持浏览此图片格式,请提供 PNG/JPEG 预览');
|
|
75
|
-
const data = await fileBytes(file, config.maxUncompressedBytes);
|
|
76
|
-
if (`a-${hash(data).slice(0, 20)}` !== asset.assetId)
|
|
75
|
+
const data = verified ?? await fileBytes(file, config.maxUncompressedBytes);
|
|
76
|
+
if (!verified && `a-${hash(data).slice(0, 20)}` !== asset.assetId)
|
|
77
77
|
throw new Error('图片内容与清单不一致');
|
|
78
78
|
return binary(data, asset.mime);
|
|
79
79
|
};
|
|
80
80
|
const tree = (snapshot, kbId) => ({ kbId, revision: snapshot.revision, coverageIncomplete: snapshot.manifest.coverageIncomplete, report: snapshot.manifest.report, documents: snapshot.manifest.documents.map(doc => ({ ...doc, chunks: doc.chunks.map(({ segments: _segments, anchors: _anchors, ...chunk }) => chunk) })) });
|
|
81
|
-
add('list', 'GET', async (request) => {
|
|
81
|
+
add('list', 'GET', async (request) => { const args = jobListQuerySchema.parse(query(request)); if (args.jobsKbId)
|
|
82
|
+
await store.kb(args.jobsKbId); return ({ hostVersion: HOST_VERSION, ...await store.overview(), ...jobs.list(args.jobsKbId, args.jobOffset, args.jobLimit), dataRoot: store.root, config: { chunkTargetChars: config.chunkTargetChars, visionProvider: config.visionProvider, visionModel: config.visionModel, maxInputBytes: config.maxInputBytes } }); });
|
|
83
|
+
add('job-details', 'GET', async (request) => { const args = jobDetailsQuerySchema.parse(query(request)); return jobs.details(args.jobId, args.section === 'report'); });
|
|
84
|
+
add('job-usage', 'GET', async (request) => { const args = jobUsageQuerySchema.parse(query(request)); return jobs.usage(args.jobId, args.offset, args.limit, request.signal); });
|
|
82
85
|
add('create', 'POST', async (request) => store.create((await body(request, z.strictObject({ name }))).name));
|
|
83
86
|
add('delete', 'POST', async (request) => { const args = await body(request, managementArgs); return jobs.trash(args.kbId, args.confirmName); });
|
|
84
87
|
add('restore', 'POST', async (request) => jobs.restore((await body(request, libraryArgs)).kbId));
|
|
@@ -107,10 +110,10 @@ export function routes(runtime, ctx) {
|
|
|
107
110
|
});
|
|
108
111
|
add('draft-asset', 'GET', async (request) => { const args = draftAssetQuery.parse(query(request)), snapshot = await jobs.preview(args.jobId), doc = required(snapshot.manifest.documents.find(d => d.docId === args.docId), '草稿文档'), asset = required(doc.assets.find(a => a.assetId === args.assetId), '草稿图片'); return image(asset, await safePath(snapshot.root, join('sources', doc.docId, asset.path))); });
|
|
109
112
|
add('catalog', 'GET', async (request) => retrieval.catalog(catalogQuerySchema.parse(query(request))));
|
|
110
|
-
add('tree', 'GET', async (request) => { const args = revisionQuerySchema.parse(query(request)),
|
|
113
|
+
add('tree', 'GET', async (request) => { const args = revisionQuerySchema.parse(query(request)), { kb, snapshot } = await store.open(args.kbId, args.revision), overlays = await store.overlays(kb.kbId), value = tree(snapshot, kb.kbId); return { ...value, location: await store.snapshotLocation(kb.kbId, snapshot), documents: value.documents.map(doc => ({ ...doc, figures: doc.figures.map(figure => ({ ...figure, expectedHash: hash(overlays[figure.overlayKey] || null) })) })) }; });
|
|
111
114
|
add('read', 'GET', async (request) => retrieval.read(readQuerySchema.parse(query(request))));
|
|
112
115
|
add('search', 'POST', async (request) => retrieval.search(await body(request, searchSchema)));
|
|
113
|
-
add('asset', 'GET', async (request) => { const args = assetQuerySchema.parse(query(request)), kb = await store.kb(args.kbId), asset = await store.asset(kb.kbId, args.revision, args.docId, args.assetId); return image(asset.asset, asset.file); });
|
|
116
|
+
add('asset', 'GET', async (request) => { const args = assetQuerySchema.parse(query(request)), kb = await store.kb(args.kbId), asset = await store.asset(kb.kbId, args.revision, args.docId, args.assetId); return image(asset.asset, asset.file, asset.data); });
|
|
114
117
|
add('schema', 'GET', async (request) => { const kb = await store.kb(libraryArgs.parse(query(request)).kbId), content = await store.schema(kb.kbId); return { content, hash: hash(content) }; });
|
|
115
118
|
add('schema', 'POST', async (request) => { const args = await body(request, libraryArgs.extend({ content: z.string().max(20000), expectedHash: digest })), kb = await store.kb(args.kbId); return store.setSchema(kb.kbId, args.content, args.expectedHash); });
|
|
116
119
|
add('review', 'POST', async (request) => {
|
package/lib/runtime.d.ts
CHANGED
|
@@ -18,8 +18,12 @@ export declare function createRuntime(ctx: Context, raw?: Config): {
|
|
|
18
18
|
modelTimeoutMs: number;
|
|
19
19
|
modelAttempts: number;
|
|
20
20
|
visionConcurrency: number;
|
|
21
|
+
maxModelCallsLimit: number;
|
|
22
|
+
maxModelTokensLimit: number;
|
|
23
|
+
uploadRetentionDays: number;
|
|
21
24
|
maxModelCalls: number;
|
|
22
25
|
maxModelTokens: number;
|
|
26
|
+
imageTimeoutMs: number;
|
|
23
27
|
visionMaxEdge: number;
|
|
24
28
|
visionTileSize: number;
|
|
25
29
|
visionMaxTiles: number;
|
package/lib/runtime.js
CHANGED
package/lib/settings.d.ts
CHANGED
|
@@ -11,8 +11,12 @@ export declare const settingsSchema: z.ZodObject<{
|
|
|
11
11
|
modelTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
12
12
|
modelAttempts: z.ZodDefault<z.ZodNumber>;
|
|
13
13
|
visionConcurrency: z.ZodDefault<z.ZodNumber>;
|
|
14
|
+
maxModelCallsLimit: z.ZodDefault<z.ZodNumber>;
|
|
15
|
+
maxModelTokensLimit: z.ZodDefault<z.ZodNumber>;
|
|
16
|
+
uploadRetentionDays: z.ZodDefault<z.ZodNumber>;
|
|
14
17
|
maxModelCalls: z.ZodDefault<z.ZodNumber>;
|
|
15
18
|
maxModelTokens: z.ZodDefault<z.ZodNumber>;
|
|
19
|
+
imageTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
16
20
|
visionMaxEdge: z.ZodDefault<z.ZodNumber>;
|
|
17
21
|
visionTileSize: z.ZodDefault<z.ZodNumber>;
|
|
18
22
|
visionMaxTiles: z.ZodDefault<z.ZodNumber>;
|
package/lib/settings.js
CHANGED
|
@@ -6,13 +6,16 @@ export const settingsSchema = z.strictObject({
|
|
|
6
6
|
catalogMaxChars: positive.min(512).default(6000), candidateLimit: positive.max(30).default(6),
|
|
7
7
|
maxInputBytes: positive.default(64 * 1024 * 1024), maxUncompressedBytes: positive.default(256 * 1024 * 1024), maxArchiveEntries: positive.default(10000),
|
|
8
8
|
modelTimeoutMs: positive.default(90000), modelAttempts: positive.default(3), visionConcurrency: positive.max(8).default(2),
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
maxModelCallsLimit: positive.max(100000).default(10000), maxModelTokensLimit: positive.max(1000000000).default(20000000), uploadRetentionDays: positive.max(365).default(7),
|
|
10
|
+
maxModelCalls: positive.max(100000).default(1000), maxModelTokens: positive.max(1000000000).default(2000000), imageTimeoutMs: positive.max(120000).default(60000), visionMaxEdge: positive.max(4096).default(2048),
|
|
11
|
+
visionTileSize: positive.max(4096).default(1536), visionMaxTiles: positive.max(64).default(12), modelMaxOutputTokens: positive.default(4096),
|
|
11
12
|
summaryMaxChars: positive.default(160), figureMaxChars: positive.default(6000), pdfMaxPages: positive.default(2000),
|
|
12
13
|
pdfMaxPagePixels: positive.default(12000000), pdfPageTimeoutMs: positive.default(60000),
|
|
13
14
|
libreOfficePath: z.string().default(''), visionProvider: z.string().default(''), visionModel: z.string().default(''),
|
|
14
15
|
summaryProvider: z.string().default(''), summaryModel: z.string().default(''), skipImages: z.boolean().default(false), acceptPartial: z.boolean().default(false),
|
|
15
16
|
}).superRefine((value, context) => {
|
|
17
|
+
if (value.maxModelCalls > value.maxModelCallsLimit || value.maxModelTokens > value.maxModelTokensLimit)
|
|
18
|
+
context.addIssue({ code: 'custom', message: '默认任务预算不能超过宿主预算上限' });
|
|
16
19
|
for (const kind of ['vision', 'summary'])
|
|
17
20
|
if (Boolean(value[`${kind}Provider`]) !== Boolean(value[`${kind}Model`]))
|
|
18
21
|
context.addIssue({ code: 'custom', message: `${kind}Provider / ${kind}Model 必须成对配置` });
|
package/lib/shared.d.ts
CHANGED
|
@@ -93,3 +93,20 @@ export declare const revisionQuerySchema: z.ZodObject<{
|
|
|
93
93
|
kbId: z.ZodString;
|
|
94
94
|
revision: z.ZodOptional<z.ZodString>;
|
|
95
95
|
}, z.core.$strict>;
|
|
96
|
+
export declare const jobListQuerySchema: z.ZodObject<{
|
|
97
|
+
jobsKbId: z.ZodOptional<z.ZodString>;
|
|
98
|
+
jobOffset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
99
|
+
jobLimit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
100
|
+
}, z.core.$strict>;
|
|
101
|
+
export declare const jobDetailsQuerySchema: z.ZodObject<{
|
|
102
|
+
jobId: z.ZodString;
|
|
103
|
+
section: z.ZodDefault<z.ZodEnum<{
|
|
104
|
+
report: "report";
|
|
105
|
+
details: "details";
|
|
106
|
+
}>>;
|
|
107
|
+
}, z.core.$strict>;
|
|
108
|
+
export declare const jobUsageQuerySchema: z.ZodObject<{
|
|
109
|
+
jobId: z.ZodString;
|
|
110
|
+
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
111
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
112
|
+
}, z.core.$strict>;
|
package/lib/shared.js
CHANGED
|
@@ -15,3 +15,6 @@ export const searchSchema = z.strictObject({ kbId: selector, revision: id.option
|
|
|
15
15
|
export const readQuerySchema = z.strictObject({ kbId: selector, revision: id.optional(), chunkId: id, query: z.string().max(2000).optional(), keywords: keywordsSchema.optional(), segmentIndex: z.coerce.number().int().nonnegative().optional(), offset: z.coerce.number().int().nonnegative().optional() });
|
|
16
16
|
export const assetQuerySchema = z.strictObject({ kbId: selector, revision: id.optional(), docId: id, assetId: id });
|
|
17
17
|
export const revisionQuerySchema = z.strictObject({ kbId: selector, revision: id.optional() });
|
|
18
|
+
export const jobListQuerySchema = z.strictObject({ jobsKbId: id.optional(), jobOffset: z.coerce.number().int().nonnegative().max(1000000).default(0), jobLimit: z.coerce.number().int().min(1).max(50).default(20) });
|
|
19
|
+
export const jobDetailsQuerySchema = z.strictObject({ jobId: id, section: z.enum(['details', 'report']).default('details') });
|
|
20
|
+
export const jobUsageQuerySchema = jobDetailsQuerySchema.omit({ section: true }).extend({ offset: z.coerce.number().int().nonnegative().max(1000000).default(0), limit: z.coerce.number().int().min(1).max(50).default(20) });
|