dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.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.
Files changed (117) hide show
  1. package/README.md +31 -7
  2. package/cordis.patch.yml +11 -5
  3. package/lib/client.js +5557 -3056
  4. package/lib/doc-page.js +7740 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +36 -9
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/diagnosis/cards.d.ts +8 -7
  9. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  10. package/lib/types/diagnosis/cards.js +119 -49
  11. package/lib/types/diagnosis/cards.js.map +1 -1
  12. package/lib/types/diagnosis/run.d.ts +51 -0
  13. package/lib/types/diagnosis/run.d.ts.map +1 -0
  14. package/lib/types/diagnosis/run.js +239 -0
  15. package/lib/types/diagnosis/run.js.map +1 -0
  16. package/lib/types/diagnosis/schema.d.ts +25 -2
  17. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  18. package/lib/types/diagnosis/schema.js +64 -25
  19. package/lib/types/diagnosis/schema.js.map +1 -1
  20. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  21. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  22. package/lib/types/diagnosis/seed-source.js +18 -1
  23. package/lib/types/diagnosis/seed-source.js.map +1 -1
  24. package/lib/types/diagnosis/seed.d.ts +32 -42
  25. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  26. package/lib/types/diagnosis/seed.js +187 -51
  27. package/lib/types/diagnosis/seed.js.map +1 -1
  28. package/lib/types/diagnosis/store.d.ts +114 -26
  29. package/lib/types/diagnosis/store.d.ts.map +1 -1
  30. package/lib/types/diagnosis/store.js +336 -167
  31. package/lib/types/diagnosis/store.js.map +1 -1
  32. package/lib/types/diagnosis/types.d.ts +168 -16
  33. package/lib/types/diagnosis/types.d.ts.map +1 -1
  34. package/lib/types/diagnosis/types.js +1 -1
  35. package/lib/types/diagnosis/types.js.map +1 -1
  36. package/lib/types/document-page.d.ts +39 -0
  37. package/lib/types/document-page.d.ts.map +1 -0
  38. package/lib/types/document-page.js +43 -0
  39. package/lib/types/document-page.js.map +1 -0
  40. package/lib/types/fs-atomic.d.ts +12 -0
  41. package/lib/types/fs-atomic.d.ts.map +1 -0
  42. package/lib/types/fs-atomic.js +42 -0
  43. package/lib/types/fs-atomic.js.map +1 -0
  44. package/lib/types/index.d.ts.map +1 -1
  45. package/lib/types/index.js +214 -24
  46. package/lib/types/index.js.map +1 -1
  47. package/lib/types/knowledge-bases.d.ts +10 -2
  48. package/lib/types/knowledge-bases.d.ts.map +1 -1
  49. package/lib/types/knowledge-bases.js +20 -18
  50. package/lib/types/knowledge-bases.js.map +1 -1
  51. package/lib/types/proposals.d.ts +166 -0
  52. package/lib/types/proposals.d.ts.map +1 -0
  53. package/lib/types/proposals.js +238 -0
  54. package/lib/types/proposals.js.map +1 -0
  55. package/lib/types/runtime.d.ts +199 -7
  56. package/lib/types/runtime.d.ts.map +1 -1
  57. package/lib/types/runtime.js +728 -145
  58. package/lib/types/runtime.js.map +1 -1
  59. package/lib/types/settings-schema.d.ts +2 -2
  60. package/lib/types/types.d.ts +61 -10
  61. package/lib/types/types.d.ts.map +1 -1
  62. package/lib/types/types.js +56 -8
  63. package/lib/types/types.js.map +1 -1
  64. package/lib/types/wiki-types.d.ts +8 -1
  65. package/lib/types/wiki-types.d.ts.map +1 -1
  66. package/lib/types/wiki-types.js +16 -25
  67. package/lib/types/wiki-types.js.map +1 -1
  68. package/package.json +4 -3
  69. package/preset/tita-data-agent/agent.cordis.yml +11 -5
  70. package/src/client/DirectoryPicker.tsx +12 -12
  71. package/src/client/InfoHint.tsx +21 -8
  72. package/src/client/IntentTab.tsx +20 -26
  73. package/src/client/KnowledgeBaseTab.tsx +190 -80
  74. package/src/client/KnowledgePanel.tsx +42 -9
  75. package/src/client/KnowledgeStepRail.tsx +3 -0
  76. package/src/client/NodeSettings.tsx +20 -22
  77. package/src/client/PreviewSections.tsx +87 -33
  78. package/src/client/ProposalReview.tsx +313 -0
  79. package/src/client/TypeDefinitionDrawer.tsx +72 -38
  80. package/src/client/WikiTypeEditor.tsx +193 -77
  81. package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
  82. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  83. package/src/client/arch/model.ts +164 -26
  84. package/src/client/architecture.css +29 -9
  85. package/src/client/architecture.tsx +54 -29
  86. package/src/client/attribution/AttributionGraph.tsx +481 -34
  87. package/src/client/attribution/AttributionTab.tsx +507 -106
  88. package/src/client/attribution/CaseView.tsx +9 -14
  89. package/src/client/attribution/GraphStage.tsx +25 -18
  90. package/src/client/attribution/RunView.tsx +27 -9
  91. package/src/client/attribution/StageChrome.tsx +296 -140
  92. package/src/client/attribution/graph-options.ts +611 -47
  93. package/src/client/attribution/model.ts +559 -30
  94. package/src/client/convert/ConvertPanel.tsx +6 -1
  95. package/src/client/doc-page.css +134 -0
  96. package/src/client/doc-page.tsx +263 -0
  97. package/src/client/panel-state.ts +105 -0
  98. package/src/client/request.ts +36 -0
  99. package/src/client/styles.css +317 -43
  100. package/src/client/useFitScale.ts +15 -2
  101. package/src/client/wiki-type-groups.ts +86 -0
  102. package/src/convert/host.ts +41 -7
  103. package/src/diagnosis/cards.ts +120 -50
  104. package/src/diagnosis/run.ts +245 -0
  105. package/src/diagnosis/schema.ts +67 -26
  106. package/src/diagnosis/seed-source.ts +28 -3
  107. package/src/diagnosis/seed.ts +187 -85
  108. package/src/diagnosis/store.ts +358 -174
  109. package/src/diagnosis/types.ts +181 -16
  110. package/src/document-page.ts +51 -0
  111. package/src/fs-atomic.ts +42 -0
  112. package/src/index.ts +209 -22
  113. package/src/knowledge-bases.ts +20 -18
  114. package/src/proposals.ts +337 -0
  115. package/src/runtime.ts +736 -135
  116. package/src/types.ts +113 -19
  117. package/src/wiki-types.ts +17 -27
@@ -156,7 +156,7 @@ function send(res: ServerResponse, status: number, body: unknown): void {
156
156
  }
157
157
 
158
158
  /** Compact task view for tools — the full ingest payload never enters context. */
159
- function taskView(task: ConvertTask): Record<string, unknown> {
159
+ function taskView(kernel: ConvertService, task: ConvertTask): Record<string, unknown> {
160
160
  const manifest = task.manifest
161
161
  return {
162
162
  taskId: task.id,
@@ -171,6 +171,8 @@ function taskView(task: ConvertTask): Record<string, unknown> {
171
171
  error: task.error ?? null,
172
172
  warnings: task.warnings.map(warning => warning.code),
173
173
  attempts: task.attempts,
174
+ /** 与服务端 retry() 的守卫同源(`canRetryTask`):Agent 不该去点一枚注定失败的按钮。 */
175
+ canRetry: kernel.canRetry(task).ok,
174
176
  deliveryDir: task.deliveryDir,
175
177
  assetCount: manifest?.assets.length ?? 0,
176
178
  wordCount: manifest?.wordCount ?? 0,
@@ -307,10 +309,24 @@ export function applyConvert(ctx: Context, deps: ConvertHostDeps): ConvertHost {
307
309
  config = { ...DEFAULT_CONVERT_CONFIG, ...readConfig() }
308
310
  void resetService()
309
311
 
312
+ /**
313
+ * 写操作要用户点头。
314
+ *
315
+ * `convert_task` 是**一个混合工具**:`list` / `get` / `markdown` / `manifest` 只读,
316
+ * `cancel` / `retry` 会改队列。所以它不能整只进 `WRITE_TOOLS`(那会让「看一眼队列」
317
+ * 也要审批),也不能整只留在外面 —— 那样一个自称只读的工具就能取消别人的任务。
318
+ * 判据落在 `action` 上。
319
+ */
310
320
  ctx.on('tools/pre-execute', (exec, next): Promise<PreToolDecision> => {
311
321
  if ((WRITE_TOOLS as readonly string[]).includes(exec.name)) {
312
322
  return Promise.resolve({ kind: 'ask', reason: '该操作会转换本地内容,并写入 Tita Knowledge Store,需要本轮用户审批。' })
313
323
  }
324
+ if (exec.name === 'convert_task') {
325
+ const action = (exec.arguments as { action?: unknown } | undefined)?.action
326
+ if (action === 'cancel' || action === 'retry') {
327
+ return Promise.resolve({ kind: 'ask', reason: `convert_task 的 ${String(action)} 会改动转换队列,需要本轮用户审批。` })
328
+ }
329
+ }
314
330
  return next()
315
331
  })
316
332
 
@@ -402,7 +418,7 @@ export function applyConvert(ctx: Context, deps: ConvertHostDeps): ConvertHost {
402
418
  const submitted = await kernel.submit(request)
403
419
  const shouldWait = args.wait !== false
404
420
  const finished = shouldWait ? await waitForTask(kernel, submitted.id, config.toolWaitMs) : submitted
405
- const view = taskView(finished ?? submitted)
421
+ const view = taskView(kernel, finished ?? submitted)
406
422
  return success({
407
423
  ...view,
408
424
  waited: shouldWait,
@@ -413,7 +429,7 @@ export function applyConvert(ctx: Context, deps: ConvertHostDeps): ConvertHost {
413
429
 
414
430
  ctx.tools.register(tool(
415
431
  'convert_task',
416
- 'Inspect the conversion queue: list, get, cancel, retry, or read the delivered Markdown/manifest of a task. Read-only.',
432
+ 'Inspect and steer the conversion queue. list / get / markdown / manifest are read-only; cancel and retry change the queue (both need user approval). retry only works on a failed or cancelled task whose staged input is still in tmp/ — a delivered task cannot be retried, because its input is cleaned up when it is delivered; submit the file again instead.',
417
433
  {
418
434
  action: { type: 'string', enum: ['list', 'get', 'cancel', 'retry', 'markdown', 'manifest'], required: true },
419
435
  task_id: { type: 'string' },
@@ -425,12 +441,30 @@ export function applyConvert(ctx: Context, deps: ConvertHostDeps): ConvertHost {
425
441
  const action = String(args.action)
426
442
  if (action === 'list') {
427
443
  const limit = Number.isFinite(args.limit) ? Math.max(1, Math.min(100, Number(args.limit))) : 20
428
- return success({ tasks: kernel.list().slice(0, limit).map(taskView) })
444
+ return success({ tasks: kernel.list().slice(0, limit).map(task => taskView(kernel, task)) })
429
445
  }
430
446
  const taskId = typeof args.task_id === 'string' ? args.task_id.trim() : ''
431
447
  if (!taskId) return failure({ taskId: null }, 'TASK_ID_REQUIRED', `${action} 需要 task_id。`)
432
- if (action === 'cancel') return success(taskView(kernel.cancel(taskId)))
433
- if (action === 'retry') return success(taskView(await kernel.retry(taskId)))
448
+ /*
449
+ * `cancel` / `retry` **会改队列**,所以它们不把 `AdapterError` 抛出去让宿主兜,
450
+ * 而是把 code 与 suggestion 如实放进 envelope:Agent 读到的应该是「为什么不能」
451
+ * 与「那该怎么办」(例如:已交付的任务要**重新提交文件**,而不是重试 ——
452
+ * 它的输入在交付后就清理了)。
453
+ */
454
+ if (action === 'cancel' || action === 'retry') {
455
+ try {
456
+ return success(taskView(kernel, action === 'cancel' ? kernel.cancel(taskId) : await kernel.retry(taskId)))
457
+ } catch (cause) {
458
+ const error = cause as { code?: unknown; message?: unknown; suggestion?: unknown }
459
+ return failure(
460
+ { taskId },
461
+ typeof error.code === 'string' ? error.code : 'CONVERT_TASK_FAILED',
462
+ typeof error.message === 'string' ? error.message : String(cause),
463
+ false,
464
+ typeof error.suggestion === 'string' ? error.suggestion : undefined,
465
+ )
466
+ }
467
+ }
434
468
  if (action === 'markdown') {
435
469
  const file = await kernel.readDeliveryFile(taskId, 'index.md')
436
470
  if (!file) return failure({ taskId }, 'ARTIFACT_NOT_FOUND', '该任务没有 index.md(未成功或已被清理)。')
@@ -443,7 +477,7 @@ export function applyConvert(ctx: Context, deps: ConvertHostDeps): ConvertHost {
443
477
  }
444
478
  const task = kernel.get(taskId)
445
479
  if (!task) return failure({ taskId }, 'TASK_NOT_FOUND', `任务不存在:${taskId}`)
446
- return success(taskView(task))
480
+ return success(taskView(kernel, task))
447
481
  },
448
482
  ))
449
483
 
@@ -1,8 +1,9 @@
1
- import { DIAGNOSIS_ENTITIES, type DiagnosisEntityType } from './schema.js'
2
- import type { AttributionCase } from './seed.js'
1
+ import { DIAGNOSIS_ENTITIES, LEGACY_BLOCK_FIELDS, LEGACY_ENTITY_TYPES, type DiagnosisEntityType } from './schema.js'
3
2
  import type {
3
+ AssetType,
4
+ AttributionCase,
4
5
  AttributionEdge, AttributionEdgeKind, AttributionGraphData, AttributionNode, AttributionNodeKind,
5
- FailureMode,
6
+ FailureReason,
6
7
  } from './types.js'
7
8
 
8
9
  /**
@@ -10,13 +11,12 @@ import type {
10
11
  *
11
12
  * ## 为什么这一层必须存在
12
13
  *
13
- * 在它之前,智能归因的图只有一条来源:`seed.ts` 里的 TypeScript 字面量,被
14
- * `DiagnosisStore.ensure()` 直接写成 `05_diagnosis/*.jsonl`。那意味着**排查思路从来没有
15
- * 进过 wiki**:它不在 `01_source_truth` 里、不是 `02_wiki` 的卡片、编译器连
16
- * `diagnosis/hypothesis` 这个 entity_type 都不认 —— 于是它既不会被 wikiNote 化,
14
+ * 在它之前,智能归因的图只有一条来源:`seed.ts` 里的 TypeScript 字面量。那意味着
15
+ * **排查思路从来没有进过 wiki**:它不在 `01_source_truth` 里、不是 `02_wiki` 的卡片、
16
+ * 编译器连 `diagnosis/hypothesis` 这个 entity_type 都不认 —— 于是它既不会被 wikiNote 化,
17
17
  * 也不会进向量索引,**召回永远召不到它**。而「遇到类似问题先召回」正是这个产品的主张。
18
18
  *
19
- * 这一层把图变成卡片的**投影**:卡片是手写的(人写、Agent 写、从 SourceRaw 编译而来),
19
+ * 这一层把图变成卡片的**投影**:卡片是手写的(人写、Agent 提议后由人确认),
20
20
  * 图是算出来的。§4「存图、显树」讲的是同一件事 —— 存的是知识,图是它的一种读法。
21
21
  *
22
22
  * ## 卡片三段式与「谁由卡片定义」的边界
@@ -31,10 +31,10 @@ import type {
31
31
  *
32
32
  * | 图上的东西 | 谁定义 | 为什么 |
33
33
  * | --- | --- | --- |
34
- * | symptom / hypothesis / check / failure-mode / case 节点 | **卡片** | 这五类是人写的知识(§6.4) |
34
+ * | symptom / hypothesis / check_point / failure_reason / case 节点 | **卡片** | 这五类是人确认过的知识(§6.4) |
35
35
  * | asset 节点 | 引用反推 | 它随数据目录同步,不手写;卡片里只留 id |
36
- * | 现象→假设 / 假设→检查 / 假设→失败模式 / 资产→假设 / 案例→现象 边 | **卡片** | 都是卡片字段的确定性投影 |
37
- * | evidence / cause 节点,produces / supports / refutes / confirmed_as 边 | **一次排查** | 它们是一次执行的结果,不是知识;由排查自动写入(§6.4) |
36
+ * | 现象→假设 / 假设→检查点 / 假设→失败原因 / 检查点→资产 / 资产→假设 / 案例→现象 边 | **卡片** | 都是卡片字段的确定性投影 |
37
+ * | evidence / cause 节点,produces / supports / refutes / confirmed_as 边 | **一次排查** | 它们是一次执行的结果,不是知识;由 `01_source_truth/agent_diagnosis_case/` 那条 SourceRaw 读时解析出来 |
38
38
  *
39
39
  * 最后一行不是妥协,是分层:把「上次查到什么」写进卡片,等于让一份可复用的知识
40
40
  * 绑死在一次具体的执行上。
@@ -80,14 +80,15 @@ const CARD_LAYOUT: Record<DiagnosisEntityType, { block: string[]; summaryFrom: s
80
80
  summaryFrom: 'observable',
81
81
  },
82
82
  'diagnosis/hypothesis': {
83
- block: ['discriminator', 'not_when', 'failure_mode', 'verified_by', 'assets', 'reuse_count', 'stale'],
83
+ block: ['discriminator', 'not_when', 'failure_reason', 'verified_by', 'assets', 'reuse_count', 'stale'],
84
84
  summaryFrom: 'summary',
85
85
  },
86
- 'diagnosis/check': {
87
- block: ['cost_minutes', 'automation', 'verdict_true', 'verdict_false', 'stale'],
86
+ 'diagnosis/check-point': {
87
+ // `assets` 是 v1.3 新增的**查询依据**:它承载「检查点 → 资产」这条 `queries` 边。
88
+ block: ['cost_minutes', 'automation', 'verdict_true', 'verdict_false', 'runbook', 'side_effect', 'assets', 'stale'],
88
89
  summaryFrom: 'summary',
89
90
  },
90
- 'diagnosis/failure-mode': {
91
+ 'diagnosis/failure-reason': {
91
92
  block: ['code', 'label', 'boundary'],
92
93
  summaryFrom: 'summary',
93
94
  },
@@ -107,7 +108,7 @@ export const CARD_NODE_KINDS: AttributionNodeKind[] = DIAGNOSIS_ENTITIES.map(spe
107
108
 
108
109
  /** 卡片能定义的边:全部是卡片字段的确定性投影。 */
109
110
  export const CARD_EDGE_KINDS: AttributionEdgeKind[] = [
110
- 'may_be_caused_by', 'verified_by', 'categorized_as', 'indexed_from', 'instance_of',
111
+ 'may_be_caused_by', 'verified_by', 'categorized_as', 'queries', 'indexed_from', 'instance_of',
111
112
  ]
112
113
 
113
114
  /** 一次排查写下的边。它们不属于任何卡片,见文件头的边界表。 */
@@ -150,8 +151,8 @@ export function serializeCard(card: DiagnosisCard): string {
150
151
  export function parseCard(source: string): DiagnosisCard | undefined {
151
152
  // 复用运行时的 front matter 解析(同一个 yaml 子集),不另写一套。
152
153
  const parsed = parseFrontmatter(source)
153
- const entityType = String(parsed.frontmatter.entity_type ?? '')
154
- if (!isEntityType(entityType)) return undefined
154
+ const entityType = normalizeEntityType(String(parsed.frontmatter.entity_type ?? ''))
155
+ if (!entityType) return undefined
155
156
  const entityId = String(parsed.frontmatter.entity_id ?? '').trim()
156
157
  if (!entityId) return undefined
157
158
 
@@ -160,7 +161,7 @@ export function parseCard(source: string): DiagnosisCard | undefined {
160
161
  if (match) {
161
162
  try {
162
163
  const value = JSON.parse(match[1])
163
- if (value && typeof value === 'object' && !Array.isArray(value)) block = value as Record<string, unknown>
164
+ if (value && typeof value === 'object' && !Array.isArray(value)) block = normalizeBlock(value as Record<string, unknown>)
164
165
  } catch { /* 块坏了不静默降级成空对象:留空之后由三层校验的「格式层」报缺字段。 */ }
165
166
  }
166
167
  const body = match ? parsed.content.replace(match[0], '') : parsed.content
@@ -180,6 +181,22 @@ export function parseCard(source: string): DiagnosisCard | undefined {
180
181
  }
181
182
  }
182
183
 
184
+ /** 改名之前的 `entity_type` 按别名认,而不是判为未知类型然后静默丢掉这张卡片。 */
185
+ export function normalizeEntityType(value: string): DiagnosisEntityType | undefined {
186
+ const mapped = LEGACY_ENTITY_TYPES[value] ?? value
187
+ return DIAGNOSIS_ENTITIES.some(spec => spec.entityType === mapped) ? mapped as DiagnosisEntityType : undefined
188
+ }
189
+
190
+ /** 结构化块里的旧字段名(`failure_mode`)搬到新名字上。 */
191
+ function normalizeBlock(block: Record<string, unknown>): Record<string, unknown> {
192
+ const next: Record<string, unknown> = { ...block }
193
+ for (const [from, to] of Object.entries(LEGACY_BLOCK_FIELDS)) {
194
+ if (next[to] === undefined && next[from] !== undefined) next[to] = next[from]
195
+ delete next[from]
196
+ }
197
+ return next
198
+ }
199
+
183
200
  /**
184
201
  * 只解析 front matter 与正文。
185
202
  *
@@ -224,20 +241,21 @@ function splitSections(body: string): DiagnosisCardSection[] {
224
241
  return sections.filter(section => section.body !== '')
225
242
  }
226
243
 
227
- function isEntityType(value: string): value is DiagnosisEntityType {
228
- return DIAGNOSIS_ENTITIES.some(spec => spec.entityType === value)
229
- }
230
-
231
244
  /* ─────────────────────────────────────────────── 图 → 卡片 ── */
232
245
 
233
246
  /**
234
247
  * 类型层与案例 → 卡片。
235
248
  *
236
- * `asset` / `evidence` / `cause` 节点**不产出卡片**:它们不是手写的知识(§6.4)。
237
- * 被假设引用的资产会在反序列化时按 id 补一个最小节点回来。
249
+ * `asset` / `evidence` / `cause` 节点**不产出卡片**:它们不是手确认过的知识(§6.4)。
250
+ * 被引用的资产会在反序列化时按 id 补一个最小节点回来。
238
251
  */
239
252
  export function graphToCards(graph: AttributionGraphData, cases: AttributionCase[] = []): DiagnosisCard[] {
240
253
  const cards: DiagnosisCard[] = []
254
+ /** 资产 id → 卡片声明过的种类。它写在**引用它的那张卡片**上,不写在资产节点自己身上。 */
255
+ const assetTypeById = new Map<string, AssetType>()
256
+ for (const node of graph.nodes) {
257
+ if (node.kind === 'asset' && node.assetType) assetTypeById.set(node.id, node.assetType)
258
+ }
241
259
 
242
260
  for (const node of graph.nodes) {
243
261
  // 案例节点只是图上的**桩**:它的正文与有序路径住在 `cases` 里(§6.4)。图上的桩不单独成卡,
@@ -264,18 +282,25 @@ export function graphToCards(graph: AttributionGraphData, cases: AttributionCase
264
282
  } else if (node.kind === 'hypothesis') {
265
283
  block.discriminator = node.discriminator ?? ''
266
284
  block.not_when = node.notWhen ?? ''
267
- block.failure_mode = node.failureMode ?? null
285
+ block.failure_reason = node.failureReason ?? null
268
286
  block.verified_by = graph.edges.filter(edge => edge.kind === 'verified_by' && edge.source === node.id).map(edge => edge.target)
269
- block.assets = graph.edges.filter(edge => edge.kind === 'indexed_from' && edge.target === node.id).map(edge => edge.source)
287
+ block.assets = graph.edges
288
+ .filter(edge => edge.kind === 'indexed_from' && edge.target === node.id)
289
+ .map(edge => assetEntryOf(edge.source, assetTypeById))
270
290
  block.reuse_count = node.reuseCount ?? 0
271
291
  if (node.stale !== undefined) block.stale = node.stale
272
- } else if (node.kind === 'check') {
292
+ } else if (node.kind === 'check_point') {
273
293
  block.cost_minutes = node.costMinutes ?? null
274
294
  block.automation = node.automation ?? null
275
295
  block.verdict_true = node.verdictTrue ?? ''
276
296
  block.verdict_false = node.verdictFalse ?? ''
297
+ // 检查点 → 资产:`queries` 边。它和假设上的 `assets`(`indexed_from`)是两条边,
298
+ // 两端与回答的问题都不同,所以各读各的,不共用一份。
299
+ block.assets = graph.edges
300
+ .filter(edge => edge.kind === 'queries' && edge.source === node.id)
301
+ .map(edge => assetEntryOf(edge.target, assetTypeById))
277
302
  if (node.stale !== undefined) block.stale = node.stale
278
- } else if (node.kind === 'failure-mode') {
303
+ } else if (node.kind === 'failure_reason') {
279
304
  block.code = node.code ?? null
280
305
  block.label = node.title
281
306
  block.boundary = ''
@@ -328,9 +353,9 @@ export interface CompiledDiagnosis {
328
353
  /**
329
354
  * 卡片 → 图。
330
355
  *
331
- * 只产出「卡片定义得了」的那部分(类型层 + 引用层的资产 + 案例,以及五种卡片边);
332
- * `evidence` / `cause` 与四条执行边由一次排查写入,不在这里合成 —— 否则一次执行的结果
333
- * 会被伪装成可复用的知识。
356
+ * 只产出「卡片定义得了」的那部分(类型层 + 引用层的资产 + 案例,以及六种卡片边);
357
+ * `evidence` / `cause` 与四条执行边由一次排查写入(`agent_diagnosis_case` 的 SourceRaw),
358
+ * 不在这里合成 —— 否则一次执行的结果会被伪装成可复用的知识。
334
359
  */
335
360
  export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
336
361
  const nodes: AttributionNode[] = []
@@ -343,16 +368,25 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
343
368
  seen.add(card.entityId)
344
369
  }
345
370
 
346
- /** 资产节点按引用反推:引用层随数据目录同步,卡片里只留 id。 */
371
+ /**
372
+ * 资产节点按引用反推:引用层随数据目录同步,卡片里只留 id。
373
+ *
374
+ * `assets[]` 的每一项可以是 `"dwd_ord_di"`,也可以是 `{ id, type }` —— 后者顺带声明
375
+ * 资产的种类,它只影响「查询依据」那一段怎么分组(§7.7 ④),不改变边的两端。
376
+ */
347
377
  const assetIds = new Set<string>()
348
- const modeIdByCode = new Map<FailureMode, string>()
378
+ const assetTypes = new Map<string, AssetType>()
379
+ const reasonIdByCode = new Map<FailureReason, string>()
349
380
 
350
381
  for (const card of cards) {
351
- if (card.entityType === 'diagnosis/failure-mode' && typeof card.block.code === 'string') {
352
- modeIdByCode.set(card.block.code as FailureMode, card.entityId)
382
+ if (card.entityType === 'diagnosis/failure-reason' && typeof card.block.code === 'string') {
383
+ reasonIdByCode.set(card.block.code as FailureReason, card.entityId)
353
384
  }
354
- if (card.entityType === 'diagnosis/hypothesis' && Array.isArray(card.block.assets)) {
355
- for (const id of card.block.assets) assetIds.add(String(id))
385
+ if (card.entityType === 'diagnosis/hypothesis' || card.entityType === 'diagnosis/check-point') {
386
+ for (const ref of assetRefsOf(card.block.assets)) {
387
+ assetIds.add(ref.id)
388
+ if (ref.type) assetTypes.set(ref.id, ref.type)
389
+ }
356
390
  }
357
391
  }
358
392
 
@@ -393,8 +427,8 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
393
427
  if (discriminator) node.discriminator = discriminator
394
428
  const notWhen = stringOf(card.block.not_when)
395
429
  if (notWhen) node.notWhen = notWhen
396
- const failureMode = stringOf(card.block.failure_mode)
397
- if (failureMode) node.failureMode = failureMode as FailureMode
430
+ const failureReason = stringOf(card.block.failure_reason)
431
+ if (failureReason) node.failureReason = failureReason as FailureReason
398
432
  const reuse = numberOf(card.block.reuse_count)
399
433
  if (reuse !== undefined) node.reuseCount = reuse
400
434
  if (typeof card.block.stale === 'boolean') node.stale = card.block.stale
@@ -402,15 +436,15 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
402
436
  for (const [index, target] of listOf(card.block.verified_by).entries()) {
403
437
  edges.push({ id: `e-${card.entityId.toLowerCase()}-${String(target).toLowerCase()}-${index}`, source: card.entityId, target: String(target), kind: 'verified_by' })
404
438
  }
405
- for (const [index, asset] of listOf(card.block.assets).entries()) {
406
- edges.push({ id: `e-${String(asset).toLowerCase()}-${card.entityId.toLowerCase()}-${index}`, source: String(asset), target: card.entityId, kind: 'indexed_from' })
439
+ for (const [index, asset] of assetRefsOf(card.block.assets).entries()) {
440
+ edges.push({ id: `e-${asset.id.toLowerCase()}-${card.entityId.toLowerCase()}-${index}`, source: asset.id, target: card.entityId, kind: 'indexed_from' })
407
441
  }
408
- if (failureMode) {
409
- const modeNode = modeIdByCode.get(failureMode as FailureMode)
410
- if (modeNode) edges.push({ id: `e-${card.entityId.toLowerCase()}-${modeNode.toLowerCase()}`, source: card.entityId, target: modeNode, kind: 'categorized_as' })
411
- else problems.push(`${card.entityId} 的 failure_mode=${failureMode} 找不到对应的失败模式卡片 —— categorized_as 边无从建立`)
442
+ if (failureReason) {
443
+ const reasonNode = reasonIdByCode.get(failureReason as FailureReason)
444
+ if (reasonNode) edges.push({ id: `e-${card.entityId.toLowerCase()}-${reasonNode.toLowerCase()}`, source: card.entityId, target: reasonNode, kind: 'categorized_as' })
445
+ else problems.push(`${card.entityId} 的 failure_reason=${failureReason} 找不到对应的失败原因卡片 —— categorized_as 边无从建立`)
412
446
  }
413
- } else if (card.entityType === 'diagnosis/check') {
447
+ } else if (card.entityType === 'diagnosis/check-point') {
414
448
  const cost = numberOf(card.block.cost_minutes)
415
449
  if (cost !== undefined) node.costMinutes = cost
416
450
  const automation = stringOf(card.block.automation)
@@ -419,10 +453,18 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
419
453
  if (verdictTrue) node.verdictTrue = verdictTrue
420
454
  const verdictFalse = stringOf(card.block.verdict_false)
421
455
  if (verdictFalse) node.verdictFalse = verdictFalse
456
+ const assets = assetRefsOf(card.block.assets).map(ref => ref.id)
457
+ if (assets.length > 0) node.assets = assets
422
458
  if (typeof card.block.stale === 'boolean') node.stale = card.block.stale
423
- } else if (card.entityType === 'diagnosis/failure-mode') {
459
+
460
+ // 检查点 → 资产(`queries`):这条检查要去查哪些对象。与假设上的
461
+ // `indexed_from`(这条假设落在哪个对象上)是两条边,方向和语义都不同。
462
+ for (const [index, asset] of assets.entries()) {
463
+ edges.push({ id: `e-${card.entityId.toLowerCase()}-${asset.toLowerCase()}-q${index}`, source: card.entityId, target: asset, kind: 'queries' })
464
+ }
465
+ } else if (card.entityType === 'diagnosis/failure-reason') {
424
466
  const code = stringOf(card.block.code)
425
- if (code) node.code = code as FailureMode
467
+ if (code) node.code = code as FailureReason
426
468
  } else if (card.entityType === 'diagnosis/case') {
427
469
  const symptom = stringOf(card.block.symptom)
428
470
  if (!symptom) problems.push(`${card.entityId} 没有 symptom —— 案例必须从一个现象进入`)
@@ -447,7 +489,8 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
447
489
 
448
490
  for (const id of assetIds) {
449
491
  if (nodes.some(node => node.id === id)) continue
450
- nodes.push({ id, kind: 'asset', layer: 'reference', title: id })
492
+ const assetType = assetTypes.get(id)
493
+ nodes.push({ id, kind: 'asset', layer: 'reference', title: id, ...(assetType ? { assetType } : {}) })
451
494
  }
452
495
 
453
496
  // 引用完整性:卡片引用了不存在的卡片时,必须报出来(否则图上会出现悬空边,
@@ -461,6 +504,33 @@ export function cardsToGraph(cards: DiagnosisCard[]): CompiledDiagnosis {
461
504
  return { graph: { nodes, edges }, cases, problems }
462
505
  }
463
506
 
507
+ /**
508
+ * `assets[]` 的一项 → `{ id, type? }`。
509
+ *
510
+ * 两种写法都认:`"dwd_ord_di"`(只留 id,资产随数据目录同步)与
511
+ * `{ id: "dwd_ord_di", type: "table" }`(顺带声明种类,供「查询依据」那一段分组)。
512
+ * 认不出来的项直接丢掉 —— 造一个 id 是 `[object Object]` 的资产节点,比少一条边难解释。
513
+ */
514
+ function assetRefsOf(value: unknown): Array<{ id: string; type?: AssetType }> {
515
+ return listOf(value).flatMap((entry): Array<{ id: string; type?: AssetType }> => {
516
+ if (typeof entry === 'string') return entry.trim() ? [{ id: entry.trim() }] : []
517
+ if (entry && typeof entry === 'object') {
518
+ const record = entry as { id?: unknown; type?: unknown }
519
+ const id = stringOf(record.id)
520
+ if (!id) return []
521
+ const type = stringOf(record.type) as AssetType | undefined
522
+ return [{ id, ...(type ? { type } : {}) }]
523
+ }
524
+ return []
525
+ })
526
+ }
527
+
528
+ /** 写回卡片时用同一条规则:声明过种类就写成对象,否则保持一行一个 id 的短写法。 */
529
+ function assetEntryOf(id: string, types: Map<string, AssetType>): string | { id: string; type: AssetType } {
530
+ const type = types.get(id)
531
+ return type ? { id, type } : id
532
+ }
533
+
464
534
  function stringOf(value: unknown): string | undefined {
465
535
  return typeof value === 'string' && value.trim() !== '' ? value : undefined
466
536
  }