dsh-data-domain-knowledge 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +360 -0
- package/cordis.patch.yml +410 -0
- package/eval/README.md +76 -0
- package/eval/evaluate-retrieval.mjs +121 -0
- package/eval/queries.example.jsonl +2 -0
- package/lib/client.js +111415 -0
- package/lib/types/config-schema.d.ts +35 -0
- package/lib/types/config-schema.d.ts.map +1 -0
- package/lib/types/config-schema.js +34 -0
- package/lib/types/config-schema.js.map +1 -0
- package/lib/types/convert/core-loader.d.ts +32 -0
- package/lib/types/convert/core-loader.d.ts.map +1 -0
- package/lib/types/convert/core-loader.js +60 -0
- package/lib/types/convert/core-loader.js.map +1 -0
- package/lib/types/convert/host.d.ts +88 -0
- package/lib/types/convert/host.d.ts.map +1 -0
- package/lib/types/convert/host.js +479 -0
- package/lib/types/convert/host.js.map +1 -0
- package/lib/types/convert/ingest.d.ts +125 -0
- package/lib/types/convert/ingest.d.ts.map +1 -0
- package/lib/types/convert/ingest.js +228 -0
- package/lib/types/convert/ingest.js.map +1 -0
- package/lib/types/convert/settings-schema.d.ts +18 -0
- package/lib/types/convert/settings-schema.d.ts.map +1 -0
- package/lib/types/convert/settings-schema.js +26 -0
- package/lib/types/convert/settings-schema.js.map +1 -0
- package/lib/types/convert/types.d.ts +46 -0
- package/lib/types/convert/types.d.ts.map +1 -0
- package/lib/types/convert/types.js +41 -0
- package/lib/types/convert/types.js.map +1 -0
- package/lib/types/diagnosis/cards.d.ts +71 -0
- package/lib/types/diagnosis/cards.d.ts.map +1 -0
- package/lib/types/diagnosis/cards.js +482 -0
- package/lib/types/diagnosis/cards.js.map +1 -0
- package/lib/types/diagnosis/problem-classes.d.ts +51 -0
- package/lib/types/diagnosis/problem-classes.d.ts.map +1 -0
- package/lib/types/diagnosis/problem-classes.js +66 -0
- package/lib/types/diagnosis/problem-classes.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +65 -0
- package/lib/types/diagnosis/schema.d.ts.map +1 -0
- package/lib/types/diagnosis/schema.js +208 -0
- package/lib/types/diagnosis/schema.js.map +1 -0
- package/lib/types/diagnosis/seed-source.d.ts +20 -0
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -0
- package/lib/types/diagnosis/seed-source.js +96 -0
- package/lib/types/diagnosis/seed-source.js.map +1 -0
- package/lib/types/diagnosis/seed.d.ts +50 -0
- package/lib/types/diagnosis/seed.d.ts.map +1 -0
- package/lib/types/diagnosis/seed.js +242 -0
- package/lib/types/diagnosis/seed.js.map +1 -0
- package/lib/types/diagnosis/store.d.ts +81 -0
- package/lib/types/diagnosis/store.d.ts.map +1 -0
- package/lib/types/diagnosis/store.js +409 -0
- package/lib/types/diagnosis/store.js.map +1 -0
- package/lib/types/diagnosis/types.d.ts +124 -0
- package/lib/types/diagnosis/types.d.ts.map +1 -0
- package/lib/types/diagnosis/types.js +13 -0
- package/lib/types/diagnosis/types.js.map +1 -0
- package/lib/types/directory-picker.d.ts +94 -0
- package/lib/types/directory-picker.d.ts.map +1 -0
- package/lib/types/directory-picker.js +110 -0
- package/lib/types/directory-picker.js.map +1 -0
- package/lib/types/extract-model.d.ts +76 -0
- package/lib/types/extract-model.d.ts.map +1 -0
- package/lib/types/extract-model.js +193 -0
- package/lib/types/extract-model.js.map +1 -0
- package/lib/types/extract.d.ts +121 -0
- package/lib/types/extract.d.ts.map +1 -0
- package/lib/types/extract.js +549 -0
- package/lib/types/extract.js.map +1 -0
- package/lib/types/fts.d.ts +96 -0
- package/lib/types/fts.d.ts.map +1 -0
- package/lib/types/fts.js +184 -0
- package/lib/types/fts.js.map +1 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +1112 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/intent/engine.d.ts +108 -0
- package/lib/types/intent/engine.d.ts.map +1 -0
- package/lib/types/intent/engine.js +501 -0
- package/lib/types/intent/engine.js.map +1 -0
- package/lib/types/intent/host.d.ts +88 -0
- package/lib/types/intent/host.d.ts.map +1 -0
- package/lib/types/intent/host.js +389 -0
- package/lib/types/intent/host.js.map +1 -0
- package/lib/types/intent/preset-tools.d.ts +37 -0
- package/lib/types/intent/preset-tools.d.ts.map +1 -0
- package/lib/types/intent/preset-tools.js +45 -0
- package/lib/types/intent/preset-tools.js.map +1 -0
- package/lib/types/intent/settings-schema.d.ts +59 -0
- package/lib/types/intent/settings-schema.d.ts.map +1 -0
- package/lib/types/intent/settings-schema.js +57 -0
- package/lib/types/intent/settings-schema.js.map +1 -0
- package/lib/types/intent/store.d.ts +75 -0
- package/lib/types/intent/store.d.ts.map +1 -0
- package/lib/types/intent/store.js +139 -0
- package/lib/types/intent/store.js.map +1 -0
- package/lib/types/intent/tools.d.ts +63 -0
- package/lib/types/intent/tools.d.ts.map +1 -0
- package/lib/types/intent/tools.js +108 -0
- package/lib/types/intent/tools.js.map +1 -0
- package/lib/types/intent/types.d.ts +189 -0
- package/lib/types/intent/types.d.ts.map +1 -0
- package/lib/types/intent/types.js +62 -0
- package/lib/types/intent/types.js.map +1 -0
- package/lib/types/knowledge-bases.d.ts +266 -0
- package/lib/types/knowledge-bases.d.ts.map +1 -0
- package/lib/types/knowledge-bases.js +1107 -0
- package/lib/types/knowledge-bases.js.map +1 -0
- package/lib/types/layout.d.ts +262 -0
- package/lib/types/layout.d.ts.map +1 -0
- package/lib/types/layout.js +312 -0
- package/lib/types/layout.js.map +1 -0
- package/lib/types/migrate.d.ts +110 -0
- package/lib/types/migrate.d.ts.map +1 -0
- package/lib/types/migrate.js +280 -0
- package/lib/types/migrate.js.map +1 -0
- package/lib/types/recall-rank.d.ts +158 -0
- package/lib/types/recall-rank.d.ts.map +1 -0
- package/lib/types/recall-rank.js +261 -0
- package/lib/types/recall-rank.js.map +1 -0
- package/lib/types/runtime.d.ts +568 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +3045 -0
- package/lib/types/runtime.js.map +1 -0
- package/lib/types/settings-schema.d.ts +117 -0
- package/lib/types/settings-schema.d.ts.map +1 -0
- package/lib/types/settings-schema.js +92 -0
- package/lib/types/settings-schema.js.map +1 -0
- package/lib/types/settings-volatile.d.ts +102 -0
- package/lib/types/settings-volatile.d.ts.map +1 -0
- package/lib/types/settings-volatile.js +114 -0
- package/lib/types/settings-volatile.js.map +1 -0
- package/lib/types/types.d.ts +799 -0
- package/lib/types/types.d.ts.map +1 -0
- package/lib/types/types.js +387 -0
- package/lib/types/types.js.map +1 -0
- package/lib/types/wiki-types.d.ts +68 -0
- package/lib/types/wiki-types.d.ts.map +1 -0
- package/lib/types/wiki-types.js +322 -0
- package/lib/types/wiki-types.js.map +1 -0
- package/package.json +101 -0
- package/preset/tita-data-agent/agent.cordis.yml +376 -0
- package/preset/tita-data-agent/preset.yml +3 -0
- package/python/requirements.txt +8 -0
- package/scripts/doctor.mjs +70 -0
- package/scripts/e2e-pipeline.mjs +355 -0
- package/scripts/install-agent-preset.mjs +92 -0
- package/scripts/setup-embedding.sh +34 -0
- package/scripts/sync-agent-preset.mjs +188 -0
- package/src/client/AgentPresetCard.tsx +247 -0
- package/src/client/DirectoryPicker.tsx +357 -0
- package/src/client/InfoHint.tsx +65 -0
- package/src/client/IntentLogicTab.tsx +185 -0
- package/src/client/IntentTab.tsx +715 -0
- package/src/client/KnowledgeBaseTab.tsx +1454 -0
- package/src/client/KnowledgeGraphView.tsx +306 -0
- package/src/client/KnowledgePanel.tsx +381 -0
- package/src/client/KnowledgeStepRail.tsx +60 -0
- package/src/client/NodeSettings.tsx +392 -0
- package/src/client/PreviewSections.tsx +116 -0
- package/src/client/RecallPanel.tsx +399 -0
- package/src/client/TypeDefinitionDrawer.tsx +215 -0
- package/src/client/WikiFlowCard.tsx +226 -0
- package/src/client/WikiTypeEditor.tsx +332 -0
- package/src/client/agent-preset.ts +121 -0
- package/src/client/arch/AgentArchitectureGraph.tsx +219 -0
- package/src/client/arch/NodeLogicPanel.tsx +133 -0
- package/src/client/arch/ProblemDetailPanel.tsx +88 -0
- package/src/client/arch/model.ts +691 -0
- package/src/client/architecture.css +191 -0
- package/src/client/architecture.tsx +530 -0
- package/src/client/attribution/AttributionGraph.tsx +104 -0
- package/src/client/attribution/AttributionTab.tsx +332 -0
- package/src/client/attribution/CaseView.tsx +102 -0
- package/src/client/attribution/GraphStage.tsx +87 -0
- package/src/client/attribution/RunView.tsx +184 -0
- package/src/client/attribution/StageChrome.tsx +403 -0
- package/src/client/attribution/ViewSwitch.tsx +107 -0
- package/src/client/attribution/graph-options.ts +381 -0
- package/src/client/attribution/model.ts +462 -0
- package/src/client/config-scope.ts +119 -0
- package/src/client/convert/ConvertPanel.tsx +594 -0
- package/src/client/convert/styles.css +138 -0
- package/src/client/convert/styles.ts +49 -0
- package/src/client/format.ts +39 -0
- package/src/client/graph/layout.ts +181 -0
- package/src/client/graph.css +113 -0
- package/src/client/index.ts +98 -0
- package/src/client/problem-domains.ts +114 -0
- package/src/client/styles.css +940 -0
- package/src/client/styles.ts +11 -0
- package/src/client/useFitScale.ts +68 -0
- package/src/config-schema.ts +45 -0
- package/src/convert/core-loader.ts +68 -0
- package/src/convert/host.ts +579 -0
- package/src/convert/ingest.ts +367 -0
- package/src/convert/settings-schema.ts +27 -0
- package/src/convert/types.ts +65 -0
- package/src/diagnosis/cards.ts +498 -0
- package/src/diagnosis/problem-classes.ts +117 -0
- package/src/diagnosis/schema.ts +260 -0
- package/src/diagnosis/seed-source.ts +107 -0
- package/src/diagnosis/seed.ts +291 -0
- package/src/diagnosis/store.ts +432 -0
- package/src/diagnosis/types.ts +152 -0
- package/src/directory-picker.ts +164 -0
- package/src/extract-model.ts +227 -0
- package/src/extract.ts +632 -0
- package/src/fts.ts +203 -0
- package/src/index.ts +1091 -0
- package/src/intent/README.md +63 -0
- package/src/intent/engine.ts +527 -0
- package/src/intent/host.ts +462 -0
- package/src/intent/preset-tools.ts +48 -0
- package/src/intent/settings-schema.ts +63 -0
- package/src/intent/store.ts +143 -0
- package/src/intent/tools.ts +139 -0
- package/src/intent/types.ts +245 -0
- package/src/knowledge-bases.ts +1160 -0
- package/src/layout.ts +350 -0
- package/src/migrate.ts +262 -0
- package/src/python/embedding_worker.py +471 -0
- package/src/recall-rank.ts +299 -0
- package/src/runtime.ts +3069 -0
- package/src/settings-schema.ts +94 -0
- package/src/settings-volatile.ts +122 -0
- package/src/types.ts +1140 -0
- package/src/wiki-types.ts +357 -0
package/src/extract.ts
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import type { KnowledgeCard, SchemaType, SourceRaw, WikiTypeDef } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pass 0 · 候选抽取 + 归一(与库内判重)。
|
|
5
|
+
*
|
|
6
|
+
* 这一层是「SourceRaw → Wiki」的第一个编译阶段,也是唯一一个**不调用模型**的阶段:
|
|
7
|
+
* 它只做「文档里明明白白写着什么」这件事。三个设计判断:
|
|
8
|
+
*
|
|
9
|
+
* 1. **纯函数。** 不碰磁盘、不碰模型、不碰网络,所以它可以被直接单测,
|
|
10
|
+
* 也可以在重建之外被复用(Agent 想先看看会抽出什么)。真正落盘的是 `rebuild()`。
|
|
11
|
+
* 2. **只认有据可查的候选。** 每个候选都必须带着 `evidence`(来源 id + 小节 / 原文片段);
|
|
12
|
+
* 抽不出证据的位置宁可不出候选,也不编一个名字出来。
|
|
13
|
+
* 3. **related ≠ same。** `relation` 候选永远只变成图的边,绝不参与卡片合并 ——
|
|
14
|
+
* 「A 和 B 有关系」和「A 就是 B」是两件事,混起来会把两张卡片糊成一张,
|
|
15
|
+
* 而且糊掉之后没有任何痕迹能还原。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface CandidateEvidence {
|
|
19
|
+
sourceId: string
|
|
20
|
+
/** 候选来自哪个 `##`/`###` 小节(声明式候选可自带)。 */
|
|
21
|
+
heading?: string
|
|
22
|
+
/** 支撑这个候选的原文片段;只作证据展示,不参与判重。 */
|
|
23
|
+
quote?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ExtractionCandidate {
|
|
27
|
+
kind: 'entity' | 'concept' | 'relation'
|
|
28
|
+
/** Display name, e.g. 「销售口径」. */
|
|
29
|
+
name: string
|
|
30
|
+
/** The wiki card type this candidate should become. Must be an enabled WikiTypeDef id. */
|
|
31
|
+
type: SchemaType
|
|
32
|
+
evidence: CandidateEvidence
|
|
33
|
+
/** Other spellings that mean the same thing (from `[[Name|alias]]` and frontmatter). */
|
|
34
|
+
aliases?: string[]
|
|
35
|
+
/** Markdown body that backs this candidate (used as the card body). */
|
|
36
|
+
body?: string
|
|
37
|
+
/** For kind === 'relation': the other end. */
|
|
38
|
+
target?: { name: string; type?: SchemaType }
|
|
39
|
+
/** For kind === 'relation': the relation id, e.g. 'relates_to'. */
|
|
40
|
+
relation?: string
|
|
41
|
+
confidence: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 抽取关系候选时使用的默认关系 id;图里也只认这个 id(见 `rebuild()` 的边装配)。 */
|
|
45
|
+
export const RELATES_TO = 'relates_to'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 每份来源最多抽多少张卡片候选。
|
|
49
|
+
*
|
|
50
|
+
* 这是**有意为之的上限**,不是省略号:一份长文档的小节可以有几十个,放开就会把
|
|
51
|
+
* 一个知识库灌成「一篇文档 = 几十张未审核卡片」,人再也不会去看它们。宁可少抽,
|
|
52
|
+
* 剩下的由 Agent 用声明式候选主动补。
|
|
53
|
+
*/
|
|
54
|
+
export const MAX_CANDIDATES_PER_SOURCE = 12
|
|
55
|
+
|
|
56
|
+
/** `##`/`###` 小节正文短于此长度就不成候选:那更像标签而不是一个可定义的对象。 */
|
|
57
|
+
const SECTION_MIN_CHARS = 120
|
|
58
|
+
|
|
59
|
+
/** 这些小节名是结构性噪音,抽成卡片只会污染 wiki。 */
|
|
60
|
+
const GENERIC_HEADINGS = new Set([
|
|
61
|
+
'参考', '参考资料', '参考文献', '参考链接', '附录', '目录', '变更记录', '更新记录',
|
|
62
|
+
'版本历史', '修订记录', '相关文档', '相关链接', '致谢', '脚注', '备注', '索引', '术语表',
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/** 兜底类型:所有已知类型都不命中时落它(见 `pickType`)。 */
|
|
66
|
+
const FALLBACK_TYPE: SchemaType = 'concept'
|
|
67
|
+
|
|
68
|
+
/** 与 `runtime.ts` 的 `SAFE_ID` 同形:卡片 id 只允许 ASCII 安全字符。 */
|
|
69
|
+
const SAFE_ENTITY_ID = /^[a-zA-Z0-9._:-]{1,160}$/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 名字归一:小写 + 把非字母数字的连续段折成 `-`。
|
|
73
|
+
*
|
|
74
|
+
* **必须与 `candidateKey` / `buildAliasIndex` 用同一套归一**,否则「GMV 口径」和
|
|
75
|
+
* 「gmv-口径」在索引里是两个键,判重就会漏。这里刻意用小写化而不是运行时 slug 的
|
|
76
|
+
* 原样保留,因为判重是「同一个指称」的问题,大小写不构成区别。
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeCandidateName(value: string): string {
|
|
79
|
+
return value.trim().toLowerCase().replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 120)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function candidateKey(candidate: { type: string; name: string }): string {
|
|
83
|
+
return `${candidate.type}:${normalizeCandidateName(candidate.name)}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 候选落到磁盘时用的 `entity_id`。
|
|
88
|
+
*
|
|
89
|
+
* 卡片 id 必须过 `SAFE_ID`(ASCII),而候选名常常是中文,所以中文名不能直接当 id:
|
|
90
|
+
* 这里退化成 `candidate-<fnv1a>` 的稳定哈希。名字本身仍然完整地写进卡片 title,
|
|
91
|
+
* 并作为 alias 进索引,所以下一次判重照样能按名字找到它 —— 判重认的是名字,不是 id。
|
|
92
|
+
*/
|
|
93
|
+
export function candidateEntityId(name: string): string {
|
|
94
|
+
const normalized = normalizeCandidateName(name)
|
|
95
|
+
if (SAFE_ENTITY_ID.test(normalized)) return normalized
|
|
96
|
+
return `candidate-${fnv1a(normalized)}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** FNV-1a(32 位):只用来生成稳定短 id,不用于任何安全目的。 */
|
|
100
|
+
function fnv1a(value: string): string {
|
|
101
|
+
let hash = 0x811c9dc5
|
|
102
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
103
|
+
hash ^= value.charCodeAt(index)
|
|
104
|
+
hash = Math.imul(hash, 0x01000193)
|
|
105
|
+
}
|
|
106
|
+
return (hash >>> 0).toString(36)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stringList(value: unknown): string[] {
|
|
110
|
+
if (typeof value === 'string') return value.trim() ? [value.trim()] : []
|
|
111
|
+
if (!Array.isArray(value)) return []
|
|
112
|
+
return value.map(item => (typeof item === 'string' ? item.trim() : '')).filter(Boolean)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function plainRecord(value: unknown): Record<string, unknown> {
|
|
116
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ------------------------------------------------------------ 类型判定
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 用类型注册表的 `examples` / `description` 做**关键词重合**判型。
|
|
123
|
+
*
|
|
124
|
+
* 刻意不用模型:这一步只要「像指标就落指标,什么都不像就落概念」,判错了人还能改
|
|
125
|
+
* (卡片是 unreviewed)。要的是**确定性** —— 同一份文档两次抽取必须得到同一个类型,
|
|
126
|
+
* 否则增量指纹会永远在变。
|
|
127
|
+
*/
|
|
128
|
+
function typeCorpus(def: WikiTypeDef): string {
|
|
129
|
+
return [def.id, def.label, def.description, ...def.examples].join('\n').toLowerCase()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 名称的匹配片段。
|
|
134
|
+
*
|
|
135
|
+
* 中文没有词边界,只按空格切词的话「销售口径」永远匹配不上任何例子,所以额外补
|
|
136
|
+
* 2-gram(`销售` / `售口` / `口径`)。片段只用于打分,不用于生成任何 id。
|
|
137
|
+
*/
|
|
138
|
+
function nameFragments(name: string): string[] {
|
|
139
|
+
const lower = name.trim().toLowerCase()
|
|
140
|
+
const fragments = new Set<string>()
|
|
141
|
+
if (lower.length >= 2) fragments.add(lower)
|
|
142
|
+
for (const token of lower.split(/[^\p{L}\p{N}]+/u)) if (token.length >= 2) fragments.add(token)
|
|
143
|
+
const compact = [...lower.replace(/[^\p{L}\p{N}]+/gu, '')]
|
|
144
|
+
for (let index = 0; index + 1 < compact.length; index += 1) fragments.add(`${compact[index]}${compact[index + 1]}`)
|
|
145
|
+
return [...fragments]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scoreType(def: WikiTypeDef, name: string): number {
|
|
149
|
+
const corpus = typeCorpus(def)
|
|
150
|
+
const full = name.trim().toLowerCase()
|
|
151
|
+
let score = full.length >= 2 && corpus.includes(full) ? 3 : 0
|
|
152
|
+
for (const fragment of nameFragments(name)) if (corpus.includes(fragment)) score += 1
|
|
153
|
+
return score
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 在**启用的**类型里挑一个;全不命中时落 `concept`(不在注册表里就落第一个启用的)。 */
|
|
157
|
+
function pickType(name: string, enabled: WikiTypeDef[]): SchemaType {
|
|
158
|
+
let best: WikiTypeDef | undefined
|
|
159
|
+
let bestScore = 0
|
|
160
|
+
for (const def of enabled) {
|
|
161
|
+
const score = scoreType(def, name)
|
|
162
|
+
// 只有严格更高才替换:同分保持注册表顺序,抽取结果因此是确定的。
|
|
163
|
+
if (score > bestScore) { best = def; bestScore = score }
|
|
164
|
+
}
|
|
165
|
+
if (best) return best.id as SchemaType
|
|
166
|
+
if (enabled.some(def => def.id === FALLBACK_TYPE)) return FALLBACK_TYPE
|
|
167
|
+
return (enabled[0]?.id ?? FALLBACK_TYPE) as SchemaType
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ------------------------------------------------------------ 小节切分
|
|
171
|
+
|
|
172
|
+
interface Section { heading: string; content: string; markdown: string }
|
|
173
|
+
|
|
174
|
+
/** 按 `##`/`###` 切小节;`#` 与 `####` 不参与(前者是文档标题,后者太细)。 */
|
|
175
|
+
function sectionsOf(content: string): Section[] {
|
|
176
|
+
const sections: Section[] = []
|
|
177
|
+
let current: { heading: string; lines: string[] } | undefined
|
|
178
|
+
const flush = (): void => {
|
|
179
|
+
if (!current) return
|
|
180
|
+
const markdown = current.lines.join('\n').trim()
|
|
181
|
+
const body = current.lines.slice(1).join('\n').trim()
|
|
182
|
+
sections.push({ heading: current.heading, content: body, markdown })
|
|
183
|
+
current = undefined
|
|
184
|
+
}
|
|
185
|
+
for (const line of content.split(/\r?\n/)) {
|
|
186
|
+
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line)
|
|
187
|
+
if (match) {
|
|
188
|
+
flush()
|
|
189
|
+
if (match[1] === '##' || match[1] === '###') current = { heading: match[2]!.replace(/[::]\s*$/, '').trim(), lines: [line] }
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (current) current.lines.push(line)
|
|
193
|
+
}
|
|
194
|
+
flush()
|
|
195
|
+
return sections
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isGenericHeading(heading: string): boolean {
|
|
199
|
+
const normalized = heading.replace(/[\s::。.、]+$/g, '').trim()
|
|
200
|
+
if (GENERIC_HEADINGS.has(normalized)) return true
|
|
201
|
+
// 纯序号 / 纯数字小节(`1.`、`(二)`、`3`)不构成一个可定义的对象。
|
|
202
|
+
return /^[((]?[0-9一二三四五六七八九十]+[)).、]?$/.test(normalized)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sectionForName(sections: Section[], name: string): Section | undefined {
|
|
206
|
+
const normalized = normalizeCandidateName(name)
|
|
207
|
+
if (!normalized) return undefined
|
|
208
|
+
return sections.find(section => {
|
|
209
|
+
const heading = normalizeCandidateName(section.heading)
|
|
210
|
+
return heading === normalized || (heading.length >= 2 && normalized.includes(heading)) || (normalized.length >= 2 && heading.includes(normalized))
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ------------------------------------------------------------ 声明式候选
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 文档frontmatter / metadata 里可以直接声明候选,这是 Agent 的**逃生通道**:
|
|
218
|
+
* 自动抽取判断不了的时候,由调用方明确写出「这里有一个 X」。
|
|
219
|
+
*
|
|
220
|
+
* `SourceRaw` 只带 `metadata:` 这一块(`loadSources` 只投影它),所以声明写在
|
|
221
|
+
* `metadata` 里。接受四种形态,按出现顺序合并:
|
|
222
|
+
*
|
|
223
|
+
* ```yaml
|
|
224
|
+
* metadata:
|
|
225
|
+
* candidates: # ① 分桶
|
|
226
|
+
* entities: [{ name: 订单表, type: table }]
|
|
227
|
+
* concepts: [{ name: 销售口径, aliases: [GMV 口径], body: "…" }]
|
|
228
|
+
* relations: [{ from: 销售口径, to: GMV, relation: relates_to }]
|
|
229
|
+
* candidates: # ② 扁平数组(每条自带 kind)
|
|
230
|
+
* - { kind: entity, name: 订单表, type: table }
|
|
231
|
+
* - { kind: concept, name: 销售口径 }
|
|
232
|
+
* - { kind: relation, name: 销售口径, target: { name: GMV } }
|
|
233
|
+
* - 纯字符串 # 等价于 { kind: concept, name: 字符串 }
|
|
234
|
+
* entities: [ … ] # ③ 直接挂 entities / concepts / relations
|
|
235
|
+
* concepts: [ … ]
|
|
236
|
+
* relations: [ … ]
|
|
237
|
+
* ```
|
|
238
|
+
*
|
|
239
|
+
* 单条候选可带:`name`(或 `title`;关系也可用 `from`)、`type` / `entity_type`、
|
|
240
|
+
* `aliases`、`body`、`heading`、`confidence`。关系还可带 `to` / `target`、
|
|
241
|
+
* `relation`(默认 `relates_to`)、`to_type`。
|
|
242
|
+
*/
|
|
243
|
+
interface DeclaredEntry { kind: 'entity' | 'concept' | 'relation'; entry: unknown }
|
|
244
|
+
|
|
245
|
+
function declaredEntries(metadata: Record<string, unknown>): DeclaredEntry[] {
|
|
246
|
+
const result: DeclaredEntry[] = []
|
|
247
|
+
const pushList = (kind: DeclaredEntry['kind'], value: unknown): void => {
|
|
248
|
+
if (!Array.isArray(value)) return
|
|
249
|
+
for (const entry of value) result.push({ kind, entry })
|
|
250
|
+
}
|
|
251
|
+
const bag = metadata.candidates
|
|
252
|
+
if (Array.isArray(bag)) {
|
|
253
|
+
for (const entry of bag) {
|
|
254
|
+
const record = typeof entry === 'string' ? undefined : plainRecord(entry)
|
|
255
|
+
const declared = record && typeof record.kind === 'string' ? record.kind.trim().toLowerCase() : ''
|
|
256
|
+
const kind: DeclaredEntry['kind'] = declared === 'relation' ? 'relation' : declared === 'entity' ? 'entity' : 'concept'
|
|
257
|
+
result.push({ kind, entry })
|
|
258
|
+
}
|
|
259
|
+
} else if (bag !== null && typeof bag === 'object') {
|
|
260
|
+
const bucket = plainRecord(bag)
|
|
261
|
+
pushList('entity', bucket.entities)
|
|
262
|
+
pushList('concept', bucket.concepts)
|
|
263
|
+
pushList('relation', bucket.relations)
|
|
264
|
+
}
|
|
265
|
+
pushList('entity', metadata.entities)
|
|
266
|
+
pushList('concept', metadata.concepts)
|
|
267
|
+
pushList('relation', metadata.relations)
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function entryName(entry: unknown): string {
|
|
272
|
+
if (typeof entry === 'string') return entry.trim()
|
|
273
|
+
const record = plainRecord(entry)
|
|
274
|
+
for (const key of ['name', 'title', 'from'] as const) {
|
|
275
|
+
const value = record[key]
|
|
276
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
277
|
+
}
|
|
278
|
+
return ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 显式声明的类型 id:原样带出去,交给 `writeCard` 的类型闸门裁决(不在这里改判)。 */
|
|
282
|
+
function entryTypeId(entry: unknown): string | undefined {
|
|
283
|
+
const record = plainRecord(entry)
|
|
284
|
+
for (const key of ['type', 'entity_type'] as const) {
|
|
285
|
+
const value = record[key]
|
|
286
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
287
|
+
}
|
|
288
|
+
return undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function entryTargetName(entry: unknown): string {
|
|
292
|
+
const record = plainRecord(entry)
|
|
293
|
+
const target = record.target
|
|
294
|
+
if (typeof record.to === 'string' && record.to.trim()) return record.to.trim()
|
|
295
|
+
if (typeof target === 'string' && target.trim()) return target.trim()
|
|
296
|
+
const nested = plainRecord(target)
|
|
297
|
+
return typeof nested.name === 'string' ? nested.name.trim() : ''
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function entryTargetTypeId(entry: unknown): string | undefined {
|
|
301
|
+
const record = plainRecord(entry)
|
|
302
|
+
const direct = record.to_type
|
|
303
|
+
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
|
304
|
+
const nested = plainRecord(record.target)
|
|
305
|
+
return typeof nested.type === 'string' && nested.type.trim() ? nested.type.trim() : undefined
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function entryRelation(entry: unknown): string {
|
|
309
|
+
const value = plainRecord(entry).relation
|
|
310
|
+
return typeof value === 'string' && value.trim() ? value.trim() : RELATES_TO
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function entryConfidence(entry: unknown, fallback: number): number {
|
|
314
|
+
const value = Number(plainRecord(entry).confidence)
|
|
315
|
+
return Number.isFinite(value) && value > 0 && value <= 1 ? value : fallback
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function entryBody(entry: unknown): string | undefined {
|
|
319
|
+
const value = plainRecord(entry).body
|
|
320
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function entryHeading(entry: unknown): string | undefined {
|
|
324
|
+
const value = plainRecord(entry).heading
|
|
325
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function entryQuote(entry: unknown): string {
|
|
329
|
+
const text = typeof entry === 'string' ? entry : JSON.stringify(entry)
|
|
330
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, 240)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ------------------------------------------------------------ 抽取入口
|
|
334
|
+
|
|
335
|
+
/** 文档自己的主候选:声明式优先,其次第一个像样的小节(`[[链接]]` 关系的起点)。 */
|
|
336
|
+
function kindFor(type: SchemaType): 'entity' | 'concept' {
|
|
337
|
+
return type === FALLBACK_TYPE ? 'concept' : 'entity'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function extractCandidates(source: SourceRaw, wikiTypes: WikiTypeDef[]): ExtractionCandidate[] {
|
|
341
|
+
const metadata = plainRecord(source.metadata)
|
|
342
|
+
const enabled = wikiTypes.filter(def => def.enabled)
|
|
343
|
+
const sections = sectionsOf(source.content)
|
|
344
|
+
const evidence = (heading?: string, quote?: string): CandidateEvidence => ({
|
|
345
|
+
sourceId: source.sourceId,
|
|
346
|
+
...(heading ? { heading } : {}),
|
|
347
|
+
...(quote ? { quote } : {}),
|
|
348
|
+
})
|
|
349
|
+
// 显式声明的类型原样采信:抽取器不替 Agent 改判类型。写不进去(id 未登记 / 已停用)
|
|
350
|
+
// 是 `writeCard` 的闸门该说的话,那时它会被计入 candidatesRejected 而不是被静默换型。
|
|
351
|
+
const declaredType = (raw: string | undefined, name: string): SchemaType => (raw ?? pickType(name, enabled)) as SchemaType
|
|
352
|
+
|
|
353
|
+
// ---- ① / ② 声明式候选(最高置信)
|
|
354
|
+
const declaredEntities: ExtractionCandidate[] = []
|
|
355
|
+
const declaredRelations: ExtractionCandidate[] = []
|
|
356
|
+
for (const { kind, entry } of declaredEntries(metadata)) {
|
|
357
|
+
const name = entryName(entry)
|
|
358
|
+
if (!name) continue
|
|
359
|
+
const type = declaredType(entryTypeId(entry), name)
|
|
360
|
+
if (kind === 'relation') {
|
|
361
|
+
const to = entryTargetName(entry)
|
|
362
|
+
if (!to) continue
|
|
363
|
+
declaredRelations.push({
|
|
364
|
+
kind: 'relation', name, type,
|
|
365
|
+
evidence: evidence(entryHeading(entry), entryQuote(entry)),
|
|
366
|
+
target: { name: to, ...(entryTargetTypeId(entry) ? { type: declaredType(entryTargetTypeId(entry), to) } : {}) },
|
|
367
|
+
relation: entryRelation(entry),
|
|
368
|
+
confidence: entryConfidence(entry, 0.95),
|
|
369
|
+
})
|
|
370
|
+
continue
|
|
371
|
+
}
|
|
372
|
+
const aliases = stringList(plainRecord(entry).aliases)
|
|
373
|
+
const heading = entryHeading(entry) ?? sectionForName(sections, name)?.heading
|
|
374
|
+
const body = entryBody(entry) ?? sectionForName(sections, name)?.markdown
|
|
375
|
+
declaredEntities.push({
|
|
376
|
+
kind: kind === 'entity' ? 'entity' : kindFor(type), name, type,
|
|
377
|
+
evidence: evidence(heading, entryQuote(entry)),
|
|
378
|
+
...(aliases.length > 0 ? { aliases } : {}),
|
|
379
|
+
...(body ? { body } : {}),
|
|
380
|
+
confidence: entryConfidence(entry, 0.95),
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---- ④ 小节候选(先算:`[[链接]]` 的关系起点可能就是第一个小节)
|
|
385
|
+
const headingCandidates: ExtractionCandidate[] = []
|
|
386
|
+
for (const section of sections) {
|
|
387
|
+
if (isGenericHeading(section.heading)) continue
|
|
388
|
+
if (section.content.length < SECTION_MIN_CHARS) continue
|
|
389
|
+
const type = pickType(section.heading, enabled)
|
|
390
|
+
headingCandidates.push({
|
|
391
|
+
kind: kindFor(type), name: section.heading, type,
|
|
392
|
+
evidence: evidence(section.heading, section.content.slice(0, 240)),
|
|
393
|
+
body: section.markdown,
|
|
394
|
+
confidence: 0.5,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- ③ wikilink 候选 + 从主候选指向它的 relates_to
|
|
399
|
+
const linkCandidates: ExtractionCandidate[] = []
|
|
400
|
+
const linkSeen = new Set<string>()
|
|
401
|
+
/**
|
|
402
|
+
* 每个 `[[链接]]` 落在哪个小节里。
|
|
403
|
+
*
|
|
404
|
+
* 0029 实跑发现的召回污染就是这里造成的:链接候选原来只带 `evidence(undefined, …)`,
|
|
405
|
+
* 于是它**没有正文**,落卡时只能拿 wiki 类型的模板顶上 —— 模型召回到的是
|
|
406
|
+
* 「(必填:写什么)」。而链接所在的那一小节就是关于它的现成说明,本来就在手边。
|
|
407
|
+
*/
|
|
408
|
+
const sectionByHeading = new Map(sections.map(section => [section.heading, section]))
|
|
409
|
+
const linkSection = new Map<string, Section>()
|
|
410
|
+
{
|
|
411
|
+
let current: Section | undefined
|
|
412
|
+
for (const line of source.content.split(/\r?\n/)) {
|
|
413
|
+
const heading = /^#{2,6}\s+(.+?)\s*$/.exec(line)
|
|
414
|
+
if (heading) { current = sectionByHeading.get(heading[1]!.trim()); continue }
|
|
415
|
+
if (!current) continue
|
|
416
|
+
for (const match of line.matchAll(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g)) {
|
|
417
|
+
const name = (match[1] ?? '').trim()
|
|
418
|
+
if (name && !linkSection.has(name)) linkSection.set(name, current)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const match of source.content.matchAll(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g)) {
|
|
423
|
+
const name = (match[1] ?? '').trim()
|
|
424
|
+
if (!name) continue
|
|
425
|
+
const alias = (match[2] ?? '').trim()
|
|
426
|
+
const type = pickType(name, enabled)
|
|
427
|
+
const key = candidateKey({ type, name })
|
|
428
|
+
if (linkSeen.has(key)) continue
|
|
429
|
+
linkSeen.add(key)
|
|
430
|
+
const section = linkSection.get(name)
|
|
431
|
+
linkCandidates.push({
|
|
432
|
+
kind: kindFor(type), name, type,
|
|
433
|
+
evidence: evidence(section?.heading, match[0].slice(0, 240)),
|
|
434
|
+
...(alias ? { aliases: [alias] } : {}),
|
|
435
|
+
...(section ? { body: section.markdown } : {}),
|
|
436
|
+
confidence: 0.6,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
const primary = declaredEntities[0] ?? headingCandidates[0]
|
|
440
|
+
const linkRelations: ExtractionCandidate[] = []
|
|
441
|
+
if (primary) {
|
|
442
|
+
for (const link of linkCandidates) {
|
|
443
|
+
// 主候选指向自己不算关系。
|
|
444
|
+
if (candidateKey(primary) === candidateKey(link)) continue
|
|
445
|
+
linkRelations.push({
|
|
446
|
+
kind: 'relation', name: primary.name, type: primary.type,
|
|
447
|
+
evidence: link.evidence,
|
|
448
|
+
target: { name: link.name, type: link.type },
|
|
449
|
+
relation: RELATES_TO,
|
|
450
|
+
confidence: 0.55,
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---- ⑤ 什么都没抽到时,文档标题本身就是一个候选(有标题就有据)
|
|
456
|
+
const titleCandidates: ExtractionCandidate[] = []
|
|
457
|
+
const title = (source.title ?? '').trim()
|
|
458
|
+
if (declaredEntities.length + linkCandidates.length + headingCandidates.length === 0 && title) {
|
|
459
|
+
const type = pickType(title, enabled)
|
|
460
|
+
titleCandidates.push({
|
|
461
|
+
kind: kindFor(type), name: title, type,
|
|
462
|
+
// 整篇正文就是「这篇文档在讲什么」的正文 —— 没有小节可切时,它就是唯一的内容。
|
|
463
|
+
evidence: evidence(undefined, title),
|
|
464
|
+
...(source.content.trim() ? { body: source.content.trim() } : {}),
|
|
465
|
+
confidence: 0.35,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 顺序 = 优先级(声明式 → 链接 → 小节 → 标题),同类内保持文档出现顺序;
|
|
470
|
+
// 上限只砍尾部的低置信候选,不会把声明式候选挤掉。
|
|
471
|
+
const entities: ExtractionCandidate[] = []
|
|
472
|
+
const entitySeen = new Set<string>()
|
|
473
|
+
for (const candidate of [...declaredEntities, ...linkCandidates, ...headingCandidates, ...titleCandidates]) {
|
|
474
|
+
if (!candidate.name.trim()) continue
|
|
475
|
+
const key = candidateKey(candidate)
|
|
476
|
+
if (entitySeen.has(key)) continue
|
|
477
|
+
entitySeen.add(key)
|
|
478
|
+
entities.push(candidate)
|
|
479
|
+
if (entities.length >= MAX_CANDIDATES_PER_SOURCE) break
|
|
480
|
+
}
|
|
481
|
+
const relations: ExtractionCandidate[] = []
|
|
482
|
+
const relationSeen = new Set<string>()
|
|
483
|
+
for (const candidate of [...declaredRelations, ...linkRelations]) {
|
|
484
|
+
const to = candidate.target?.name?.trim()
|
|
485
|
+
if (!candidate.name.trim() || !to) continue
|
|
486
|
+
const key = `${normalizeCandidateName(candidate.name)}->${normalizeCandidateName(to)}:${candidate.relation ?? RELATES_TO}`
|
|
487
|
+
if (relationSeen.has(key)) continue
|
|
488
|
+
relationSeen.add(key)
|
|
489
|
+
relations.push(candidate)
|
|
490
|
+
if (relations.length >= MAX_CANDIDATES_PER_SOURCE) break
|
|
491
|
+
}
|
|
492
|
+
return [...entities, ...relations]
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ------------------------------------------------------------ 归一 · 与库内判重
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 现有卡片的判重索引。
|
|
499
|
+
*
|
|
500
|
+
* 三处都登记:`entityType + entityId`(技术 id)、`entityType + title`(人写的中文名)、
|
|
501
|
+
* `metadata.aliases` 里的每个写法。**别名是关键字**:Agent 写卡片时把「GMV 口径」登记
|
|
502
|
+
* 成 `gmv-kpi` 的别名,抽取器下一次就认得这两者是同一个指称,不会再造第二张卡。
|
|
503
|
+
*/
|
|
504
|
+
export function buildAliasIndex(cards: KnowledgeCard[]): Map<string, KnowledgeCard> {
|
|
505
|
+
const index = new Map<string, KnowledgeCard>()
|
|
506
|
+
const put = (type: string, name: unknown, card: KnowledgeCard): void => {
|
|
507
|
+
if (typeof name !== 'string' || !name.trim()) return
|
|
508
|
+
const key = candidateKey({ type, name })
|
|
509
|
+
// 先到先得:同一 slug 撞车时保持确定性,不让文件系统顺序决定谁赢。
|
|
510
|
+
if (!index.has(key)) index.set(key, card)
|
|
511
|
+
}
|
|
512
|
+
for (const card of cards) {
|
|
513
|
+
put(card.entityType, card.entityId, card)
|
|
514
|
+
put(card.entityType, card.title, card)
|
|
515
|
+
for (const alias of stringList(plainRecord(card.metadata).aliases)) put(card.entityType, alias, card)
|
|
516
|
+
}
|
|
517
|
+
return index
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface NormalizedCandidates {
|
|
521
|
+
fresh: ExtractionCandidate[]
|
|
522
|
+
/**
|
|
523
|
+
* Candidates whose referent already exists: merge into that card.
|
|
524
|
+
*
|
|
525
|
+
* `body` 是**这次新抽到的正文**,必须一起带出去。只带 `sourceId` 的话,调用方
|
|
526
|
+
* 除了「记一笔来源」什么也做不了 —— 新写进来源里的内容会**静默消失**,卡片永远
|
|
527
|
+
* 停在第一次抽取时的样子(落点是 `runtime.ts` 的 `appendExtracted`)。
|
|
528
|
+
*/
|
|
529
|
+
merges: Array<{ key: string; entityId: string; entityType: SchemaType; sourceId: string; body: string }>
|
|
530
|
+
/**
|
|
531
|
+
* Relation candidates are never merged — they become graph edges.
|
|
532
|
+
*
|
|
533
|
+
* `fromKey` / `toKey` 是**图上的节点键**(`<entityType>:<entityId>`),不是候选键
|
|
534
|
+
* (`<type>:<归一化名字>`)。两者在名字是 ASCII 时长得一样,在中文名上完全不同 ——
|
|
535
|
+
* 中文名的卡片 id 会退化成 `candidate-<hash>`。这里必须先解析成真正的节点键,
|
|
536
|
+
* 否则装配图时按候选键去找节点,**所有中文名之间的关系都会被当成「端点不存在」丢掉**。
|
|
537
|
+
*/
|
|
538
|
+
relations: Array<{ fromKey: string; toKey: string; relation: string; sourceId: string }>
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 归一:把候选分成「新建」「并入已有卡片」「关系」三类。
|
|
543
|
+
*
|
|
544
|
+
* 关系两端也走同一套判重,所以边只可能连到**真实存在的 wiki 节点**上;解析不出来的
|
|
545
|
+
* 端点会带着一个稳定 key 出去,由 `rebuild()` 在装配图时丢弃并计数,而不是在这里
|
|
546
|
+
* 凭空造一个节点。
|
|
547
|
+
*/
|
|
548
|
+
export function normalizeCandidates(candidates: ExtractionCandidate[], index: Map<string, KnowledgeCard>): NormalizedCandidates {
|
|
549
|
+
const resolved = new Map(index)
|
|
550
|
+
const byName = new Map<string, string[]>()
|
|
551
|
+
const remember = (key: string): void => {
|
|
552
|
+
const separator = key.indexOf(':')
|
|
553
|
+
const name = separator >= 0 ? key.slice(separator + 1) : key
|
|
554
|
+
const bucket = byName.get(name)
|
|
555
|
+
if (bucket) { if (!bucket.includes(key)) bucket.push(key) }
|
|
556
|
+
else byName.set(name, [key])
|
|
557
|
+
}
|
|
558
|
+
for (const key of resolved.keys()) remember(key)
|
|
559
|
+
|
|
560
|
+
const lookup = (name: string, type?: string): string | undefined => {
|
|
561
|
+
const normalized = normalizeCandidateName(name)
|
|
562
|
+
if (!normalized) return undefined
|
|
563
|
+
if (type) {
|
|
564
|
+
const exact = `${type}:${normalized}`
|
|
565
|
+
if (resolved.has(exact)) return exact
|
|
566
|
+
}
|
|
567
|
+
// 没给类型(或给的类型对不上)时按名字找:判重认的是指称,不是类型。
|
|
568
|
+
return byName.get(normalized)?.[0]
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 候选键 → 图上的节点键。找不到对应卡片时返回 undefined,由调用方决定怎么兜底。 */
|
|
572
|
+
const nodeKey = (name: string, type?: string): string | undefined => {
|
|
573
|
+
const key = lookup(name, type)
|
|
574
|
+
if (!key) return undefined
|
|
575
|
+
const card = resolved.get(key)
|
|
576
|
+
return card ? `${card.entityType}:${card.entityId}` : key
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const fresh: ExtractionCandidate[] = []
|
|
580
|
+
const merges: NormalizedCandidates['merges'] = []
|
|
581
|
+
const relations: NormalizedCandidates['relations'] = []
|
|
582
|
+
/** 本次已判为「新建」的 key(含别名):同一份文档里的重复写法不再各建一张。 */
|
|
583
|
+
const freshKeys = new Set<string>()
|
|
584
|
+
|
|
585
|
+
const register = (key: string, card: KnowledgeCard): void => {
|
|
586
|
+
if (!resolved.has(key)) resolved.set(key, card)
|
|
587
|
+
remember(key)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const candidate of candidates) {
|
|
591
|
+
if (candidate.kind === 'relation') {
|
|
592
|
+
const targetName = candidate.target?.name ?? ''
|
|
593
|
+
if (!candidate.name.trim() || !targetName.trim()) continue
|
|
594
|
+
const targetType = candidate.target?.type
|
|
595
|
+
relations.push({
|
|
596
|
+
// 解析不出来时退回候选键:它一定不在 nodeMap 里,装配阶段会把它计入
|
|
597
|
+
// relationsDropped —— 这比在这里凭空造一个节点诚实。
|
|
598
|
+
fromKey: nodeKey(candidate.name, candidate.type) ?? candidateKey({ type: candidate.type, name: candidate.name }),
|
|
599
|
+
toKey: nodeKey(targetName, targetType) ?? candidateKey({ type: targetType ?? candidate.type, name: targetName }),
|
|
600
|
+
relation: candidate.relation ?? RELATES_TO,
|
|
601
|
+
sourceId: candidate.evidence.sourceId,
|
|
602
|
+
})
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
const key = candidateKey(candidate)
|
|
606
|
+
if (freshKeys.has(key)) continue
|
|
607
|
+
const existing = resolved.get(key)
|
|
608
|
+
if (existing) {
|
|
609
|
+
merges.push({ key, entityId: existing.entityId, entityType: existing.entityType, sourceId: candidate.evidence.sourceId, body: candidate.body ?? '' })
|
|
610
|
+
continue
|
|
611
|
+
}
|
|
612
|
+
fresh.push(candidate)
|
|
613
|
+
const projected: KnowledgeCard = {
|
|
614
|
+
entityId: candidateEntityId(candidate.name),
|
|
615
|
+
entityType: candidate.type,
|
|
616
|
+
title: candidate.name,
|
|
617
|
+
status: 'unreviewed',
|
|
618
|
+
content: candidate.body ?? '',
|
|
619
|
+
sourceRefs: [candidate.evidence.sourceId],
|
|
620
|
+
metadata: {},
|
|
621
|
+
updatedAt: '',
|
|
622
|
+
}
|
|
623
|
+
freshKeys.add(key)
|
|
624
|
+
register(key, projected)
|
|
625
|
+
for (const alias of candidate.aliases ?? []) {
|
|
626
|
+
const aliasKey = candidateKey({ type: candidate.type, name: alias })
|
|
627
|
+
freshKeys.add(aliasKey)
|
|
628
|
+
register(aliasKey, projected)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return { fresh, merges, relations }
|
|
632
|
+
}
|