@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -2
  3. package/lib/agent-access.d.ts +23 -0
  4. package/lib/agent-access.js +28 -0
  5. package/lib/chunks.js +27 -5
  6. package/lib/client-types/client/JobDetails.d.ts +16 -0
  7. package/lib/client-types/client/JobsPanel.d.ts +6 -1
  8. package/lib/client-types/client/QualityReport.d.ts +3 -2
  9. package/lib/client-types/client/api.d.ts +83 -52
  10. package/lib/client-types/client/locales.d.ts +2 -0
  11. package/lib/client-types/client/useKnowledge.d.ts +30 -15
  12. package/lib/client-types/contract.d.ts +83 -52
  13. package/lib/client-types/data.d.ts +11 -0
  14. package/lib/client-types/settings.d.ts +4 -0
  15. package/lib/client-types/shared.d.ts +17 -0
  16. package/lib/client.js +40 -40
  17. package/lib/config.js +9 -1
  18. package/lib/contract.d.ts +33 -2
  19. package/lib/contract.js +2 -1
  20. package/lib/data.d.ts +11 -0
  21. package/lib/data.js +2 -0
  22. package/lib/document/convert.js +52 -16
  23. package/lib/document/pdf-layout.js +45 -10
  24. package/lib/document/pdf-session.js +10 -4
  25. package/lib/document/pdf-vision.js +8 -3
  26. package/lib/document/pdf-worker.js +76 -21
  27. package/lib/document/pdf.js +39 -9
  28. package/lib/document/zip.js +14 -6
  29. package/lib/figures/image-client.d.ts +5 -0
  30. package/lib/figures/image-client.js +63 -0
  31. package/lib/figures/image-gate.d.ts +12 -0
  32. package/lib/figures/image-gate.js +39 -0
  33. package/lib/figures/image-protocol.d.ts +19 -0
  34. package/lib/figures/image-protocol.js +9 -0
  35. package/lib/figures/image-worker.d.ts +1 -0
  36. package/lib/figures/image-worker.js +69 -0
  37. package/lib/figures/process.d.ts +1 -1
  38. package/lib/figures/process.js +19 -38
  39. package/lib/index.js +6 -2
  40. package/lib/indexes.js +17 -6
  41. package/lib/job-storage.d.ts +264 -0
  42. package/lib/job-storage.js +118 -0
  43. package/lib/jobs.d.ts +782 -1489
  44. package/lib/jobs.js +64 -31
  45. package/lib/models.d.ts +1 -1
  46. package/lib/models.js +15 -9
  47. package/lib/retrieval.js +44 -13
  48. package/lib/revision-cache.d.ts +24 -0
  49. package/lib/revision-cache.js +100 -0
  50. package/lib/routes.js +66 -20
  51. package/lib/runtime.d.ts +4 -0
  52. package/lib/runtime.js +15 -4
  53. package/lib/settings.d.ts +4 -0
  54. package/lib/settings.js +5 -2
  55. package/lib/shared.d.ts +17 -0
  56. package/lib/shared.js +3 -0
  57. package/lib/skills.js +6 -3
  58. package/lib/store.d.ts +101 -5
  59. package/lib/store.js +78 -29
  60. package/lib/tools.js +12 -4
  61. package/lib/util.js +2 -1
  62. 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 readJson(join(dir, 'job.json'), jobSchema, null);
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
- return { ...record, directory, cacheDir: join(this.store.root, 'cache'), checkpoint: async () => {
69
- const snapshot = jobSchema.parse(JSON.parse(JSON.stringify(this.jobs.get(record.jobId) || record)));
70
- const pending = tail.then(() => atomicWrite(join(directory, 'job.json'), snapshot));
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) { return [...this.jobs.values()].filter(j => !kbId || j.kbId === kbId).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).map(j => this.public(j)); }
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, filename;
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), old = current ? (await this.store.revision(kb.kbId, current.revision)).manifest.documents.find(d => d.docId === docId) : null;
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), schemaHash = hash(schema);
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()}`, directory = join(this.store.root, 'jobs', jobId);
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.report ?? { 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)) }] };
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'), sourceDir = join(stage, 'sources', job.docId);
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
- for (const doc of docs)
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 (!Object.values(catalogs).some(c => c.pages.some(p => p.entries.some(e => e.chunkId === chunk.chunkId))))
282
+ if (!indexedChunks.has(chunk.chunkId))
257
283
  throw new Error('索引遗漏片段');
258
284
  for (const assetId of chunk.assetRefs)
259
- if (!doc.assets.some(a => a.assetId === assetId))
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.report = report;
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.public(job);
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), doc = snapshot.manifest.documents.find(d => d.docId === docId), figure = doc?.figures.find(f => f.occurrenceId === occurrenceId);
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), previousOverlay = overlays[figure.overlayKey] || null;
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), related = [...this.jobs.values()].filter(j => j.kbId === 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 readJson(join(dir, 'job.json'), jobSchema, null);
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 readJson(await safePath(dir, 'job.json', false), jobSchema, null);
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.public(this.get(jobId));
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, file = join(job.directory, 'calls', `${String(number).padStart(6, '0')}.json`);
33
- const controller = new AbortController(), timeout = setTimeout(() => controller.abort(new Error('模型调用超时')), job.config.modelTimeoutMs);
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 = '', complete = '', usage, finish, accounted = false;
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 { Jimp } = await import('jimp'), data = await new Jimp({ width: 32, height: 32, color: 0xffffffff }).getBuffer('image/png');
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 = '', continuation = null;
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], '语义块'), heading = s.startLine === undefined || s.endLine === undefined ? `[${s.type} · segment ${i}]\n` : `[L${s.startLine}–L${s.endLine} · ${s.type}]\n`;
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), hit = terms.map(t => value.toLowerCase().indexOf(t)).filter(x => x >= 0).sort((a, b) => a - b)[0];
55
- const offset = options.segmentIndex !== undefined ? start : (hit === undefined ? 0 : Math.max(0, chars(value.slice(0, hit)) - 250));
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 = offset + chars(value) < characters.length ? { segmentIndex: i, offset: offset + chars(value) } : i + 1 < chunk.segments.length ? { segmentIndex: i + 1, offset: 0 } : null;
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(), entries = [];
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 kb = await this.store.kb(args.kbId), snapshot = await this.store.revision(kb.kbId, args.revision);
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 kb = await this.store.kb(args.kbId), snapshot = await this.store.revision(kb.kbId, args.revision);
106
- const terms = queryTerms(args.query, args.keywords), scored = [];
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.chunkContent(snapshot, chunk), body = content.toLowerCase();
112
- const hits = terms.filter(t => body.includes(t)), title = chunk.headingPath.join('/').toLowerCase(), keys = (chunk.keywords || []).map(k => k.toLowerCase());
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, content } of top) {
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), snapshot = await this.store.readChunk(kb.kbId, args.revision, args.chunkId);
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
+ }