clearai-dsh 0.1.7 → 0.2.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/CHANGELOG.md +59 -0
- package/README.md +89 -74
- package/README.zh-CN.md +86 -71
- package/lib/client.js +944 -69
- package/lib/domain-language.js +758 -0
- package/lib/fold.js +894 -12
- package/lib/host.js +112 -2
- package/package.json +14 -3
- package/presets/clearai/agent.cordis.yml +10 -3
- package/presets/clearai/plugins/clearai-kernel.js +550 -12
- package/presets/clearai/plugins/prompts.js +47 -0
- package/presets/clearai/preset.yml +3 -3
- package/presets/clearai/skills/clearai-loop/SKILL.md +16 -13
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 领域语言层:**值形态、词条、断言的纯函数**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么它必须是一份纯函数:同一批判据要被三处消费——
|
|
5
|
+
* · 折法(`fold.js`)把账本里的本体事件折成 `state.lexicon`,并派生冲突与图;
|
|
6
|
+
* · 宿主侧的路由与预设侧的工具在**落账之前**校验注册与断言(那里有 I/O);
|
|
7
|
+
* · 客户端只读投影,不重算。
|
|
8
|
+
* 校验一旦有两份实现就会漂,而漂法最难发现:「登记时放行、升格时拒绝」在界面上
|
|
9
|
+
* 长得和「这条还没验」一模一样。所以判据只有这一份。
|
|
10
|
+
*
|
|
11
|
+
* 三条边界:
|
|
12
|
+
* · **不碰 I/O**。`code` 形态指向的文件存不存在,由拥有文件系统的那一侧查;这里只判形状,
|
|
13
|
+
* 于是同一份判据能在宿主与浏览器两侧跑出同一结果。
|
|
14
|
+
* · **不读时钟**。时间由调用方作为 `at` 传进来。
|
|
15
|
+
* · **不判真假**。这里只回答「这条断言合不合这门语言」,不回答「它对不对」——
|
|
16
|
+
* 后者是认识论循环的事(等级、证据、评估、人门)。
|
|
17
|
+
*
|
|
18
|
+
* 名词:一个项目有一份**领域词汇**(`lexicon`),里面是两种条目——
|
|
19
|
+
* **概念**(`term`,图上的节点)与**谓词**(`predicate`,图上的边)。两者共用一个 id 命名空间:
|
|
20
|
+
* 断言里 `predicate` 与 `subject.type` 都是这个空间里的名字,分开两个空间只会让引用含混。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 字面值的五种形态。断言客体如果是**值**,必是其中之一。 */
|
|
24
|
+
export const VALUE_FORMS = ['statement', 'quantity', 'formula', 'code', 'reference']
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 断言客体可以是**值**(上面五种),也可以是**另一个实例**(关系谓词的宾语,
|
|
28
|
+
* 例如「A 是 B 的一部分」)。`instance` 因此不是第六种「值形态」,而是关系谓词的对象形态。
|
|
29
|
+
*/
|
|
30
|
+
export const OBJECT_KINDS = [...VALUE_FORMS, 'instance']
|
|
31
|
+
|
|
32
|
+
/** 词条的两种角色。它们共用同一条生命周期(接纳 → 修订 → 黏性废止)。 */
|
|
33
|
+
export const LEXICON_KINDS = ['term', 'predicate']
|
|
34
|
+
|
|
35
|
+
/** id 的形状:小写字母开头的 slug。中英文之外的类型名一律不认,免得同一个概念有两种写法。 */
|
|
36
|
+
const ID_PATTERN = /^[a-z][a-z0-9_]{1,39}$/
|
|
37
|
+
|
|
38
|
+
/** 空词汇。`emptyState()` 与「日志里还没有任何本体事件」必须给出同一个形状。 */
|
|
39
|
+
export function emptyLexicon() {
|
|
40
|
+
return { terms: [], predicates: [] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
44
|
+
const text = (value) => (typeof value === 'string' ? value.trim() : '')
|
|
45
|
+
const clone = (value) => JSON.parse(JSON.stringify(value))
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 老账本里没有 `lexicon` 这个字段(那时还没有领域本体这回事),而且**将来还可能丢**——
|
|
49
|
+
* 投影缓存的形状只保证同版本内一致。所以每个消费者入口都先过一次它,少一次 `?? {}` 的抄写。
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeLexicon(raw) {
|
|
52
|
+
const lexicon = isPlainObject(raw) ? raw : {}
|
|
53
|
+
return {
|
|
54
|
+
terms: Array.isArray(lexicon.terms) ? lexicon.terms : [],
|
|
55
|
+
predicates: Array.isArray(lexicon.predicates) ? lexicon.predicates : [],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 在共享命名空间里找一个条目(概念或谓词),返回 `{ kind, entry }` 或 null。 */
|
|
60
|
+
export function findEntry(lexicon, id) {
|
|
61
|
+
const normalized = normalizeLexicon(lexicon)
|
|
62
|
+
if (text(id) === '') return null
|
|
63
|
+
const term = normalized.terms.find((item) => item.id === id)
|
|
64
|
+
if (term !== undefined) return { kind: 'term', entry: term }
|
|
65
|
+
const predicate = normalized.predicates.find((item) => item.id === id)
|
|
66
|
+
if (predicate !== undefined) return { kind: 'predicate', entry: predicate }
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 一个 id 是否已被占用(概念与谓词共用一个空间)。 */
|
|
71
|
+
function idTaken(lexicon, id) {
|
|
72
|
+
return findEntry(lexicon, id) !== null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 条目今天能不能被**新的**断言/引用使用:已废止的不行(存量事实照旧可读)。 */
|
|
76
|
+
function isUsable(entry) {
|
|
77
|
+
return isPlainObject(entry) && entry.status !== 'deprecated'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function problem(code, detail) {
|
|
81
|
+
return `${code}:${detail}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 概念之间的父链:返回值里带环就说明这份词汇已经不合法了(健康检查要说得出来)。 */
|
|
85
|
+
export function termChain(lexicon, id) {
|
|
86
|
+
const normalized = normalizeLexicon(lexicon)
|
|
87
|
+
const byId = new Map(normalized.terms.map((item) => [item.id, item]))
|
|
88
|
+
const chain = []
|
|
89
|
+
const seen = new Set()
|
|
90
|
+
let cursor = text(id)
|
|
91
|
+
while (cursor !== '') {
|
|
92
|
+
if (seen.has(cursor)) return { chain, cycle: cursor }
|
|
93
|
+
seen.add(cursor)
|
|
94
|
+
const entry = byId.get(cursor)
|
|
95
|
+
if (entry === undefined) return { chain, missing: cursor }
|
|
96
|
+
chain.push(cursor)
|
|
97
|
+
cursor = text(entry.parent)
|
|
98
|
+
}
|
|
99
|
+
return { chain, cycle: null, missing: null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 注册一个新概念要满足的条件。返回问题清单(空 = 通过)。
|
|
104
|
+
* 为什么**依据**必填:约定可以自愿,但不能无来由。依据不是事实证明,而是「这个约定
|
|
105
|
+
* 为什么被引入」;没有它,词汇表迟早变成模型的临时造词坊。
|
|
106
|
+
*/
|
|
107
|
+
export function validateTerm(lexicon, draft) {
|
|
108
|
+
const problems = []
|
|
109
|
+
const at = '概念'
|
|
110
|
+
if (!isPlainObject(draft)) return [problem('term_shape', `${at}必须是一个对象`)]
|
|
111
|
+
const id = text(draft.id)
|
|
112
|
+
if (!ID_PATTERN.test(id)) problems.push(problem('id_shape', `${at} id 要小写字母开头的 slug(字母/数字/下划线,≤40):收到「${id}」`))
|
|
113
|
+
else if (idTaken(lexicon, id)) problems.push(problem('id_taken', `id「${id}」已经被占用(概念与谓词共用一个命名空间)`))
|
|
114
|
+
if (text(draft.label) === '') problems.push(problem('label_required', `${at}要有名字`))
|
|
115
|
+
if (text(draft.gloss) === '') problems.push(problem('gloss_required', `${at}要有释义:一句话说清它指什么,不然引用它的人各读各的`))
|
|
116
|
+
if (text(draft.basis) === '') problems.push(problem('basis_required', `${at}要写依据:哪份材料、哪条事实或人说的哪句话让这个词成立`))
|
|
117
|
+
if (draft.aliases !== undefined && draft.aliases !== null) {
|
|
118
|
+
if (!Array.isArray(draft.aliases) || draft.aliases.some((alias) => typeof alias !== 'string')) problems.push(problem('aliases_shape', 'aliases 只能是字符串数组'))
|
|
119
|
+
}
|
|
120
|
+
const parent = text(draft.parent)
|
|
121
|
+
if (parent !== '') {
|
|
122
|
+
const found = findEntry(lexicon, parent)
|
|
123
|
+
if (found === null) problems.push(problem('parent_unknown', `父概念「${parent}」还没登记`))
|
|
124
|
+
else if (found.kind !== 'term') problems.push(problem('parent_not_term', `父概念「${parent}」是个谓词,is_a 只能连概念`))
|
|
125
|
+
else if (!isUsable(found.entry)) problems.push(problem('parent_deprecated', `父概念「${parent}」已废止`))
|
|
126
|
+
else {
|
|
127
|
+
const walk = termChain(lexicon, parent)
|
|
128
|
+
if (walk.cycle !== null || walk.missing !== null) problems.push(problem('parent_cycle', `父概念「${parent}」的父链已经成环或指空,不能再往上接`))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return problems
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 注册一个新谓词要满足的条件。
|
|
136
|
+
*
|
|
137
|
+
* 值域只有两种写法,没有第三种:`{ term }` 说宾语是**另一个概念的实例**,
|
|
138
|
+
* `{ form }` 说宾语是**一个字面值**并且形态固定。含糊的值域等于没有值域——
|
|
139
|
+
* 「什么都能填」的谓词,查询与冲突检查都拿它没办法。
|
|
140
|
+
*/
|
|
141
|
+
export function validatePredicate(lexicon, draft) {
|
|
142
|
+
const problems = []
|
|
143
|
+
const at = '谓词'
|
|
144
|
+
if (!isPlainObject(draft)) return [problem('predicate_shape', `${at}必须是一个对象`)]
|
|
145
|
+
const id = text(draft.id)
|
|
146
|
+
if (!ID_PATTERN.test(id)) problems.push(problem('id_shape', `${at} id 要小写字母开头的 slug(字母/数字/下划线,≤40):收到「${id}」`))
|
|
147
|
+
else if (idTaken(lexicon, id)) problems.push(problem('id_taken', `id「${id}」已经被占用(概念与谓词共用一个命名空间)`))
|
|
148
|
+
if (text(draft.label) === '') problems.push(problem('label_required', `${at}要有名字`))
|
|
149
|
+
if (text(draft.basis) === '') problems.push(problem('basis_required', `${at}要写依据`))
|
|
150
|
+
const domain = text(draft.domain)
|
|
151
|
+
if (domain !== '') {
|
|
152
|
+
const found = findEntry(lexicon, domain)
|
|
153
|
+
if (found === null) problems.push(problem('domain_unknown', `主词概念「${domain}」还没登记`))
|
|
154
|
+
else if (found.kind !== 'term') problems.push(problem('domain_not_term', `主词「${domain}」是个谓词,主词域只能是概念`))
|
|
155
|
+
else if (!isUsable(found.entry)) problems.push(problem('domain_deprecated', `主词概念「${domain}」已废止`))
|
|
156
|
+
}
|
|
157
|
+
const range = draft.range
|
|
158
|
+
if (!isPlainObject(range)) problems.push(problem('range_required', '谓词必须登记值域:{term} 或 {form}'))
|
|
159
|
+
else {
|
|
160
|
+
const term = text(range.term)
|
|
161
|
+
const form = text(range.form)
|
|
162
|
+
if (term !== '' && form !== '') problems.push(problem('range_ambiguous', '值域只能二选一:{term} 或 {form}'))
|
|
163
|
+
else if (term !== '') {
|
|
164
|
+
const found = findEntry(lexicon, term)
|
|
165
|
+
if (found === null) problems.push(problem('range_unknown', `宾语概念「${term}」还没登记`))
|
|
166
|
+
else if (found.kind !== 'term') problems.push(problem('range_not_term', `宾语「${term}」是个谓词,宾语域只能是概念`))
|
|
167
|
+
else if (!isUsable(found.entry)) problems.push(problem('range_deprecated', `宾语概念「${term}」已废止`))
|
|
168
|
+
} else if (form !== '') {
|
|
169
|
+
if (!VALUE_FORMS.includes(form)) problems.push(problem('range_form_unknown', `值形态「${form}」不认识(可用:${VALUE_FORMS.join(' / ')};关系谓词请用 {term})`))
|
|
170
|
+
if (range.unit !== undefined && range.unit !== null && typeof range.unit !== 'string') problems.push(problem('range_unit_shape', 'unit 只能是字符串'))
|
|
171
|
+
} else problems.push(problem('range_required', '值域要么给 {term},要么给 {form}'))
|
|
172
|
+
}
|
|
173
|
+
if (draft.functional !== undefined && draft.functional !== null && typeof draft.functional !== 'boolean') problems.push(problem('functional_shape', 'functional 只能是布尔值(单值=真)'))
|
|
174
|
+
return problems
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 客体值的可比形状:冲突检查与「同一件事」的判定都用它,而不是各写一套。 */
|
|
178
|
+
export function objectKey(object) {
|
|
179
|
+
if (!isPlainObject(object)) return ''
|
|
180
|
+
const kind = text(object.kind)
|
|
181
|
+
const value = object.value
|
|
182
|
+
if (kind === 'quantity') return `quantity:${Number(value)}:${text(object.unit)}`
|
|
183
|
+
return `${kind}:${text(String(value ?? ''))}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 主体的可比形状:类型 + 名称。类型缺失时只用名称——它照样能查出冲突,只是粗一档。 */
|
|
187
|
+
export function subjectKey(assertion) {
|
|
188
|
+
const subject = isPlainObject(assertion?.subject) ? assertion.subject : {}
|
|
189
|
+
return `${text(subject.type)}|${text(subject.id)}`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 客体形态与谓词值域是否相容。 */
|
|
193
|
+
function objectProblems(predicate, object) {
|
|
194
|
+
const problems = []
|
|
195
|
+
if (!isPlainObject(object)) return [problem('object_required', '断言要有宾语')]
|
|
196
|
+
const kind = text(object.kind)
|
|
197
|
+
if (!OBJECT_KINDS.includes(kind)) return [problem('object_kind_unknown', `宾语形态「${kind}」不认识(可用:${OBJECT_KINDS.join(' / ')})`)]
|
|
198
|
+
const range = isPlainObject(predicate.range) ? predicate.range : {}
|
|
199
|
+
const rangeTerm = text(range.term)
|
|
200
|
+
const rangeForm = text(range.form)
|
|
201
|
+
if (rangeTerm !== '') {
|
|
202
|
+
if (kind !== 'instance') return [problem('object_form_mismatch', `谓词「${predicate.id}」的宾语是概念「${rangeTerm}」的实例,宾语形态应为 instance`)]
|
|
203
|
+
const type = text(object.type)
|
|
204
|
+
if (type !== '' && type !== rangeTerm) return [problem('object_type_mismatch', `宾语实例的类型「${type}」与谓词值域「${rangeTerm}」不一致`)]
|
|
205
|
+
if (text(object.value) === '') return [problem('object_value_required', '宾语实例要有名称')]
|
|
206
|
+
return []
|
|
207
|
+
}
|
|
208
|
+
if (rangeForm !== '' && kind !== rangeForm) return [problem('object_form_mismatch', `谓词「${predicate.id}」的值形态是 ${rangeForm},宾语却是 ${kind}`)]
|
|
209
|
+
switch (kind) {
|
|
210
|
+
case 'statement':
|
|
211
|
+
if (text(object.value) === '') problems.push(problem('object_value_required', '陈述不能为空'))
|
|
212
|
+
else if (String(object.value).length > 2000) problems.push(problem('object_value_too_long', '陈述超过 2000 字:把它拆成断言,或把长文放证据里'))
|
|
213
|
+
break
|
|
214
|
+
case 'quantity':
|
|
215
|
+
if (typeof object.value !== 'number' || !Number.isFinite(object.value)) problems.push(problem('object_quantity_shape', '量形态的 value 必须是有限数值'))
|
|
216
|
+
if (text(object.unit) === '') problems.push(problem('object_unit_required', '量形态要带单位:没有单位的数不是量'))
|
|
217
|
+
break
|
|
218
|
+
case 'formula':
|
|
219
|
+
if (text(object.value) === '') problems.push(problem('object_value_required', '公式不能为空'))
|
|
220
|
+
break
|
|
221
|
+
case 'code':
|
|
222
|
+
if (text(object.value) === '') problems.push(problem('object_value_required', 'code 形态要指向工作区里的一个文件,不能为空'))
|
|
223
|
+
else if (/^([/\\]|[A-Za-z]:[\\/])/.test(text(object.value))) problems.push(problem('object_code_absolute', 'code 形态要写工作区内的相对路径:绝对路径换个工作区就没了'))
|
|
224
|
+
else if (text(object.value).split(/[/\\]/).includes('..')) problems.push(problem('object_code_escape', 'code 形态不许越出工作区(路径里出现了 ..)'))
|
|
225
|
+
break
|
|
226
|
+
case 'reference':
|
|
227
|
+
if (text(object.value) === '') problems.push(problem('object_value_required', '引用不能为空'))
|
|
228
|
+
break
|
|
229
|
+
default:
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
return problems
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 一条断言合不合这门语言。`lexicon` 是那一刻的词汇——**引用必须已经存在**(先登记后引用,
|
|
237
|
+
* 没有「隐式建档」这条捷径:那样词汇表会长出一堆没人认领的条目)。
|
|
238
|
+
*/
|
|
239
|
+
export function validateAssertion(lexicon, assertion) {
|
|
240
|
+
const problems = []
|
|
241
|
+
if (!isPlainObject(assertion)) return [problem('assertion_shape', '断言必须是一个对象')]
|
|
242
|
+
const predicateId = text(assertion.predicate)
|
|
243
|
+
if (predicateId === '') return [problem('predicate_required', '断言要写谓词')]
|
|
244
|
+
const found = findEntry(lexicon, predicateId)
|
|
245
|
+
if (found === null) return [problem('predicate_unknown', `谓词「${predicateId}」还没登记`)]
|
|
246
|
+
if (found.kind !== 'predicate') return [problem('predicate_not_predicate', `「${predicateId}」是个概念,不能当谓词用`)]
|
|
247
|
+
const predicate = found.entry
|
|
248
|
+
if (!isUsable(predicate)) problems.push(problem('predicate_deprecated', `谓词「${predicateId}」已废止:新断言不能再用它`))
|
|
249
|
+
const subject = assertion.subject
|
|
250
|
+
if (!isPlainObject(subject) || text(subject.id) === '') problems.push(problem('subject_required', '断言要有主体(至少一个名称)'))
|
|
251
|
+
else {
|
|
252
|
+
const domain = text(predicate.domain)
|
|
253
|
+
const type = text(subject.type)
|
|
254
|
+
if (domain !== '') {
|
|
255
|
+
if (type === '') problems.push(problem('subject_type_required', `谓词「${predicateId}」声明了主词域「${domain}」,主体要写明 type`))
|
|
256
|
+
else if (type !== domain) problems.push(problem('subject_type_mismatch', `主体类型「${type}」与主词域「${domain}」不一致`))
|
|
257
|
+
}
|
|
258
|
+
if (type !== '') {
|
|
259
|
+
const typeEntry = findEntry(lexicon, type)
|
|
260
|
+
if (typeEntry === null) problems.push(problem('subject_type_unknown', `主体类型「${type}」还没登记`))
|
|
261
|
+
else if (typeEntry.kind !== 'term') problems.push(problem('subject_type_not_term', `主体类型「${type}」是个谓词`))
|
|
262
|
+
else if (!isUsable(typeEntry.entry)) problems.push(problem('subject_type_deprecated', `主体类型「${type}」已废止`))
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
problems.push(...objectProblems(predicate, assertion.object))
|
|
266
|
+
if (assertion.qualifiers !== undefined && assertion.qualifiers !== null && !isPlainObject(assertion.qualifiers)) problems.push(problem('qualifiers_shape', 'qualifiers 只能是对象(限定条件:时间、工况、适用范围)'))
|
|
267
|
+
return problems
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 一条事实的**整组**断言是否自洽。除了逐条校验,还查「同一主体同一谓词给了两个值」——
|
|
272
|
+
* 单值谓词上这已经是自相矛盾,不该等到与别的事实比才发现。
|
|
273
|
+
*/
|
|
274
|
+
export function validateAssertions(lexicon, assertions) {
|
|
275
|
+
if (assertions === undefined || assertions === null) return []
|
|
276
|
+
if (!Array.isArray(assertions)) return [problem('assertions_shape', 'assertions 只能是数组')]
|
|
277
|
+
if (assertions.length === 0) return []
|
|
278
|
+
const problems = []
|
|
279
|
+
const seen = new Map()
|
|
280
|
+
for (const [index, assertion] of assertions.entries()) {
|
|
281
|
+
for (const item of validateAssertion(lexicon, assertion)) problems.push(`断言 ${index + 1} · ${item}`)
|
|
282
|
+
const key = `${text(assertion?.predicate)}\u0000${subjectKey(assertion)}`
|
|
283
|
+
const value = objectKey(assertion?.object)
|
|
284
|
+
if (seen.has(key) && seen.get(key) !== value) {
|
|
285
|
+
problems.push(problem('assertion_self_conflict', `同一事实里「${text(assertion?.predicate)}」在主体「${text(assertion?.subject?.id)}」上给了两个值:${seen.get(key)} 与 ${value}`))
|
|
286
|
+
}
|
|
287
|
+
seen.set(key, value)
|
|
288
|
+
}
|
|
289
|
+
return problems
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* **冲突是派生读数,不是存储对象**:两条**未撤回**的已确认事实落在同一个单值谓词、
|
|
294
|
+
* 同一主体、而客体不同,就是一对冲突。它只被**说出来**,不被裁决——哪条为真不是这里的事
|
|
295
|
+
* (去问证据与人)。所以这个函数没有副作用,也只读事实与词汇。
|
|
296
|
+
*
|
|
297
|
+
* 已废止的谓词照样参与:约束在被废止之前对那批事实是生效的,今天不算才是说假话。
|
|
298
|
+
*/
|
|
299
|
+
export function deriveConflicts(facts, lexicon) {
|
|
300
|
+
const normalized = normalizeLexicon(lexicon)
|
|
301
|
+
const functional = new Set(normalized.predicates.filter((item) => item.functional === true).map((item) => item.id))
|
|
302
|
+
if (functional.size === 0) return []
|
|
303
|
+
const buckets = new Map()
|
|
304
|
+
for (const fact of Array.isArray(facts) ? facts : []) {
|
|
305
|
+
if (!isPlainObject(fact)) continue
|
|
306
|
+
if (fact.review?.decision === 'retracted') continue
|
|
307
|
+
for (const assertion of Array.isArray(fact.assertions) ? fact.assertions : []) {
|
|
308
|
+
const predicate = text(assertion?.predicate)
|
|
309
|
+
if (!functional.has(predicate)) continue
|
|
310
|
+
const key = `${predicate}\u0000${subjectKey(assertion)}`
|
|
311
|
+
const rows = buckets.get(key) ?? []
|
|
312
|
+
rows.push({ fact: fact.id ?? null, value: objectKey(assertion.object), text: fact.text ?? null, level: fact.level ?? null })
|
|
313
|
+
buckets.set(key, rows)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const conflicts = []
|
|
317
|
+
for (const [key, rows] of buckets) {
|
|
318
|
+
const byValue = new Map()
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
if (!byValue.has(row.value)) byValue.set(row.value, [])
|
|
321
|
+
byValue.get(row.value).push(row)
|
|
322
|
+
}
|
|
323
|
+
if (byValue.size < 2) continue
|
|
324
|
+
const [predicate, subject] = key.split('\u0000')
|
|
325
|
+
conflicts.push({
|
|
326
|
+
predicate,
|
|
327
|
+
subject,
|
|
328
|
+
/** 每个取值给一条代表(第一个出现的那条事实),面板据此成对打开两侧。 */
|
|
329
|
+
sides: [...byValue.entries()]
|
|
330
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
331
|
+
.map(([value, list]) => ({ value, fact: list[0].fact, text: list[0].text, level: list[0].level, count: list.length })),
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
return conflicts.sort((a, b) => (a.predicate === b.predicate ? (a.subject < b.subject ? -1 : 1) : a.predicate < b.predicate ? -1 : 1))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* 词汇健康度(派生读数,不拦任何操作):悬空引用、父链成环、没人用的条目、以及
|
|
339
|
+
* **引用了已废止条目的事实**。最后一条要看得见——那些事实仍然有效(历史留着),
|
|
340
|
+
* 但引用它的人该知道这门语言已经变了。
|
|
341
|
+
*/
|
|
342
|
+
export function lexiconHealth(lexicon, facts) {
|
|
343
|
+
const normalized = normalizeLexicon(lexicon)
|
|
344
|
+
const issues = []
|
|
345
|
+
const usedTerms = new Set()
|
|
346
|
+
const usedPredicates = new Set()
|
|
347
|
+
for (const term of normalized.terms) {
|
|
348
|
+
if (text(term.parent) !== '') usedTerms.add(text(term.parent))
|
|
349
|
+
const walk = termChain(normalized, term.id)
|
|
350
|
+
if (walk.cycle !== null) issues.push({ kind: 'cycle', severity: 'warning', id: term.id, detail: `is_a 父链成环(回到「${walk.cycle}」)` })
|
|
351
|
+
else if (walk.missing !== null) issues.push({ kind: 'dangling_parent', severity: 'warning', id: term.id, detail: `父概念「${walk.missing}」不在词汇里` })
|
|
352
|
+
}
|
|
353
|
+
for (const predicate of normalized.predicates) {
|
|
354
|
+
const domain = text(predicate.domain)
|
|
355
|
+
const range = isPlainObject(predicate.range) ? predicate.range : {}
|
|
356
|
+
if (domain !== '') {
|
|
357
|
+
usedTerms.add(domain)
|
|
358
|
+
if (findEntry(normalized, domain) === null) issues.push({ kind: 'dangling_domain', severity: 'warning', id: predicate.id, detail: `主词域「${domain}」不在词汇里` })
|
|
359
|
+
}
|
|
360
|
+
const rangeTerm = text(range.term)
|
|
361
|
+
if (rangeTerm !== '') {
|
|
362
|
+
usedTerms.add(rangeTerm)
|
|
363
|
+
if (findEntry(normalized, rangeTerm) === null) issues.push({ kind: 'dangling_range', severity: 'warning', id: predicate.id, detail: `宾语域「${rangeTerm}」不在词汇里` })
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const fact of Array.isArray(facts) ? facts : []) {
|
|
367
|
+
const retracted = fact?.review?.decision === 'retracted'
|
|
368
|
+
for (const assertion of Array.isArray(fact?.assertions) ? fact.assertions : []) {
|
|
369
|
+
const predicateId = text(assertion?.predicate)
|
|
370
|
+
if (predicateId === '') continue
|
|
371
|
+
usedPredicates.add(predicateId)
|
|
372
|
+
const type = text(assertion?.subject?.type)
|
|
373
|
+
if (type !== '') usedTerms.add(type)
|
|
374
|
+
const predicate = findEntry(normalized, predicateId)
|
|
375
|
+
if (predicate === null) {
|
|
376
|
+
if (!retracted) issues.push({ kind: 'unknown_predicate_in_fact', severity: 'warning', id: String(fact.id ?? ''), detail: `事实里用了没登记的谓词「${predicateId}」` })
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
if (predicate.entry.status === 'deprecated' && !retracted) issues.push({ kind: 'deprecated_in_use', severity: 'info', id: predicateId, detail: `已废止的「${predicateId}」仍被事实「${String(fact.id ?? '')}」引用(记录保留,不再新增)` })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const term of normalized.terms) {
|
|
383
|
+
if (term.status === 'deprecated') continue
|
|
384
|
+
if (!usedTerms.has(term.id) && !normalized.predicates.some((item) => text(item.domain) === term.id || text(item.range?.term) === term.id)) {
|
|
385
|
+
issues.push({ kind: 'unused', severity: 'info', id: term.id, detail: '还没有任何谓词或事实引用它' })
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const predicate of normalized.predicates) {
|
|
389
|
+
if (predicate.status === 'deprecated') continue
|
|
390
|
+
if (!usedPredicates.has(predicate.id)) issues.push({ kind: 'unused', severity: 'info', id: predicate.id, detail: '还没有任何事实用它' })
|
|
391
|
+
}
|
|
392
|
+
return issues.sort((a, b) => (a.kind === b.kind ? (a.id < b.id ? -1 : 1) : a.kind < b.kind ? -1 : 1))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* **图是投影,不是存储**:同一份账本,永远算出同一组节点、同一组边、同一套默认坐标。
|
|
397
|
+
*
|
|
398
|
+
* 两层分开画(设计里那条最主要的错误就是把它们画成同一种边):
|
|
399
|
+
* · 本体层:概念是节点、`is_a` 与谓词是边;
|
|
400
|
+
* · 实体层:实例是节点、断言是边,边上带那条事实的认识论读数(等级 / 复核态)。
|
|
401
|
+
*
|
|
402
|
+
* 坐标是**确定性布局**:按 `is_a` 深度分层,层内按 id 排序,实例排在最下面一行行铺开。
|
|
403
|
+
* 用户临时拖拽只改当前界面——布局不是知识,不进账本。
|
|
404
|
+
*/
|
|
405
|
+
export function graphProjection(state) {
|
|
406
|
+
const lexicon = normalizeLexicon(state?.lexicon)
|
|
407
|
+
const facts = Array.isArray(state?.facts) ? state.facts : []
|
|
408
|
+
const nodes = []
|
|
409
|
+
const edges = []
|
|
410
|
+
const termById = new Map(lexicon.terms.map((item) => [item.id, item]))
|
|
411
|
+
const predicateById = new Map(lexicon.predicates.map((item) => [item.id, item]))
|
|
412
|
+
const uses = new Map()
|
|
413
|
+
for (const fact of facts) {
|
|
414
|
+
for (const assertion of Array.isArray(fact?.assertions) ? fact.assertions : []) {
|
|
415
|
+
const type = text(assertion?.subject?.type)
|
|
416
|
+
if (type !== '') uses.set(type, (uses.get(type) ?? 0) + 1)
|
|
417
|
+
const predicateId = text(assertion?.predicate)
|
|
418
|
+
if (predicateId !== '') uses.set(predicateId, (uses.get(predicateId) ?? 0) + 1)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const depthOf = (term) => {
|
|
422
|
+
let depth = 0
|
|
423
|
+
let cursor = text(term.parent)
|
|
424
|
+
const seen = new Set([term.id])
|
|
425
|
+
while (cursor !== '' && !seen.has(cursor)) {
|
|
426
|
+
seen.add(cursor)
|
|
427
|
+
depth += 1
|
|
428
|
+
cursor = text(termById.get(cursor)?.parent)
|
|
429
|
+
}
|
|
430
|
+
return depth
|
|
431
|
+
}
|
|
432
|
+
const terms = [...lexicon.terms].sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
433
|
+
const predicates = [...lexicon.predicates].sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
434
|
+
for (const term of terms) nodes.push({ id: `term:${term.id}`, kind: 'concept', layer: 'ontology', ref: term.id, label: term.label ?? term.id, status: term.status ?? 'admitted', parent: text(term.parent) === '' ? null : text(term.parent), uses: uses.get(term.id) ?? 0, depth: depthOf(term) })
|
|
435
|
+
const formsUsed = VALUE_FORMS.filter((form) => predicates.some((item) => text(item.range?.form) === form))
|
|
436
|
+
for (const [index, form] of formsUsed.entries()) nodes.push({ id: `form:${form}`, kind: 'value_type', layer: 'ontology', ref: form, label: form, status: 'admitted', uses: 0, depth: 0, formIndex: index })
|
|
437
|
+
for (const term of terms) {
|
|
438
|
+
if (text(term.parent) === '') continue
|
|
439
|
+
if (!termById.has(text(term.parent))) continue
|
|
440
|
+
edges.push({ id: `is_a:${term.id}`, kind: 'is_a', layer: 'ontology', predicate: null, label: 'is_a', from: `term:${term.id}`, to: `term:${term.parent}`, status: term.status ?? 'admitted' })
|
|
441
|
+
}
|
|
442
|
+
for (const predicate of predicates) {
|
|
443
|
+
const range = isPlainObject(predicate.range) ? predicate.range : {}
|
|
444
|
+
const rangeTerm = text(range.term)
|
|
445
|
+
const form = text(range.form)
|
|
446
|
+
const to = rangeTerm !== '' ? `term:${rangeTerm}` : form !== '' ? `form:${form}` : null
|
|
447
|
+
if (to !== null) edges.push({ id: `predicate:${predicate.id}`, kind: 'predicate', layer: 'ontology', predicate: predicate.id, label: predicate.label ?? predicate.id, from: text(predicate.domain) === '' ? null : `term:${text(predicate.domain)}`, to, status: predicate.status ?? 'admitted', functional: predicate.functional === true })
|
|
448
|
+
}
|
|
449
|
+
const instances = new Map()
|
|
450
|
+
for (const fact of facts) {
|
|
451
|
+
const status = fact?.review?.decision === 'retracted' ? 'retracted' : fact?.refuted === true ? 'refuted' : 'live'
|
|
452
|
+
for (const assertion of Array.isArray(fact?.assertions) ? fact.assertions : []) {
|
|
453
|
+
const subject = isPlainObject(assertion?.subject) ? assertion.subject : {}
|
|
454
|
+
const subjectId = text(subject.id)
|
|
455
|
+
if (subjectId === '') continue
|
|
456
|
+
const key = `${text(subject.type)}|${subjectId}`
|
|
457
|
+
if (!instances.has(key)) instances.set(key, { id: key, kind: 'instance', layer: 'entity', ref: subjectId, label: subjectId, type: text(subject.type) === '' ? null : text(subject.type), facts: [] })
|
|
458
|
+
instances.get(key).facts.push(fact.id ?? null)
|
|
459
|
+
const predicateId = text(assertion?.predicate)
|
|
460
|
+
const object = isPlainObject(assertion?.object) ? assertion.object : {}
|
|
461
|
+
const objectKind = text(object.kind)
|
|
462
|
+
let to = null
|
|
463
|
+
if (objectKind === 'instance') {
|
|
464
|
+
const objectLabel = text(object.value)
|
|
465
|
+
const objectType = text(object.type) === '' ? text(predicateById.get(predicateId)?.range?.term) : text(object.type)
|
|
466
|
+
const objectKeyId = `${objectType}|${objectLabel}`
|
|
467
|
+
if (objectLabel !== '') {
|
|
468
|
+
if (!instances.has(objectKeyId)) instances.set(objectKeyId, { id: objectKeyId, kind: 'instance', layer: 'entity', ref: objectLabel, label: objectLabel, type: objectType === '' ? null : objectType, facts: [] })
|
|
469
|
+
instances.get(objectKeyId).facts.push(fact.id ?? null)
|
|
470
|
+
to = objectKeyId
|
|
471
|
+
}
|
|
472
|
+
} else if (predicateId !== '') {
|
|
473
|
+
const literalId = `${predicateId}:${objectKey(assertion.object)}`
|
|
474
|
+
if (!instances.has(literalId)) instances.set(literalId, { id: literalId, kind: 'literal', layer: 'entity', ref: objectKey(assertion.object), label: formatObject(object), type: null, facts: [] })
|
|
475
|
+
instances.get(literalId).facts.push(fact.id ?? null)
|
|
476
|
+
to = literalId
|
|
477
|
+
}
|
|
478
|
+
if (to !== null) edges.push({ id: `assertion:${fact.id ?? ''}:${predicateId}:${subjectKey(assertion)}`, kind: 'assertion', layer: 'entity', predicate: predicateId, label: text(lexicon.predicates.find((item) => item.id === predicateId)?.label) || predicateId, from: key, to, status, level: fact.level ?? null, fact: fact.id ?? null, scope: fact.scope ?? null, claim: text(fact.hypothesis) === '' ? null : text(fact.hypothesis) })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const instance of [...instances.values()].sort((a, b) => (a.id < b.id ? -1 : 1))) nodes.push(instance)
|
|
482
|
+
/**
|
|
483
|
+
* **确定性布局:按层分段,层内折行。**
|
|
484
|
+
*
|
|
485
|
+
* 「层」= `is_a` 深度(概念)、值形态、实例/字面值各成一段,段与段依次向下排。
|
|
486
|
+
* 关键的一步是**折行**:早先每一层只铺一行,于是一堆没有父概念的根被排成一条
|
|
487
|
+
* 极宽极扁的带子(真数据:23 个节点铺出 3800×180),任何视口里都只能缩到看不清。
|
|
488
|
+
* 折行之后同一层在有限宽度内往下堆,整体接近屏幕比例,读得清。
|
|
489
|
+
*
|
|
490
|
+
* 它仍然是**纯函数、确定性**:同一份账本永远算出同一套坐标(层内按 id 排,不靠遍历顺序)。
|
|
491
|
+
*/
|
|
492
|
+
const COLUMN = 220
|
|
493
|
+
const ROW = 132
|
|
494
|
+
const PER_ROW = 6
|
|
495
|
+
let cursorY = 0
|
|
496
|
+
const band = (list) => {
|
|
497
|
+
const top = cursorY
|
|
498
|
+
cursorY += Math.max(1, Math.ceil(list.length / PER_ROW)) * ROW
|
|
499
|
+
return top
|
|
500
|
+
}
|
|
501
|
+
const place = (list, top) => {
|
|
502
|
+
list.forEach((node, index) => {
|
|
503
|
+
node.x = (index % PER_ROW) * COLUMN
|
|
504
|
+
node.y = top + Math.floor(index / PER_ROW) * ROW
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
const byDepth = new Map()
|
|
508
|
+
for (const node of nodes.filter((item) => item.kind === 'concept')) {
|
|
509
|
+
const list = byDepth.get(node.depth) ?? []
|
|
510
|
+
list.push(node)
|
|
511
|
+
byDepth.set(node.depth, list)
|
|
512
|
+
}
|
|
513
|
+
/** 深度小的在上(父在上、子在下),同深度内按 id——两条都为了确定性。 */
|
|
514
|
+
for (const depth of [...byDepth.keys()].sort((left, right) => left - right)) {
|
|
515
|
+
const list = byDepth.get(depth)
|
|
516
|
+
place(list, band(list))
|
|
517
|
+
}
|
|
518
|
+
const forms = nodes.filter((item) => item.kind === 'value_type')
|
|
519
|
+
if (forms.length > 0) place(forms, band(forms))
|
|
520
|
+
const leaves = nodes.filter((item) => item.kind === 'instance' || item.kind === 'literal')
|
|
521
|
+
if (leaves.length > 0) place(leaves, band(leaves))
|
|
522
|
+
const width = Math.max(...nodes.map((node) => (typeof node.x === 'number' ? node.x : 0)), 0) + COLUMN
|
|
523
|
+
const height = Math.max(...nodes.map((node) => (typeof node.y === 'number' ? node.y : 0)), 0) + ROW
|
|
524
|
+
/**
|
|
525
|
+
* 返回的是**纯语义**:节点、边、包围盒。视口该画哪一块、先画谁、怎么缩放,
|
|
526
|
+
* 都是渲染层的事(现在是 React Flow)——投影不再替渲染做决定。
|
|
527
|
+
*/
|
|
528
|
+
return { nodes, edges, bounds: { width, height } }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 宾语的一行人话(货架、面板、卡片共用同一句话,免得两处各写一套)。 */
|
|
532
|
+
export function formatObject(object) {
|
|
533
|
+
if (!isPlainObject(object)) return ''
|
|
534
|
+
const kind = text(object.kind)
|
|
535
|
+
const value = object.value
|
|
536
|
+
if (kind === 'quantity') return `${String(value)}${text(object.unit) === '' ? '' : ` ${text(object.unit)}`}`
|
|
537
|
+
return String(value ?? '')
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** 一条断言的一行人话:`主体 · 谓词 = 宾语`。 */
|
|
541
|
+
export function formatAssertion(lexicon, assertion) {
|
|
542
|
+
if (!isPlainObject(assertion)) return ''
|
|
543
|
+
const predicate = findEntry(lexicon, text(assertion.predicate))
|
|
544
|
+
const label = predicate === null ? text(assertion.predicate) : text(predicate.entry.label) || text(assertion.predicate)
|
|
545
|
+
const subject = isPlainObject(assertion.subject) ? text(assertion.subject.id) : ''
|
|
546
|
+
return `${subject} · ${label} = ${formatObject(assertion.object)}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 把一条本体事件折进词汇(返回**新**词汇;调用方通常是已经克隆过状态的折法)。
|
|
551
|
+
*
|
|
552
|
+
* 生命周期只有三个动作,和设计里那三条一一对应:
|
|
553
|
+
* · `term-added` / `predicate-added` —— 带依据接纳;
|
|
554
|
+
* · `term-revised` / `predicate-revised` —— **只许改展示信息**(名字、释义、别名)。
|
|
555
|
+
* 语义变化(含义、主词域、值域、单值性)不许走这条路:换 id 重新注册。
|
|
556
|
+
* 稳定 id 的含义在历史上悄悄改变,是对所有旧事实说假话。
|
|
557
|
+
* · `term-deprecated` / `predicate-deprecated` —— **黏性废止**:没有复活这条路,
|
|
558
|
+
* 要恢复语义就注册新条目。存量事实继续读得通,新断言不许再引用。
|
|
559
|
+
*/
|
|
560
|
+
export function applyLexiconMutation(lexicon, mutation, at) {
|
|
561
|
+
const next = normalizeLexicon(lexicon)
|
|
562
|
+
const terms = next.terms.map((item) => ({ ...item }))
|
|
563
|
+
const predicates = next.predicates.map((item) => ({ ...item }))
|
|
564
|
+
const output = { terms, predicates }
|
|
565
|
+
/**
|
|
566
|
+
* 事件名带命名空间(`ontology/term_added`),而这里只认动词本身——
|
|
567
|
+
* 归一放在一处,而不是让每个调用方各剥一次前缀(剥漏一处,那几个事件就会静默不生效)。
|
|
568
|
+
*/
|
|
569
|
+
const kind = text(mutation?.t).replace(/^ontology\//, '')
|
|
570
|
+
const id = text(mutation?.id)
|
|
571
|
+
const collection = kind === undefined ? [] : kind.startsWith('term') ? terms : kind.startsWith('predicate') ? predicates : []
|
|
572
|
+
const setRevision = (entry, note) => {
|
|
573
|
+
entry.revisions = Array.isArray(entry.revisions) ? entry.revisions.slice() : []
|
|
574
|
+
entry.revisions.push({ version: entry.version ?? 1, label: entry.label ?? null, gloss: entry.gloss ?? null, aliases: Array.isArray(entry.aliases) ? entry.aliases.slice() : null, reason: note, at })
|
|
575
|
+
}
|
|
576
|
+
switch (kind) {
|
|
577
|
+
case 'term_added': {
|
|
578
|
+
terms.push({ id, label: mutation.label ?? id, gloss: mutation.gloss ?? '', aliases: Array.isArray(mutation.aliases) ? mutation.aliases.slice() : [], parent: text(mutation.parent) === '' ? null : text(mutation.parent), status: 'admitted', version: 1, at, basis: mutation.basis ?? null, by: mutation.by ?? null, revisions: [], deprecated: null })
|
|
579
|
+
break
|
|
580
|
+
}
|
|
581
|
+
case 'predicate_added': {
|
|
582
|
+
predicates.push({
|
|
583
|
+
id,
|
|
584
|
+
label: mutation.label ?? id,
|
|
585
|
+
gloss: mutation.gloss ?? '',
|
|
586
|
+
domain: text(mutation.domain) === '' ? null : text(mutation.domain),
|
|
587
|
+
range: isPlainObject(mutation.range) ? clone(mutation.range) : null,
|
|
588
|
+
functional: mutation.functional === true,
|
|
589
|
+
status: 'admitted',
|
|
590
|
+
version: 1,
|
|
591
|
+
at,
|
|
592
|
+
basis: mutation.basis ?? null,
|
|
593
|
+
by: mutation.by ?? null,
|
|
594
|
+
revisions: [],
|
|
595
|
+
deprecated: null,
|
|
596
|
+
})
|
|
597
|
+
break
|
|
598
|
+
}
|
|
599
|
+
case 'term_revised': {
|
|
600
|
+
const entry = terms.find((item) => item.id === id)
|
|
601
|
+
if (entry === undefined || entry.status === 'deprecated') break
|
|
602
|
+
setRevision(entry, mutation.reason ?? null)
|
|
603
|
+
if (mutation.label !== undefined) entry.label = mutation.label
|
|
604
|
+
if (mutation.gloss !== undefined) entry.gloss = mutation.gloss
|
|
605
|
+
if (mutation.aliases !== undefined) entry.aliases = Array.isArray(mutation.aliases) ? mutation.aliases.slice() : []
|
|
606
|
+
entry.version = (entry.version ?? 1) + 1
|
|
607
|
+
entry.at = at
|
|
608
|
+
break
|
|
609
|
+
}
|
|
610
|
+
case 'predicate_revised': {
|
|
611
|
+
const entry = predicates.find((item) => item.id === id)
|
|
612
|
+
if (entry === undefined || entry.status === 'deprecated') break
|
|
613
|
+
setRevision(entry, mutation.reason ?? null)
|
|
614
|
+
if (mutation.label !== undefined) entry.label = mutation.label
|
|
615
|
+
if (mutation.gloss !== undefined) entry.gloss = mutation.gloss
|
|
616
|
+
entry.version = (entry.version ?? 1) + 1
|
|
617
|
+
entry.at = at
|
|
618
|
+
break
|
|
619
|
+
}
|
|
620
|
+
case 'term_deprecated':
|
|
621
|
+
case 'predicate_deprecated': {
|
|
622
|
+
const entry = collection.find((item) => item.id === id)
|
|
623
|
+
if (entry === undefined || entry.status === 'deprecated') break
|
|
624
|
+
entry.status = 'deprecated'
|
|
625
|
+
entry.deprecated = { reason: mutation.reason ?? null, at }
|
|
626
|
+
break
|
|
627
|
+
}
|
|
628
|
+
default:
|
|
629
|
+
return next
|
|
630
|
+
}
|
|
631
|
+
return output
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* **领域词汇的货架**(`clear/ontology/domain.md` 的正文)。
|
|
636
|
+
*
|
|
637
|
+
* 为什么它必须是一份纯函数:同一份词汇与事实,谁渲染都得同一串字节——
|
|
638
|
+
* 幂等写盘靠它(内容没变就不重写,文件时间戳是给人的读数),测试也靠它钉住。
|
|
639
|
+
*
|
|
640
|
+
* 三件事按顺序说:有什么词(概念 / 谓词)、它们长什么样(图)、用起来什么情况(引用与冲突)。
|
|
641
|
+
* **不写"权威"二字就够了吗**:不够——所以抬头先写明这一份是读面,
|
|
642
|
+
* 改它不会改词汇,词汇只认账本事件。
|
|
643
|
+
*/
|
|
644
|
+
export function describeDomainShelf(lexicon, facts, hypotheses = []) {
|
|
645
|
+
const normalized = normalizeLexicon(lexicon)
|
|
646
|
+
const rows = Array.isArray(facts) ? facts : []
|
|
647
|
+
const terms = [...normalized.terms].sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
648
|
+
const predicates = [...normalized.predicates].sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
649
|
+
const usage = new Map()
|
|
650
|
+
for (const fact of rows) {
|
|
651
|
+
for (const assertion of Array.isArray(fact?.assertions) ? fact.assertions : []) {
|
|
652
|
+
const predicate = text(assertion?.predicate)
|
|
653
|
+
if (predicate !== '') usage.set(predicate, (usage.get(predicate) ?? 0) + 1)
|
|
654
|
+
const type = text(assertion?.subject?.type)
|
|
655
|
+
if (type !== '') usage.set(type, (usage.get(type) ?? 0) + 1)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const conflicts = deriveConflicts(rows, normalized)
|
|
659
|
+
const lines = [
|
|
660
|
+
'# 领域本体(项目词汇)',
|
|
661
|
+
'',
|
|
662
|
+
'> 概念与谓词由账本里的本体事件折出来;**这一份是读面,不是权威**——改它不会改词汇。',
|
|
663
|
+
'> 词条只能经具名动词增删改:注册要带依据、修订留版本、废止留缘由且**不会删除**。',
|
|
664
|
+
'> 语义变化(含义、主词域、值域、单值性)必须**换 id**:稳定 id 的含义不许在历史上悄悄改变。',
|
|
665
|
+
'',
|
|
666
|
+
]
|
|
667
|
+
if (terms.length === 0 && predicates.length === 0) {
|
|
668
|
+
lines.push('(还没有词条。先注册概念与谓词,再让假设带上断言——引用不存在的词会在落账之前被拒。)', '')
|
|
669
|
+
return `${lines.join('\n')}`
|
|
670
|
+
}
|
|
671
|
+
lines.push(`## 概念(${terms.length})`, '')
|
|
672
|
+
if (terms.length === 0) lines.push('(无)', '')
|
|
673
|
+
else {
|
|
674
|
+
lines.push('| id | 名称 | 释义 | 父概念 | 状态 | 引用 | 依据 |', '|---|---|---|---|---|---|---|')
|
|
675
|
+
for (const term of terms) {
|
|
676
|
+
lines.push(`| \`${term.id}\` | ${escapeCell(term.label ?? term.id)} | ${escapeCell(term.gloss ?? '')} | ${term.parent === null || term.parent === undefined ? '—' : `\`${term.parent}\``} | ${term.status === 'deprecated' ? '**已废止**' : '已接纳'} | ${usage.get(term.id) ?? 0} | ${escapeCell(term.basis ?? '—')} |`)
|
|
677
|
+
}
|
|
678
|
+
lines.push('')
|
|
679
|
+
}
|
|
680
|
+
lines.push(`## 谓词(${predicates.length})`, '')
|
|
681
|
+
if (predicates.length === 0) lines.push('(无)', '')
|
|
682
|
+
else {
|
|
683
|
+
lines.push('| id | 名称 | 主词域 | 值域 | 单值 | 状态 | 引用 | 依据 |', '|---|---|---|---|---|---|---|---|')
|
|
684
|
+
for (const predicate of predicates) {
|
|
685
|
+
const range = isPlainObject(predicate.range) ? predicate.range : {}
|
|
686
|
+
const rangeText = text(range.term) !== '' ? `概念 \`${text(range.term)}\`` : `值形态 \`${text(range.form)}\`${text(range.unit) === '' ? '' : `(${text(range.unit)})`}`
|
|
687
|
+
lines.push(`| \`${predicate.id}\` | ${escapeCell(predicate.label ?? predicate.id)} | ${text(predicate.domain) === '' ? '—' : `\`${text(predicate.domain)}\``} | ${rangeText} | ${predicate.functional === true ? '是' : '否'} | ${predicate.status === 'deprecated' ? '**已废止**' : '已接纳'} | ${usage.get(predicate.id) ?? 0} | ${escapeCell(predicate.basis ?? '—')} |`)
|
|
688
|
+
}
|
|
689
|
+
lines.push('')
|
|
690
|
+
}
|
|
691
|
+
const mermaid = describeDomainGraph(normalized)
|
|
692
|
+
if (mermaid !== '') lines.push('## 图', '', mermaid, '')
|
|
693
|
+
const deprecated = [...terms, ...predicates].filter((entry) => entry.status === 'deprecated')
|
|
694
|
+
if (deprecated.length > 0) {
|
|
695
|
+
lines.push('## 已废止(记录保留,新断言不许再引用)', '')
|
|
696
|
+
for (const entry of deprecated) lines.push(`- \`${entry.id}\`:${escapeCell(entry.deprecated?.reason ?? '(未写缘由)')}`)
|
|
697
|
+
lines.push('')
|
|
698
|
+
}
|
|
699
|
+
const typed = rows.filter((fact) => Array.isArray(fact?.assertions) && fact.assertions.length > 0).length
|
|
700
|
+
const propositions = Array.isArray(hypotheses) ? hypotheses : []
|
|
701
|
+
const typedPropositions = propositions.filter((item) => Array.isArray(item?.assertions) && item.assertions.length > 0).length
|
|
702
|
+
lines.push('## 使用', '', `- 已升格事实里 ${typed}/${rows.length} 条带类型化断言;流转中的命题里 ${typedPropositions}/${propositions.length} 条带断言(未升格,不计入「引用」列)。`)
|
|
703
|
+
if (conflicts.length > 0) {
|
|
704
|
+
lines.push(`- **冲突 ${conflicts.length} 对**(只暴露,不裁决):`)
|
|
705
|
+
for (const conflict of conflicts) {
|
|
706
|
+
lines.push(` - \`${conflict.predicate}\` · ${escapeCell(conflict.subject)}:${conflict.sides.map((side) => `${side.fact ?? '?'}(${side.value})`).join(' 对 ')}`)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return `${lines.join('\n')}`
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** 一个表格单元格里的竖线与换行会毁掉整张表,先逃掉。 */
|
|
713
|
+
function escapeCell(value) {
|
|
714
|
+
return String(value ?? '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 本体图的 Mermaid 文本(货架里那段)。刻意只画**本体层**:
|
|
719
|
+
* 实体层(实例与断言)在面板上按事实逐条看更清楚,把它塞进一张 Mermaid 会把概念淹没。
|
|
720
|
+
*/
|
|
721
|
+
export function describeDomainGraph(lexicon) {
|
|
722
|
+
const normalized = normalizeLexicon(lexicon)
|
|
723
|
+
if (normalized.terms.length === 0 && normalized.predicates.length === 0) return ''
|
|
724
|
+
const lines = ['```mermaid', 'flowchart LR']
|
|
725
|
+
const conceptId = (id) => `c_${String(id).replace(/[^A-Za-z0-9_]/g, '_')}`
|
|
726
|
+
const seen = new Set()
|
|
727
|
+
for (const term of [...normalized.terms].sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
728
|
+
if (seen.has(conceptId(term.id))) continue
|
|
729
|
+
seen.add(conceptId(term.id))
|
|
730
|
+
lines.push(` ${conceptId(term.id)}["${escapeCell(term.label ?? term.id)}"]`)
|
|
731
|
+
}
|
|
732
|
+
for (const predicate of [...normalized.predicates].sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
733
|
+
const range = isPlainObject(predicate.range) ? predicate.range : {}
|
|
734
|
+
const target = text(range.term) !== '' ? conceptId(text(range.term)) : `v_${text(range.form)}`
|
|
735
|
+
if (text(range.term) === '' && text(range.form) !== '' && !seen.has(`v_${text(range.form)}`)) {
|
|
736
|
+
seen.add(`v_${text(range.form)}`)
|
|
737
|
+
lines.push(` v_${text(range.form)}(["${text(range.form)}"])`)
|
|
738
|
+
}
|
|
739
|
+
const source = text(predicate.domain) === '' ? null : conceptId(text(predicate.domain))
|
|
740
|
+
if (source === null) {
|
|
741
|
+
/** 没声明主词域的谓词没有源头可画:给一个显式的「任意主体」节点,别画成自环。 */
|
|
742
|
+
if (!seen.has('v_any')) {
|
|
743
|
+
seen.add('v_any')
|
|
744
|
+
lines.push(' v_any(["任意主体"])')
|
|
745
|
+
}
|
|
746
|
+
lines.push(` v_any -->|"${text(predicate.id)}"| ${target}`)
|
|
747
|
+
} else {
|
|
748
|
+
lines.push(` ${source} -->|"${text(predicate.id)}${predicate.functional === true ? ' · 单值' : ''}"| ${target}`)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
for (const term of [...normalized.terms].sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
|
752
|
+
const parent = text(term.parent)
|
|
753
|
+
if (parent === '') continue
|
|
754
|
+
lines.push(` ${conceptId(term.id)} -.->|is_a| ${conceptId(parent)}`)
|
|
755
|
+
}
|
|
756
|
+
lines.push('```', '')
|
|
757
|
+
return lines.join('\n')
|
|
758
|
+
}
|