@dshtrading/knowledge 0.6.1 → 0.6.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.
@@ -1,19 +1,19 @@
1
1
  import { canonicalizeCardAuthor } from "./authors.js";
2
- import { readFile } from "node:fs/promises";
3
- import { SKIP_WRITE, transactStore } from "@dshtrading/dsh-home";
2
+ import { SKIP_WRITE, createRecordStoreAdapter, readStoreText } from "@dshtrading/dsh-home";
4
3
  //#region src/knowledge-fs.ts
5
4
  /**
6
5
  * 文件持久化版知识卡片存储(Node.js 宿主端专用)。
7
6
  *
8
- * 整表持久化经 `@dshtrading/dsh-home` 的 {@link transactStore}——跨进程排他锁 +
9
- * 锁内新鲜读盘 + 原子写(2026-10-09 多进程互相覆盖事故修复)。读取面作者归一与坏
10
- * 文件降级纪律不变(逐行对齐 custom-fs.ts 先例)。
7
+ * 读写骨架经 `@dshtrading/dsh-home` 的 {@link createRecordStoreAdapter}——跨进程排他锁 +
8
+ * 锁内新鲜读盘 + 原子写 + 落盘成功后才发布缓存(2026-10-09 多进程互相覆盖事故修复)。
9
+ * 读取面作者归一与坏文件降级纪律不变(逐行对齐 custom-fs.ts 先例)。
11
10
  */
12
11
  const LOG_PREFIX = "[dsh-trading/knowledge] failed to atomic flush knowledge cards to";
13
12
  function createFileKnowledgeCardStore(filePath) {
14
- let cache = null;
13
+ /** 读盘/解析失败的日志前缀(坏 JSON 与非 ENOENT 读失败同一条,与既有 load 口径逐字一致)。 */
14
+ const READ_FAILED = `[dsh-trading/knowledge] failed to read knowledge cards from ${filePath}:`;
15
15
  /**
16
- * 新鲜读盘(作者别名归一、坏 JSON 降级空表);供 load 与锁内重读共用。
16
+ * 文本 → 卡片表(作者别名归一、坏 JSON 降级空表)。
17
17
  *
18
18
  * 作者别名在读取时归一(authors.ts):历史数据即刻在 GUI/检索层表现为规范名。
19
19
  * 读本身不写盘,但归一结果留在缓存里,会被下一次写操作的全表回写落盘——
@@ -21,10 +21,10 @@ function createFileKnowledgeCardStore(filePath) {
21
21
  * 缺 source / 缺 id 的脏行原样保留(不丢数据);结构性校验只在入库发生,
22
22
  * 但这类行仍会让 getByUrl/search/graph 的解引用抛错(既有边界,未在此收口)。
23
23
  */
24
- async function readFromDisk() {
24
+ function decode(text) {
25
+ if (text === void 0) return /* @__PURE__ */ new Map();
25
26
  try {
26
- const content = await readFile(filePath, "utf8");
27
- const parsed = JSON.parse(content);
27
+ const parsed = JSON.parse(text);
28
28
  const map = /* @__PURE__ */ new Map();
29
29
  if (Array.isArray(parsed)) {
30
30
  let healed = 0;
@@ -37,40 +37,45 @@ function createFileKnowledgeCardStore(filePath) {
37
37
  }
38
38
  return map;
39
39
  } catch (err) {
40
- if (err?.code !== "ENOENT") console.error(`[dsh-trading/knowledge] failed to read knowledge cards from ${filePath}:`, err);
40
+ console.error(READ_FAILED, err);
41
41
  return /* @__PURE__ */ new Map();
42
42
  }
43
43
  }
44
- async function load() {
45
- if (cache !== null) return cache;
46
- cache = await readFromDisk();
47
- return cache;
48
- }
49
- async function commit(mutate) {
50
- cache = await transactStore(filePath, readFromDisk, mutate, (table) => [...table.values()], LOG_PREFIX);
51
- return cache;
52
- }
44
+ const store = createRecordStoreAdapter({
45
+ filePath,
46
+ logPrefix: LOG_PREFIX,
47
+ readFromDisk: () => readStoreText(filePath, READ_FAILED),
48
+ decode,
49
+ encode: (table) => [...table.values()]
50
+ });
53
51
  return {
54
52
  async list() {
55
- return [...(await load()).values()];
53
+ return [...(await store.load()).values()];
56
54
  },
57
55
  async get(id) {
58
- return (await load()).get(id);
56
+ return (await store.load()).get(id);
59
57
  },
60
58
  async getByUrl(url) {
61
- const map = await load();
59
+ const map = await store.load();
62
60
  const trimmed = url.trim();
63
61
  for (const card of map.values()) if (card.source.url.trim() === trimmed) return card;
64
62
  },
65
63
  async save(card) {
66
- await commit((onDisk) => {
64
+ await store.commit((onDisk) => {
67
65
  onDisk.set(card.id, canonicalizeCardAuthor({ ...card }));
68
66
  return onDisk;
69
67
  });
70
68
  },
69
+ async saveMany(cards) {
70
+ if (cards.length === 0) return;
71
+ await store.commit((onDisk) => {
72
+ for (const card of cards) onDisk.set(card.id, canonicalizeCardAuthor({ ...card }));
73
+ return onDisk;
74
+ });
75
+ },
71
76
  async delete(id) {
72
77
  let existed = false;
73
- await commit((onDisk) => {
78
+ await store.commit((onDisk) => {
74
79
  existed = onDisk.delete(id);
75
80
  return existed ? onDisk : SKIP_WRITE;
76
81
  });
@@ -17,6 +17,9 @@ function createMemoryKnowledgeCardStore(initialCards = []) {
17
17
  async save(card) {
18
18
  map.set(card.id, canonicalizeCardAuthor({ ...card }));
19
19
  },
20
+ async saveMany(cards) {
21
+ for (const card of cards) map.set(card.id, canonicalizeCardAuthor({ ...card }));
22
+ },
20
23
  async delete(id) {
21
24
  return map.delete(id);
22
25
  }
package/lib/tool.js CHANGED
@@ -26,6 +26,19 @@ function renderCardFullLines(card) {
26
26
  if (card.tickers && card.tickers.length > 0) lines.push(` 关联标的: ${card.tickers.join(", ")}`);
27
27
  return lines;
28
28
  }
29
+ /** 时间字段的可读回显:空值/缺省一律写「未知」,不留空位冒充有日期。 */
30
+ function shownTimestamp(value) {
31
+ return value !== void 0 && value.trim().length > 0 ? value : "未知";
32
+ }
33
+ /** 解析显式时效筛选的 ISO 日期:空值表示不筛,非法值抛错由工具回显而不是静默当无命中。 */
34
+ function parseCutoff(value, field) {
35
+ if (typeof value !== "string") return void 0;
36
+ const trimmed = value.trim();
37
+ if (trimmed.length === 0) return void 0;
38
+ const ms = Date.parse(trimmed);
39
+ if (Number.isNaN(ms)) throw new Error(`[knowledge_search] 参数 ${field} 不是合法日期: ${trimmed}`);
40
+ return ms;
41
+ }
29
42
  function createKnowledgeIngestTool(store, options = {}) {
30
43
  const { onWritten } = options;
31
44
  return defineTool({
@@ -67,35 +80,35 @@ function createKnowledgeIngestTool(store, options = {}) {
67
80
  description: "Overall fact-check grade: \"high\" | \"medium\" | \"low\"."
68
81
  },
69
82
  coreClaimsJson: {
70
- type: "string",
83
+ type: "json",
71
84
  required: true,
72
- description: "JSON string array of core claims, preserving the author's reasoning chain, e.g. '[\"claim1\", \"claim2\"]'."
85
+ description: "JSON array of core claims (also accepted as a JSON string), preserving the author's reasoning chain, e.g. '[\"claim1\", \"claim2\"]'."
73
86
  },
74
87
  factCheckJson: {
75
- type: "string",
88
+ type: "json",
76
89
  required: true,
77
- description: "JSON object with the three fact-check buckets, e.g. '{\"verified\":[\"...\"],\"discrepancies\":[\"...\"],\"unverifiable\":[]}'."
90
+ description: "JSON object with the three fact-check buckets (also accepted as a JSON string), e.g. '{\"verified\":[\"...\"],\"discrepancies\":[\"...\"],\"unverifiable\":[]}'."
78
91
  },
79
92
  takeawaysJson: {
80
- type: "string",
81
- description: "Optional JSON string array of reusable analysis frameworks and lessons."
93
+ type: "json",
94
+ description: "Optional JSON array of reusable analysis frameworks and lessons (also accepted as a JSON string)."
82
95
  },
83
96
  boundariesJson: {
84
- type: "string",
85
- description: "Optional JSON string array of applicability boundaries, invalidation cases and pitfalls."
97
+ type: "json",
98
+ description: "Optional JSON array of applicability boundaries, invalidation cases and pitfalls (also accepted as a JSON string)."
86
99
  },
87
100
  tagsJson: {
88
- type: "string",
101
+ type: "json",
89
102
  required: true,
90
- description: "JSON string array of controlled topic tags, e.g. '[\"宏观\", \"高股息\", \"红利策略\"]'."
103
+ description: "JSON array of controlled topic tags (also accepted as a JSON string), e.g. '[\"宏观\", \"高股息\", \"红利策略\"]'."
91
104
  },
92
105
  tickersJson: {
93
- type: "string",
94
- description: "Optional JSON string array of related instrument codes, e.g. '[\"BTCUSDT\", \"600519.SH\"]'."
106
+ type: "json",
107
+ description: "Optional JSON array of related instrument codes (also accepted as a JSON string), e.g. '[\"BTCUSDT\", \"600519.SH\"]'."
95
108
  },
96
109
  relatedJson: {
97
- type: "string",
98
- description: "Optional JSON string array of related card ids already in the library, e.g. '[\"kc_01j...\", \"kc_01k...\"]'."
110
+ type: "json",
111
+ description: "Optional JSON array of related card ids already in the library (also accepted as a JSON string), e.g. '[\"kc_01j...\", \"kc_01k...\"]'."
99
112
  }
100
113
  },
101
114
  output: {
@@ -196,7 +209,7 @@ function createKnowledgeIngestTool(store, options = {}) {
196
209
  function createKnowledgeSearchTool(store) {
197
210
  return defineTool({
198
211
  name: "knowledge_search",
199
- description: "Search knowledge cards in the local library. With a keyword the results rank by field hit weight (tags > title > core claims > summary/author); without one they come newest-updated first. Filters: cluster (the graph clustering key, pair with knowledge_graph for two-stage retrieval), author, source kind and credibility. detail=\"full\" also returns core claims, the fact-check buckets and reusable lessons (capped at 20 cards).",
212
+ description: "Search knowledge cards in the local library. With a keyword the results rank by field hit weight (tags > title > core claims > summary/author); without one they come newest-updated first. Filters: cluster (the graph clustering key, pair with knowledge_graph for two-stage retrieval), author, source kind, credibility and explicit freshness cutoffs updatedAfter / publishedAfter. Every result reports its updatedAt and source publish date (unknown is stated as such); cards are never dropped or downgraded by age unless an explicit cutoff asks for it. detail=\"full\" also returns core claims, the fact-check buckets and reusable lessons (capped at 20 cards).",
200
213
  parameters: {
201
214
  query: {
202
215
  type: "string",
@@ -222,6 +235,14 @@ function createKnowledgeSearchTool(store) {
222
235
  type: "string",
223
236
  description: "Credibility filter: \"high\" | \"medium\" | \"low\"."
224
237
  },
238
+ updatedAfter: {
239
+ type: "string",
240
+ description: "Explicit freshness cutoff: keep only cards updated at or after this ISO date/datetime (e.g. \"2026-01-01\" or \"2026-01-01T00:00:00Z\"). Cards with an unparsable updatedAt never match. Omitting it applies no age filter."
241
+ },
242
+ publishedAfter: {
243
+ type: "string",
244
+ description: "Explicit freshness cutoff on the source publish date: keep only cards whose source.publishedAt is at or after this ISO date. Cards without a source publish date never match an explicit cutoff (unknown is not assumed fresh)."
245
+ },
225
246
  limit: {
226
247
  type: "number",
227
248
  description: "Maximum results to return (default 20).",
@@ -249,12 +270,29 @@ function createKnowledgeSearchTool(store) {
249
270
  const targetCluster = typeof params.cluster === "string" ? params.cluster.trim().toLowerCase() : "";
250
271
  const targetSourceType = typeof params.sourceType === "string" ? params.sourceType.trim() : void 0;
251
272
  const targetCredibility = typeof params.credibility === "string" ? params.credibility.trim() : void 0;
273
+ let updatedAfterMs;
274
+ let publishedAfterMs;
275
+ try {
276
+ updatedAfterMs = parseCutoff(params.updatedAfter, "updatedAfter");
277
+ publishedAfterMs = parseCutoff(params.publishedAfter, "publishedAfter");
278
+ } catch (error) {
279
+ return error instanceof Error ? error.message : String(error);
280
+ }
252
281
  const limit = typeof params.limit === "number" && Number.isFinite(params.limit) ? Math.max(1, Math.min(params.limit, 100)) : 20;
253
282
  const detail = params.detail === "full" ? "full" : "summary";
254
283
  const effectiveLimit = detail === "full" ? Math.min(limit, 20) : limit;
255
284
  const matched = allCards.filter((card) => {
256
285
  if (targetSourceType && card.source.type !== targetSourceType) return false;
257
286
  if (targetCredibility && card.credibility !== targetCredibility) return false;
287
+ if (updatedAfterMs !== void 0) {
288
+ const updatedAtMs = Date.parse(card.updatedAt);
289
+ if (Number.isNaN(updatedAtMs) || updatedAtMs < updatedAfterMs) return false;
290
+ }
291
+ if (publishedAfterMs !== void 0) {
292
+ const publishedAt = card.source.publishedAt;
293
+ const publishedAtMs = publishedAt === void 0 ? NaN : Date.parse(publishedAt);
294
+ if (Number.isNaN(publishedAtMs) || publishedAtMs < publishedAfterMs) return false;
295
+ }
258
296
  if (targetCluster && (card.tags[0] ?? "").trim().toLowerCase() !== targetCluster) return false;
259
297
  if (authorLower && !card.source.author.toLowerCase().includes(authorLower)) return false;
260
298
  if (rawTags.length > 0) {
@@ -290,6 +328,7 @@ function createKnowledgeSearchTool(store) {
290
328
  const lines = [`[knowledge_search] 匹配到 ${matched.length} 张卡片 (展示前 ${results.length} 项${detail === "full" ? ", detail=full" : ""}):`];
291
329
  for (const card of results) {
292
330
  lines.push(`- [${card.id}] "${card.title}" (${card.source.type} @ ${card.source.author}, 可信度: ${card.credibility})`);
331
+ lines.push(` 时间: 更新 ${shownTimestamp(card.updatedAt)} | 发布 ${shownTimestamp(card.source.publishedAt)}`);
293
332
  lines.push(` 摘要: ${card.summary}`);
294
333
  lines.push(` 标签: ${card.tags.join(", ")} | 链接: ${card.source.url}`);
295
334
  if (detail === "full") lines.push(...renderCardFullLines(card));
@@ -364,15 +403,20 @@ function createKnowledgeDeleteTool(store, options = {}) {
364
403
  const target = await store.get(id);
365
404
  if (!target) return `[knowledge_delete] 未找到卡片 [${id}],请先用 knowledge_search 确认卡片 id`;
366
405
  const referencing = [];
406
+ const cleaned = [];
367
407
  for (const card of await store.list()) if (card.related && card.related.includes(id)) {
368
408
  const remaining = card.related.filter((r) => r !== id);
369
409
  referencing.push(card.id);
370
- await store.save({
410
+ cleaned.push({
371
411
  ...card,
372
412
  related: remaining.length > 0 ? remaining : void 0,
373
413
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
374
414
  });
375
415
  }
416
+ if (cleaned.length > 0) {
417
+ if (typeof store.saveMany === "function") await store.saveMany(cleaned);
418
+ else for (const card of cleaned) await store.save(card);
419
+ }
376
420
  if (!await store.delete(id)) return `[knowledge_delete] 删除失败:卡片 [${id}] 在删除时已不存在`;
377
421
  onWritten?.();
378
422
  const lines = [`[knowledge_delete] 已删除卡片 [${target.id}] "${target.title}"`, ` 核心论点(留痕回显): ${target.coreClaims.join(";")}`];
package/lib/types.d.ts CHANGED
@@ -73,6 +73,14 @@ interface KnowledgeCardStore {
73
73
  get(id: string): Promise<KnowledgeCard | undefined>;
74
74
  getByUrl(url: string): Promise<KnowledgeCard | undefined>;
75
75
  save(card: KnowledgeCard): Promise<void>;
76
+ /**
77
+ * 批量写入(可选能力):最终状态等同于按序逐张 save,但实现可为一次事务。
78
+ *
79
+ * 存在的理由:文件版 store 的 save 是整表事务(跨进程排他锁 + 锁内读盘 + 序列化 +
80
+ * 原子写),knowledge_delete 清理 F 张引用方就是 F+1 次整表落盘。调用方在能力存在时
81
+ * 必须走本方法,缺席(轻量/第三方实现)则逐张 save 降级——正确性两版一致,只差落盘次数。
82
+ */
83
+ saveMany?(cards: readonly KnowledgeCard[]): Promise<void>;
76
84
  delete(id: string): Promise<boolean>;
77
85
  }
78
86
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dshtrading/knowledge",
3
3
  "description": "Knowledge base data plane for dsh-trading: KnowledgeCard data model, validation, graph builder, atomic file store, and knowledge_ingest / knowledge_search agent tools.",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -25,12 +25,11 @@
25
25
  ],
26
26
  "license": "PolyForm-Noncommercial-1.0.0",
27
27
  "dependencies": {
28
- "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
29
- "@deepseek-ai/schemastery": "^3.18.4",
30
- "@dshtrading/dsh-home": "^0.6.1"
28
+ "@dshtrading/dsh-home": "^0.6.3"
31
29
  },
32
30
  "peerDependencies": {
33
- "@deepseek-ai/cordis": ">=4.0.0"
31
+ "@deepseek-ai/cordis": ">=4.0.0",
32
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2"
34
33
  },
35
34
  "devDependencies": {
36
35
  "@types/node": "^24.0.0",