dsh-memu-memory 1.2.1 → 1.2.2
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/AGENTS.md +1 -1
- package/README.md +12 -5
- package/README.zh.md +4 -2
- package/docs/contributing.md +16 -3
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -4
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +2 -2
- package/lib/client.js +1361 -146
- package/lib/consolidation-worker.js +30 -8
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1252 -91
- package/lib/cycle.js +3 -0
- package/lib/embedding-download.js +39 -4
- package/lib/engine.js +6 -3
- package/lib/global-graph.js +282 -0
- package/lib/graph.js +51 -9
- package/lib/inbound.js +19 -0
- package/lib/inject.js +277 -17
- package/lib/lexicon.js +1115 -0
- package/lib/local-vector-service.js +8 -6
- package/lib/managed-runtime.js +33 -2
- package/lib/memory.js +282 -17
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +32 -3
- package/lib/prompts/global-curator.md +76 -19
- package/lib/prompts/private-consolidation.coding.md +4 -1
- package/lib/prompts/private-consolidation.md +3 -1
- package/lib/prompts/private-consolidation.mixed.md +3 -1
- package/lib/prompts/private-consolidation.rp.md +3 -1
- package/lib/prompts.js +1 -1
- package/lib/rpc.js +577 -9
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +36 -18
- package/lib/tools.js +168 -10
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +1 -1
- package/scripts/acceptance-local.mjs +8 -5
- package/scripts/curator-pair-calibrate.mjs +259 -0
- package/scripts/lexicon-calibrate.mjs +230 -0
- package/scripts/lexicon-dedupe.mjs +318 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b2.test.mjs +2 -2
- package/tests/client-export.test.mjs +520 -0
- package/tests/client-render.test.mjs +19 -3
- package/tests/consolidation-fork.test.mjs +14 -12
- package/tests/consolidation-snapshot.test.mjs +39 -0
- package/tests/curator-absorb.test.mjs +443 -0
- package/tests/curator-calibrate.test.mjs +173 -0
- package/tests/curator-clusters.test.mjs +281 -0
- package/tests/curator-commit.test.mjs +52 -5
- package/tests/curator-evidence.test.mjs +102 -0
- package/tests/curator-link.test.mjs +316 -0
- package/tests/curator-pairing.test.mjs +252 -0
- package/tests/curator-probe.test.mjs +15 -6
- package/tests/curator-review-mode.test.mjs +314 -0
- package/tests/disclosure.test.mjs +2 -1
- package/tests/fixtures/curator-pairs/labeled.v1.jsonl +90 -0
- package/tests/fixtures/lexicon-stub-hooks.mjs +15 -0
- package/tests/fixtures/lexicon-stub.mjs +284 -0
- package/tests/graph-cascade.test.mjs +35 -2
- package/tests/graph.test.mjs +96 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/inbound.test.mjs +6 -3
- package/tests/inject-guide.test.mjs +28 -0
- package/tests/inject.test.mjs +157 -5
- package/tests/lexicon-calibrate.test.mjs +48 -0
- package/tests/lexicon-dedupe.test.mjs +171 -0
- package/tests/lexicon-frequency.test.mjs +179 -0
- package/tests/lexicon-inject.test.mjs +410 -0
- package/tests/lexicon-perf.test.mjs +436 -0
- package/tests/lexicon-ui.test.mjs +547 -0
- package/tests/lexicon.test.mjs +922 -0
- package/tests/managed-runtime.test.mjs +32 -0
- package/tests/private-links.test.mjs +445 -0
- package/tests/prompts.test.mjs +35 -0
- package/tests/recall-dedup.test.mjs +33 -4
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +188 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-lexicon-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const paths = await import('../lib/paths.js')
|
|
11
|
+
const lexicon = await import('../lib/lexicon.js')
|
|
12
|
+
const state = await import('../lib/state.js')
|
|
13
|
+
const curator = await import('../lib/curator.js')
|
|
14
|
+
const migrate = await import('../lib/migrate.js')
|
|
15
|
+
|
|
16
|
+
test.after(() => {
|
|
17
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function lexFile(libraryId) {
|
|
21
|
+
return path.join(home, 'lexicon', `${libraryId}.json`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let serial = 0
|
|
25
|
+
function newLibrary() {
|
|
26
|
+
serial += 1
|
|
27
|
+
const { id } = state.createLibrary({ name: `lexicon-test-${serial}`, scopeKey: path.join(home, `lexicon-project-${serial}`) })
|
|
28
|
+
return id
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- P0 存储:路径边界 ----
|
|
32
|
+
|
|
33
|
+
test('loadLexicon on a missing library returns the empty structure, not null', () => {
|
|
34
|
+
const empty = lexicon.loadLexicon('lex-never-created')
|
|
35
|
+
assert.deepEqual(
|
|
36
|
+
{ version: empty.version, revision: empty.revision, terms: empty.terms },
|
|
37
|
+
{ version: 1, revision: 0, terms: {} },
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('libraryId must pass isSafeId: absolute paths, .. and separators are rejected', () => {
|
|
42
|
+
for (const bad of ['..', '.', 'a/b', 'a\\b', 'C:\\tmp\\x', '/etc/passwd', 'lib x', '']) {
|
|
43
|
+
assert.throws(() => lexicon.loadLexicon(bad), (error) => error.code === 'LEXICON_INVALID_LIBRARY_ID', `expected typed rejection for ${bad}`)
|
|
44
|
+
}
|
|
45
|
+
// 拒绝发生在路径拼接之前:什么文件都没被创建出来。
|
|
46
|
+
assert.equal(fs.existsSync(path.join(home, 'lexicon')), false, 'no lexicon directory may appear for rejected ids')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('a corrupt lexicon file raises a typed error and is never treated as empty', () => {
|
|
50
|
+
const libraryId = newLibrary()
|
|
51
|
+
fs.mkdirSync(path.dirname(lexFile(libraryId)), { recursive: true })
|
|
52
|
+
fs.writeFileSync(lexFile(libraryId), '{ this is not json')
|
|
53
|
+
assert.throws(() => lexicon.loadLexicon(libraryId), (error) => {
|
|
54
|
+
assert.equal(error.code, 'LEXICON_CORRUPT')
|
|
55
|
+
assert.match(error.message, /invalid JSON/)
|
|
56
|
+
return true
|
|
57
|
+
})
|
|
58
|
+
// 写路径同样被挡住:upsert 不允许把损坏文件当空库覆盖。
|
|
59
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: 'x' }),
|
|
60
|
+
(error) => error.code === 'LEXICON_CORRUPT')
|
|
61
|
+
// 磁盘上的坏字节原样保留——绝没有被"空结构 + 一次写"洗掉。
|
|
62
|
+
assert.equal(fs.readFileSync(lexFile(libraryId), 'utf8'), '{ this is not json')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('valid JSON with a foreign shape is also corrupt, not empty', () => {
|
|
66
|
+
const libraryId = newLibrary()
|
|
67
|
+
fs.mkdirSync(path.dirname(lexFile(libraryId)), { recursive: true })
|
|
68
|
+
fs.writeFileSync(lexFile(libraryId), JSON.stringify({ version: 1, entries: {} }))
|
|
69
|
+
assert.throws(() => lexicon.loadLexicon(libraryId), (error) => error.code === 'LEXICON_CORRUPT')
|
|
70
|
+
const other = newLibrary()
|
|
71
|
+
fs.writeFileSync(lexFile(other), '[]')
|
|
72
|
+
assert.throws(() => lexicon.loadLexicon(other), (error) => error.code === 'LEXICON_CORRUPT')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ---- P0 存储:normalize ----
|
|
76
|
+
|
|
77
|
+
test('normalizeLexeme does NFKC + case folding + strips whitespace/punctuation/symbols', () => {
|
|
78
|
+
assert.equal(lexicon.normalizeLexeme('糖 刀!'), '糖刀')
|
|
79
|
+
assert.equal(lexicon.normalizeLexeme('Tea, Time!'), 'teatime')
|
|
80
|
+
assert.equal(lexicon.normalizeLexeme('Tea-time…'), 'teatime')
|
|
81
|
+
assert.equal(lexicon.normalizeLexeme(' ') , '')
|
|
82
|
+
assert.equal(lexicon.normalizeLexeme('!!!'), '')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ---- P0 存储:写入、revision CAS、原子替换 ----
|
|
86
|
+
|
|
87
|
+
test('upsert writes the §1.2 record shape and bumps the revision', () => {
|
|
88
|
+
const libraryId = newLibrary()
|
|
89
|
+
const { term, created } = lexicon.upsertLexiconTerm({
|
|
90
|
+
libraryId, term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心', usage: '描述剧情',
|
|
91
|
+
aliases: ['糖里藏刀'], examples: ['这段也太糖刀了'], evidence: [{ kind: 'message', id: 'm1', quote: '这段也太糖刀了' }],
|
|
92
|
+
source: 'user',
|
|
93
|
+
})
|
|
94
|
+
assert.equal(created, true)
|
|
95
|
+
assert.ok(term.termId.startsWith('lex-'))
|
|
96
|
+
assert.equal(term.normalizedTerm, '糖刀')
|
|
97
|
+
assert.deepEqual(term.normalizedAliases, ['糖里藏刀'])
|
|
98
|
+
assert.equal(term.status, 'active')
|
|
99
|
+
assert.equal(term.sensitivity, 'normal')
|
|
100
|
+
assert.deepEqual(term.hitCounts, { candidate: 0, injection: 0, query: 0 })
|
|
101
|
+
assert.equal(term.lastInjectedAt, null)
|
|
102
|
+
assert.ok(term.createdAt && term.updatedAt)
|
|
103
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
104
|
+
assert.equal(lex.revision, 1)
|
|
105
|
+
assert.equal(lex.terms[term.termId].meaning, '表面发糖、实际虐心')
|
|
106
|
+
// evidence 只存短证据:超长 quote 被截断,而不是整段转录复制进来。
|
|
107
|
+
const long = lexicon.upsertLexiconTerm({
|
|
108
|
+
libraryId, term: '长证据', kind: 'term', meaning: 'm',
|
|
109
|
+
evidence: [{ kind: 'message', id: 'e'.repeat(300), quote: 'q'.repeat(500) }],
|
|
110
|
+
})
|
|
111
|
+
assert.equal(long.term.evidence[0].quote.length, 200)
|
|
112
|
+
assert.equal(long.term.evidence[0].id.length, 160)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('re-upserting the same normalized term updates in place instead of creating', () => {
|
|
116
|
+
const libraryId = newLibrary()
|
|
117
|
+
// 非敏感字段(usage)变化:原地更新,不新建条目。
|
|
118
|
+
const first = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: '同一个含义' })
|
|
119
|
+
const second = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: '同一个含义', usage: '描述剧情走向' })
|
|
120
|
+
assert.equal(second.created, false)
|
|
121
|
+
assert.equal(second.term.termId, first.term.termId)
|
|
122
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
123
|
+
assert.equal(Object.keys(lex.terms).length, 1)
|
|
124
|
+
assert.equal(lex.terms[first.term.termId].usage, '描述剧情走向')
|
|
125
|
+
assert.equal(lex.revision, 2)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('normalized variants (case, width, punctuation) collapse onto one entry', () => {
|
|
129
|
+
const libraryId = newLibrary()
|
|
130
|
+
const first = lexicon.upsertLexiconTerm({ libraryId, term: 'Tea Time!', kind: 'term', meaning: '喝茶时间' })
|
|
131
|
+
const second = lexicon.upsertLexiconTerm({ libraryId, term: 'tea time~', kind: 'term', meaning: '喝茶时间' })
|
|
132
|
+
assert.equal(second.created, false)
|
|
133
|
+
assert.equal(second.term.termId, first.term.termId)
|
|
134
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('every write re-reads the latest revision inside the lock (CAS); a stale copy cannot clobber', () => {
|
|
138
|
+
const libraryId = newLibrary()
|
|
139
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '旧词', kind: 'term', meaning: 'm' })
|
|
140
|
+
const stale = lexicon.loadLexicon(libraryId) // 调用方手里的陈旧副本(revision 1)
|
|
141
|
+
// 模拟另一个进程先写:绕开本进程直接改磁盘(revision 41 + 外来词条)。
|
|
142
|
+
const onDisk = JSON.parse(fs.readFileSync(lexFile(libraryId), 'utf8'))
|
|
143
|
+
onDisk.revision = 41
|
|
144
|
+
onDisk.terms['lex-foreign'] = { term: '外来词条', normalizedTerm: '外来词条', kind: 'term', status: 'active', context: {} }
|
|
145
|
+
fs.writeFileSync(lexFile(libraryId), JSON.stringify(onDisk))
|
|
146
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '新词', kind: 'term', meaning: 'm' })
|
|
147
|
+
const after = lexicon.loadLexicon(libraryId)
|
|
148
|
+
// 如果写函数用了陈旧副本,revision 会退回 2 且外来词条丢失。
|
|
149
|
+
assert.equal(after.revision, 42)
|
|
150
|
+
assert.ok(after.terms['lex-foreign'], 'the concurrent foreign write must survive')
|
|
151
|
+
assert.ok(after.terms[Object.keys(stale.terms)[0]], 'the stale snapshot term is still there')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('writes are atomic: no temp files survive and the file always parses', () => {
|
|
155
|
+
const libraryId = newLibrary()
|
|
156
|
+
for (let i = 0; i < 3; i++) lexicon.upsertLexiconTerm({ libraryId, term: `词${i}`, kind: 'term', meaning: 'm' })
|
|
157
|
+
const dir = path.join(home, 'lexicon')
|
|
158
|
+
const leftovers = fs.readdirSync(dir).filter(name => name.includes('.tmp-') || name.endsWith('.lock'))
|
|
159
|
+
assert.deepEqual(leftovers, [])
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(lexFile(libraryId), 'utf8'))
|
|
161
|
+
assert.equal(parsed.revision, 3)
|
|
162
|
+
assert.equal(Object.keys(parsed.terms).length, 3)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('LEXICON_CAP is enforced on creation but never on update', () => {
|
|
166
|
+
const libraryId = newLibrary()
|
|
167
|
+
fs.mkdirSync(path.dirname(lexFile(libraryId)), { recursive: true })
|
|
168
|
+
const terms = {}
|
|
169
|
+
for (let i = 0; i < lexicon.LEXICON_CAP; i++) {
|
|
170
|
+
terms[`lex-fill-${i}`] = { term: `fill${i}`, normalizedTerm: `fill${i}`, kind: 'term', meaning: '容量占位', sensitivity: 'normal', status: 'active', context: {} }
|
|
171
|
+
}
|
|
172
|
+
paths.writeJson(lexFile(libraryId), { version: 1, revision: 7, libraryId, terms, rejected: {} })
|
|
173
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '超限词', kind: 'term', meaning: 'm' }),
|
|
174
|
+
(error) => error.code === 'LEXICON_CAP_REACHED')
|
|
175
|
+
// 更新既有条目不受容量限制(非敏感字段变化 → 原地更新)。
|
|
176
|
+
const updated = lexicon.upsertLexiconTerm({ libraryId, term: 'fill0', kind: 'term', meaning: '容量占位', usage: '新用法' })
|
|
177
|
+
assert.equal(updated.created, false)
|
|
178
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-fill-0'].usage, '新用法')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('recordHit accumulates candidate/injection/query separately; unknown kinds and terms are typed errors', () => {
|
|
182
|
+
const libraryId = newLibrary()
|
|
183
|
+
const { term } = lexicon.upsertLexiconTerm({ libraryId, term: '计数词', kind: 'term', meaning: 'm' })
|
|
184
|
+
lexicon.lexiconRecordHit(libraryId, term.termId, 'candidate')
|
|
185
|
+
lexicon.lexiconRecordHit(libraryId, term.termId, 'candidate')
|
|
186
|
+
lexicon.lexiconRecordHit(libraryId, term.termId, 'injection')
|
|
187
|
+
const counts = lexicon.lexiconRecordHit(libraryId, term.termId, 'query')
|
|
188
|
+
assert.deepEqual(counts.hitCounts, { candidate: 2, injection: 1, query: 1 })
|
|
189
|
+
const entry = lexicon.loadLexicon(libraryId).terms[term.termId]
|
|
190
|
+
assert.ok(entry.lastInjectedAt, 'injection stamps lastInjectedAt')
|
|
191
|
+
assert.throws(() => lexicon.lexiconRecordHit(libraryId, term.termId, 'search'), (error) => error.code === 'LEXICON_INVALID_HIT_KIND')
|
|
192
|
+
assert.throws(() => lexicon.lexiconRecordHit(libraryId, 'lex-none', 'query'), (error) => error.code === 'LEXICON_NOT_FOUND')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('delete is a management operation: it works by id and clears rejected fingerprints', () => {
|
|
196
|
+
const libraryId = newLibrary()
|
|
197
|
+
// 当前版本不再产出 rejected(人审已删除),墓碑只能来自 legacy 数据——直接写一条。
|
|
198
|
+
const legacy = lexicon.loadLexicon(libraryId)
|
|
199
|
+
legacy.terms['lex-old-reject'] = legacyEntry('待删词', { status: 'rejected', source: 'model-proposal' })
|
|
200
|
+
legacy.rejected[paths.shortHash('待删词')] = { termId: 'lex-old-reject', reason: '历史驳回', at: '2026-01-01T00:00:00.000Z' }
|
|
201
|
+
paths.writeJson(lexFile(libraryId), legacy)
|
|
202
|
+
assert.equal(lexicon.deleteLexiconTerm({ libraryId, termId: 'lex-old-reject' }).deleted, true)
|
|
203
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 0)
|
|
204
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
205
|
+
assert.equal(Object.keys(lex.rejected).length, 0, 'fingerprint map entry goes away with the term')
|
|
206
|
+
assert.throws(() => lexicon.deleteLexiconTerm({ libraryId, termId: 'lex-none' }), (error) => error.code === 'LEXICON_NOT_FOUND')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// 直接写盘的旧词条骨架(status/source 可覆盖):legacy 数据只能这样造——新写路径
|
|
210
|
+
// 不再产出 pending/rejected(§2.1),但它们仍然必须被正确读取。
|
|
211
|
+
function legacyEntry(term, overrides = {}) {
|
|
212
|
+
const now = '2026-01-01T00:00:00.000Z'
|
|
213
|
+
return {
|
|
214
|
+
term,
|
|
215
|
+
normalizedTerm: lexicon.normalizeLexeme(term),
|
|
216
|
+
normalizedAliases: [],
|
|
217
|
+
kind: 'term',
|
|
218
|
+
meaning: '历史含义',
|
|
219
|
+
usage: '',
|
|
220
|
+
aliases: [],
|
|
221
|
+
examples: [],
|
|
222
|
+
context: {},
|
|
223
|
+
visibility: 'shared',
|
|
224
|
+
sensitivity: 'normal',
|
|
225
|
+
strength: 0.5,
|
|
226
|
+
confidence: 0.7,
|
|
227
|
+
status: 'active',
|
|
228
|
+
source: 'user',
|
|
229
|
+
evidence: [],
|
|
230
|
+
fingerprint: paths.shortHash(lexicon.normalizeLexeme(term)),
|
|
231
|
+
createdAt: now,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
hitCounts: { candidate: 0, injection: 0, query: 0 },
|
|
234
|
+
lastInjectedAt: null,
|
|
235
|
+
...overrides,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---- 可见性(§1.3,contextVisible 对齐) ----
|
|
240
|
+
|
|
241
|
+
test('term context is filtered by the same contextVisible rule as memories', () => {
|
|
242
|
+
const libraryId = newLibrary()
|
|
243
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '世界词', kind: 'term', meaning: 'm', context: { worldId: 'w1' } })
|
|
244
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '专属词', kind: 'term', meaning: 'm', context: { knownBy: ['char-a'] } })
|
|
245
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '公共词', kind: 'term', meaning: 'm' })
|
|
246
|
+
// 三条 strength/updatedAt 相同,顺序由 termId 决定——断言集合而不依赖遍历序。
|
|
247
|
+
const termsIn = (ctx) => lexicon.visibleLexiconTerms(libraryId, ctx).map(t => t.term).sort()
|
|
248
|
+
assert.deepEqual(termsIn({ worldId: 'w1' }), ['公共词', '世界词'].sort())
|
|
249
|
+
assert.deepEqual(termsIn({ worldId: 'w2' }), ['公共词'])
|
|
250
|
+
assert.deepEqual(termsIn({ worldId: 'w1', characterId: 'char-a' }), ['公共词', '世界词', '专属词'].sort())
|
|
251
|
+
// 与 lib/context.js 逐条对齐:过滤失败的条目绝不出现在结果里。
|
|
252
|
+
for (const current of [{ worldId: 'w1' }, { worldId: 'w2' }, { worldId: 'w1', characterId: 'char-a' }]) {
|
|
253
|
+
const expected = Object.values(lexicon.loadLexicon(libraryId).terms)
|
|
254
|
+
.filter(entry => entry.status === 'active')
|
|
255
|
+
.filter(entry => contextVisibleOf(entry, current))
|
|
256
|
+
.length
|
|
257
|
+
assert.equal(lexicon.visibleLexiconTerms(libraryId, current).length, expected)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
function contextVisibleOf(entry, current) {
|
|
262
|
+
for (const key of ['channelId', 'characterId', 'worldId', 'branchId']) {
|
|
263
|
+
if (entry.context?.[key] && entry.context[key] !== current?.[key]) return false
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(entry.context?.knownBy) && entry.context.knownBy.length && !entry.context.knownBy.includes(current?.characterId)) return false
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
test('the same normalized term can live in two scopes without colliding, and never crosses', () => {
|
|
270
|
+
const libraryId = newLibrary()
|
|
271
|
+
const a = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: 'w1 的含义', context: { worldId: 'w1' } })
|
|
272
|
+
const b = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: 'w2 的含义', context: { worldId: 'w2' } })
|
|
273
|
+
assert.equal(a.created, true)
|
|
274
|
+
assert.equal(b.created, true)
|
|
275
|
+
assert.notEqual(a.term.termId, b.term.termId)
|
|
276
|
+
const hitsW1 = lexicon.matchLexiconHits(libraryId, '这段太糖刀了', { worldId: 'w1' })
|
|
277
|
+
assert.deepEqual(hitsW1.map(hit => hit.meaning), ['w1 的含义'])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ---- P2 审核(§2.3) ----
|
|
281
|
+
|
|
282
|
+
// ---- 生命周期(§2.1):公开 active/archived,写入即生效 ----
|
|
283
|
+
|
|
284
|
+
test('model proposals and curator writes land active immediately; sensitive triggers need the user', () => {
|
|
285
|
+
const libraryId = newLibrary()
|
|
286
|
+
const proposal = lexicon.upsertLexiconTerm({ libraryId, term: '提案词', kind: 'term', meaning: '模型猜的', source: 'model-proposal' })
|
|
287
|
+
assert.equal(proposal.created, true)
|
|
288
|
+
assert.equal(proposal.term.status, 'active', 'no pending state, no human review')
|
|
289
|
+
assert.equal(proposal.term.source, 'model-proposal')
|
|
290
|
+
assert.equal(proposal.term.reviewReason, null)
|
|
291
|
+
// 立刻进入可见 / 匹配 / 锚点 / 检索。
|
|
292
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}).map(t => t.term), ['提案词'])
|
|
293
|
+
assert.equal(lexicon.matchLexiconHits(libraryId, '来点提案词', {}).length, 1)
|
|
294
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '提案词' }, {}).length, 1)
|
|
295
|
+
assert.match(lexicon.lexiconAnchorSummary(libraryId, {}).lines[0], /提案词/)
|
|
296
|
+
// 同词重放(敏感字段不变)= 原地更新,不新建。
|
|
297
|
+
const replay = lexicon.upsertLexiconTerm({ libraryId, term: '提案词', kind: 'term', meaning: '模型猜的', source: 'model-proposal' })
|
|
298
|
+
assert.equal(replay.created, false)
|
|
299
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1)
|
|
300
|
+
// sensitive trigger:模型与 curator 都只能是 typed 拒绝(含显式 sensitivity)。
|
|
301
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
302
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: `敏感触发-${source}`, kind: 'trigger', meaning: 'm', source }),
|
|
303
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FORBIDDEN')
|
|
304
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: `敏感触发二-${source}`, kind: 'trigger', sensitivity: 'sensitive', meaning: 'm', source }),
|
|
305
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FORBIDDEN')
|
|
306
|
+
}
|
|
307
|
+
// 用户行:trigger 默认 sensitive,直接 active。
|
|
308
|
+
const userTrigger = lexicon.upsertLexiconTerm({ libraryId, term: '用户触发', kind: 'trigger', source: 'user' })
|
|
309
|
+
assert.equal(userTrigger.term.status, 'active')
|
|
310
|
+
assert.equal(userTrigger.term.sensitivity, 'sensitive')
|
|
311
|
+
// 显式 normal 的 trigger 不是敏感词,模型/curator 可以提。
|
|
312
|
+
const normalTrigger = lexicon.upsertLexiconTerm({ libraryId, term: '普通触发', kind: 'trigger', sensitivity: 'normal', source: 'model-proposal' })
|
|
313
|
+
assert.equal(normalTrigger.term.status, 'active')
|
|
314
|
+
assert.equal(normalTrigger.term.sensitivity, 'normal')
|
|
315
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 3)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('curator writes without needs_review go straight to active too', () => {
|
|
319
|
+
const libraryId = newLibrary()
|
|
320
|
+
const created = lexicon.upsertLexiconTerm({ libraryId, term: '整理词', kind: 'meme', meaning: '整理出来的含义', source: 'curator' })
|
|
321
|
+
assert.equal(created.created, true)
|
|
322
|
+
assert.equal(created.term.status, 'active')
|
|
323
|
+
assert.equal(created.term.source, 'curator')
|
|
324
|
+
// curator 显式传 pending(needs_review 时代的写法)也只能收口成 active。
|
|
325
|
+
const coerced = lexicon.upsertLexiconTerm({ libraryId, term: '整理词二', kind: 'meme', meaning: 'm', source: 'curator', status: 'pending' })
|
|
326
|
+
assert.equal(coerced.term.status, 'active')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('new callers cannot set pending or rejected; archived is an explicit user choice', () => {
|
|
330
|
+
const libraryId = newLibrary()
|
|
331
|
+
const coercedPending = lexicon.upsertLexiconTerm({ libraryId, term: '强迫待审', kind: 'term', meaning: 'm', status: 'pending' })
|
|
332
|
+
assert.equal(coercedPending.term.status, 'active')
|
|
333
|
+
const coercedRejected = lexicon.upsertLexiconTerm({ libraryId, term: '强迫驳回', kind: 'term', meaning: 'm', status: 'rejected' })
|
|
334
|
+
assert.equal(coercedRejected.term.status, 'active')
|
|
335
|
+
const archivedAtBirth = lexicon.upsertLexiconTerm({ libraryId, term: '出生归档', kind: 'term', meaning: 'm', status: 'archived' })
|
|
336
|
+
assert.equal(archivedAtBirth.term.status, 'archived')
|
|
337
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}).map(t => t.term).sort(), ['强迫待审', '强迫驳回'].sort())
|
|
338
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}, { includeArchived: true }).map(t => t.term).sort(), ['出生归档', '强迫待审', '强迫驳回'].sort())
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('non-user updates: sensitive fields are refused with a typed error, ordinary fields update in place', () => {
|
|
342
|
+
const libraryId = newLibrary()
|
|
343
|
+
const active = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: '含义 A', source: 'user' })
|
|
344
|
+
// 非敏感字段(usage)by model-proposal:原地更新,不产生任何草稿。
|
|
345
|
+
const usageOnly = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: '含义 A', usage: '新用法', source: 'model-proposal' })
|
|
346
|
+
assert.equal(usageOnly.created, false)
|
|
347
|
+
assert.equal(usageOnly.term.status, 'active')
|
|
348
|
+
assert.equal(usageOnly.term.usage, '新用法')
|
|
349
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1, 'no pending revision is ever created')
|
|
350
|
+
// 敏感字段(meaning/kind/sensitivity)任何一个变化 → typed 拒绝。
|
|
351
|
+
const cases = [
|
|
352
|
+
{ label: 'meaning', args: { meaning: '含义 B' } },
|
|
353
|
+
{ label: 'kind', args: { meaning: '含义 A', kind: 'slang' } },
|
|
354
|
+
{ label: 'sensitivity', args: { meaning: '含义 A', sensitivity: 'sensitive' } },
|
|
355
|
+
]
|
|
356
|
+
for (const { label, args } of cases) {
|
|
357
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
358
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', usage: '新用法', source, ...args }),
|
|
359
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} must not change ${label}`)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// context 也是敏感字段:同作用域里 knownBy 可见性边界的变化同样拒绝(同身份组才撞线)。
|
|
363
|
+
const bounded = lexicon.upsertLexiconTerm({ libraryId, term: '边界词', kind: 'term', meaning: 'm', knownBy: ['char-a'], source: 'user' })
|
|
364
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
365
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '边界词', kind: 'term', meaning: 'm', source }),
|
|
366
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} must not drop the knownBy boundary`)
|
|
367
|
+
}
|
|
368
|
+
assert.deepEqual(lexicon.loadLexicon(libraryId).terms[bounded.term.termId].context.knownBy, ['char-a'])
|
|
369
|
+
// visibility 不在非敏感名单里:secret 词条不能被非用户更新洗成 shared(披露边界)。
|
|
370
|
+
const secret = lexicon.upsertLexiconTerm({ libraryId, term: '秘密词', kind: 'term', meaning: 'm', visibility: 'secret', source: 'user' })
|
|
371
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
372
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '秘密词', kind: 'term', meaning: 'm', visibility: 'shared', source }),
|
|
373
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} must not disclose a secret term`)
|
|
374
|
+
}
|
|
375
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[secret.term.termId].visibility, 'secret')
|
|
376
|
+
// 用户自己可以把 visibility 改回来。
|
|
377
|
+
const disclosed = lexicon.upsertLexiconTerm({ libraryId, term: '秘密词', kind: 'term', meaning: 'm', visibility: 'shared', source: 'user' })
|
|
378
|
+
assert.equal(disclosed.term.visibility, 'shared')
|
|
379
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
380
|
+
assert.equal(lex.terms[active.term.termId].meaning, '含义 A')
|
|
381
|
+
assert.equal(Object.keys(lex.terms).length, 3, 'rejections leave no drafts behind')
|
|
382
|
+
// 用户改敏感字段:直接生效。
|
|
383
|
+
const userUpdate = lexicon.upsertLexiconTerm({ libraryId, term: '糖刀', kind: 'meme', meaning: '用户改的含义', source: 'user' })
|
|
384
|
+
assert.equal(userUpdate.created, false)
|
|
385
|
+
assert.equal(userUpdate.term.status, 'active')
|
|
386
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[active.term.termId].meaning, '用户改的含义')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('sensitive triggers are user-managed: every non-user update is refused', () => {
|
|
390
|
+
const libraryId = newLibrary()
|
|
391
|
+
const trigger = lexicon.upsertLexiconTerm({ libraryId, term: '红牌', kind: 'trigger', meaning: '触发', source: 'user' })
|
|
392
|
+
assert.equal(trigger.term.sensitivity, 'sensitive')
|
|
393
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
394
|
+
// 带着敏感档位来:连"建 sensitive trigger"的创建守卫都会先拦(同一个安全口径)。
|
|
395
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '红牌', kind: 'trigger', meaning: '触发', usage: '新用法', source }),
|
|
396
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FORBIDDEN', `${source} must not touch a sensitive trigger at all`)
|
|
397
|
+
// 想把它降成 normal(改敏感字段)也拒绝:既有 sensitive trigger 的任何更新都不给非用户。
|
|
398
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '红牌', kind: 'trigger', sensitivity: 'normal', meaning: '触发', usage: '新用法', source }),
|
|
399
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN', `${source} must not downgrade a sensitive trigger`)
|
|
400
|
+
}
|
|
401
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[trigger.term.termId].usage, '')
|
|
402
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[trigger.term.termId].sensitivity, 'sensitive')
|
|
403
|
+
const updated = lexicon.upsertLexiconTerm({ libraryId, term: '红牌', kind: 'trigger', meaning: '用户改的触发', source: 'user' })
|
|
404
|
+
assert.equal(updated.term.meaning, '用户改的触发')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('archive takes a term out of visibility/matching/search; restore puts it back', () => {
|
|
408
|
+
const libraryId = newLibrary()
|
|
409
|
+
const { term } = lexicon.upsertLexiconTerm({ libraryId, term: '归档词', kind: 'term', meaning: '要归档的含义' })
|
|
410
|
+
const archived = lexicon.archiveLexiconTerm({ libraryId, termId: term.termId })
|
|
411
|
+
assert.equal(archived.term.status, 'archived')
|
|
412
|
+
assert.equal(archived.action, 'archive')
|
|
413
|
+
// 归档后:不列出、不匹配、不检索、不进锚点——也就是不注入。
|
|
414
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}), [])
|
|
415
|
+
assert.deepEqual(lexicon.matchLexiconHits(libraryId, '来点归档词', {}), [])
|
|
416
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId, query: '归档词' }, {}), [])
|
|
417
|
+
assert.deepEqual(lexicon.lexiconAnchorSummary(libraryId, {}), { lines: [], sensitiveTriggers: [] })
|
|
418
|
+
// 管理面显式 includeArchived 才看得到。
|
|
419
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}, { includeArchived: true }).map(t => t.term), ['归档词'])
|
|
420
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '归档词' }, { includeArchived: true }).length, 1)
|
|
421
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[term.termId].hitCounts.query, 0, 'browsing archived terms does not count as a query hit')
|
|
422
|
+
// 归档不写 rejected 墓碑(归档是可恢复的退场,不是拒绝)。
|
|
423
|
+
assert.deepEqual(Object.keys(lexicon.loadLexicon(libraryId).rejected), [])
|
|
424
|
+
// 状态机是硬的:重复归档、恢复未知 id 都是 typed error。
|
|
425
|
+
assert.throws(() => lexicon.archiveLexiconTerm({ libraryId, termId: term.termId }), (error) => error.code === 'LEXICON_INVALID_STATUS')
|
|
426
|
+
assert.throws(() => lexicon.restoreLexiconTerm({ libraryId, termId: 'lex-none' }), (error) => error.code === 'LEXICON_NOT_FOUND')
|
|
427
|
+
const restored = lexicon.restoreLexiconTerm({ libraryId, termId: term.termId })
|
|
428
|
+
assert.equal(restored.term.status, 'active')
|
|
429
|
+
assert.equal(restored.action, 'restore')
|
|
430
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}).map(t => t.term), ['归档词'])
|
|
431
|
+
assert.equal(lexicon.matchLexiconHits(libraryId, '来点归档词', {}).length, 1)
|
|
432
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '归档词' }, {}).length, 1)
|
|
433
|
+
assert.throws(() => lexicon.restoreLexiconTerm({ libraryId, termId: term.termId }), (error) => error.code === 'LEXICON_INVALID_STATUS')
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('restore refuses legacy revision drafts and would-be alias conflicts', () => {
|
|
437
|
+
const libraryId = newLibrary()
|
|
438
|
+
const target = lexicon.upsertLexiconTerm({ libraryId, term: '钉子', kind: 'term', meaning: '原义' })
|
|
439
|
+
// 迁移后的修订草稿形态:archived + revisionOf 指向仍 active 的目标。
|
|
440
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
441
|
+
lex.terms['lex-legacy-draft'] = {
|
|
442
|
+
...legacyEntry('钉子草稿'),
|
|
443
|
+
term: '钉子',
|
|
444
|
+
normalizedTerm: '钉子',
|
|
445
|
+
meaning: '草稿义',
|
|
446
|
+
status: 'archived',
|
|
447
|
+
revisionOf: target.term.termId,
|
|
448
|
+
previousRevision: 1,
|
|
449
|
+
}
|
|
450
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
451
|
+
assert.throws(() => lexicon.restoreLexiconTerm({ libraryId, termId: 'lex-legacy-draft' }),
|
|
452
|
+
(error) => error.code === 'LEXICON_REVISION_IMMUTABLE')
|
|
453
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-legacy-draft'].status, 'archived')
|
|
454
|
+
// 别名撞线:归档词条 月引,另一条 active 词条把「月引」当别名占着。
|
|
455
|
+
const yue = lexicon.upsertLexiconTerm({ libraryId, term: '月引', kind: 'term', meaning: 'w1 的月引' })
|
|
456
|
+
lexicon.archiveLexiconTerm({ libraryId, termId: yue.term.termId })
|
|
457
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '引子', kind: 'term', meaning: '另一条', aliases: ['月引'] })
|
|
458
|
+
assert.throws(() => lexicon.restoreLexiconTerm({ libraryId, termId: yue.term.termId }),
|
|
459
|
+
(error) => error.code === 'LEXICON_ALIAS_CONFLICT')
|
|
460
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[yue.term.termId].status, 'archived')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
test('a collision with an archived term is not revived by model/curator; the user revives it', () => {
|
|
464
|
+
const libraryId = newLibrary()
|
|
465
|
+
const created = lexicon.upsertLexiconTerm({ libraryId, term: '复活词', kind: 'term', meaning: '旧含义' })
|
|
466
|
+
lexicon.archiveLexiconTerm({ libraryId, termId: created.term.termId })
|
|
467
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
468
|
+
const result = lexicon.upsertLexiconTerm({ libraryId, term: '复活词', kind: 'term', meaning: '旧含义', usage: '新用法', source })
|
|
469
|
+
assert.equal(result.created, false)
|
|
470
|
+
assert.equal(result.notOwned, true, `${source} must not own an archived term`)
|
|
471
|
+
assert.equal(result.term.status, 'archived')
|
|
472
|
+
}
|
|
473
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1, 'no duplicate is created next to the archived entry either')
|
|
474
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms[created.term.termId].usage, '')
|
|
475
|
+
const edited = lexicon.upsertLexiconTerm({ libraryId, term: '复活词', kind: 'term', meaning: '归档时改的含义', source: 'user' })
|
|
476
|
+
assert.equal(edited.created, false)
|
|
477
|
+
assert.equal(edited.term.status, 'archived', 'editing an archived term is not an implicit restore')
|
|
478
|
+
assert.equal(edited.term.meaning, '归档时改的含义')
|
|
479
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}), [])
|
|
480
|
+
const revived = lexicon.upsertLexiconTerm({ libraryId, term: '复活词', kind: 'term', meaning: '用户改的新含义', source: 'user', status: 'active' })
|
|
481
|
+
assert.equal(revived.created, false)
|
|
482
|
+
assert.equal(revived.revived, true)
|
|
483
|
+
assert.equal(revived.term.status, 'active')
|
|
484
|
+
assert.equal(revived.term.meaning, '用户改的新含义')
|
|
485
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}).map(t => t.term), ['复活词'])
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('a rejected tombstone blocks model replays and is never revived by non-user writes', () => {
|
|
489
|
+
const libraryId = newLibrary()
|
|
490
|
+
// 当前版本不再产出 rejected(人审已删除):墓碑只能来自 legacy 数据。
|
|
491
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
492
|
+
lex.terms['lex-old-reject'] = legacyEntry('错词', { status: 'rejected', source: 'model-proposal' })
|
|
493
|
+
lex.rejected[paths.shortHash('错词')] = { termId: 'lex-old-reject', reason: '模型猜的', at: '2026-01-01T00:00:00.000Z' }
|
|
494
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
495
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}), [])
|
|
496
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}, { includeArchived: true }), [], 'tombstones never surface in any list')
|
|
497
|
+
// 同 fingerprint 的 model-proposal 直接拒绝(大写/标点变体一样,因为指纹是规范化词面)。
|
|
498
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '错词', kind: 'term', meaning: 'again', source: 'model-proposal' }),
|
|
499
|
+
(error) => error.code === 'LEXICON_PROPOSAL_REJECTED')
|
|
500
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '错词!', kind: 'term', meaning: 'again', source: 'model-proposal' }),
|
|
501
|
+
(error) => error.code === 'LEXICON_PROPOSAL_REJECTED')
|
|
502
|
+
// curator 同样不得复活墓碑:拿回既有条目 + notOwned,正文不动。
|
|
503
|
+
const curatorTry = lexicon.upsertLexiconTerm({ libraryId, term: '错词', kind: 'term', meaning: 'curator 想改', source: 'curator' })
|
|
504
|
+
assert.equal(curatorTry.created, false)
|
|
505
|
+
assert.equal(curatorTry.notOwned, true)
|
|
506
|
+
assert.equal(curatorTry.term.status, 'rejected')
|
|
507
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-old-reject'].meaning, '历史含义')
|
|
508
|
+
// 用户显式写入 = 翻案:active 并清掉拒绝指纹。
|
|
509
|
+
const revived = lexicon.upsertLexiconTerm({ libraryId, term: '错词', kind: 'term', meaning: '用户确认的含义', source: 'user' })
|
|
510
|
+
assert.equal(revived.created, false)
|
|
511
|
+
assert.equal(revived.revived, true)
|
|
512
|
+
assert.equal(revived.term.status, 'active')
|
|
513
|
+
assert.equal(revived.term.meaning, '用户确认的含义')
|
|
514
|
+
assert.deepEqual(Object.keys(lexicon.loadLexicon(libraryId).rejected), [])
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test('legacy pending terms before migration: user writes apply directly, non-user writes never create revisions', () => {
|
|
518
|
+
const libraryId = newLibrary()
|
|
519
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
520
|
+
lex.terms['lex-legacy-pending'] = legacyEntry('旧提案', { status: 'pending', source: 'model-proposal', meaning: '旧含义' })
|
|
521
|
+
lex.terms['lex-legacy-pending-2'] = legacyEntry('旧提案二', { status: 'pending', source: 'model-proposal', meaning: '旧含义二' })
|
|
522
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
523
|
+
// 迁移前 legacy pending 不参与注入(active-only)。
|
|
524
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}), [])
|
|
525
|
+
// 非用户只改非敏感字段:原地更新并收口为 active,不建 revision。
|
|
526
|
+
const curatorUpdate = lexicon.upsertLexiconTerm({ libraryId, term: '旧提案', kind: 'term', meaning: '旧含义', usage: '新用法', source: 'curator' })
|
|
527
|
+
assert.equal(curatorUpdate.created, false)
|
|
528
|
+
assert.equal(curatorUpdate.term.status, 'active')
|
|
529
|
+
assert.equal(curatorUpdate.term.usage, '新用法')
|
|
530
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 2, 'no revision draft is created')
|
|
531
|
+
// 非用户改敏感字段:typed 拒绝,条目保持原样(仍然是 legacy pending)。
|
|
532
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '旧提案二', kind: 'term', meaning: '模型改的', source: 'model-proposal' }),
|
|
533
|
+
(error) => error.code === 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN')
|
|
534
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-legacy-pending-2'].status, 'pending')
|
|
535
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-legacy-pending-2'].meaning, '旧含义二')
|
|
536
|
+
// 用户写同词:直接应用(含敏感字段),条目转 active。
|
|
537
|
+
const userWrite = lexicon.upsertLexiconTerm({ libraryId, term: '旧提案二', kind: 'term', meaning: '用户改的', source: 'user' })
|
|
538
|
+
assert.equal(userWrite.term.status, 'active')
|
|
539
|
+
assert.equal(userWrite.term.meaning, '用户改的')
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
test('legacy revision drafts are never revived by non-user writes', () => {
|
|
543
|
+
const libraryId = newLibrary()
|
|
544
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
545
|
+
lex.terms['lex-orphan-draft'] = legacyEntry('孤草稿', { status: 'pending', meaning: '草稿义' })
|
|
546
|
+
lex.terms['lex-orphan-draft'].revisionOf = 'lex-gone-target'
|
|
547
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
548
|
+
for (const source of ['model-proposal', 'curator']) {
|
|
549
|
+
const attempt = lexicon.upsertLexiconTerm({ libraryId, term: '孤草稿', kind: 'term', meaning: '草稿义', source })
|
|
550
|
+
assert.equal(attempt.created, false)
|
|
551
|
+
assert.equal(attempt.notOwned, true, `${source} must not turn a draft into a term`)
|
|
552
|
+
assert.equal(attempt.term.status, 'pending')
|
|
553
|
+
}
|
|
554
|
+
assert.equal(lexicon.loadLexicon(libraryId).terms['lex-orphan-draft'].status, 'pending')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
test('migrateLegacyLexiconStatuses: proposals → active, sensitive/revision → archived, rejected preserved', () => {
|
|
558
|
+
const libraryId = newLibrary()
|
|
559
|
+
paths.writeJson(lexFile(libraryId), {
|
|
560
|
+
version: 1,
|
|
561
|
+
revision: 4,
|
|
562
|
+
libraryId,
|
|
563
|
+
rejected: { [paths.shortHash('墓碑')]: { termId: 'lex-m-rej', reason: '历史驳回', at: '2026-01-01T00:00:00.000Z' } },
|
|
564
|
+
terms: {
|
|
565
|
+
'lex-m-new': legacyEntry('新提案', { status: 'pending', source: 'model-proposal' }),
|
|
566
|
+
'lex-m-sensitive': legacyEntry('敏感提案', { status: 'pending', kind: 'trigger', sensitivity: 'sensitive' }),
|
|
567
|
+
// legacy 数据可能没写 sensitivity:trigger 缺省就是 sensitive(§1.2)。
|
|
568
|
+
'lex-m-trigger-default': legacyEntry('旧触发', { status: 'pending', kind: 'trigger', sensitivity: undefined }),
|
|
569
|
+
'lex-m-draft': { ...legacyEntry('待审草稿', { status: 'pending' }), revisionOf: 'lex-m-new', previousRevision: 3 },
|
|
570
|
+
'lex-m-active': legacyEntry('既有'),
|
|
571
|
+
'lex-m-rej': legacyEntry('墓碑', { status: 'rejected', source: 'model-proposal' }),
|
|
572
|
+
},
|
|
573
|
+
})
|
|
574
|
+
const first = lexicon.migrateLegacyLexiconStatuses(libraryId)
|
|
575
|
+
assert.equal(first.libraryId, libraryId)
|
|
576
|
+
assert.equal(first.migrated, 4)
|
|
577
|
+
assert.deepEqual(first.details.map(item => [item.termId, item.status]).sort(), [
|
|
578
|
+
['lex-m-draft', 'archived'],
|
|
579
|
+
['lex-m-new', 'active'],
|
|
580
|
+
['lex-m-sensitive', 'archived'],
|
|
581
|
+
['lex-m-trigger-default', 'archived'],
|
|
582
|
+
])
|
|
583
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
584
|
+
assert.equal(lex.revision, 5, 'one write, one revision bump')
|
|
585
|
+
assert.equal(lex.terms['lex-m-new'].status, 'active')
|
|
586
|
+
assert.equal(lex.terms['lex-m-new'].migratedFromStatus, 'pending')
|
|
587
|
+
assert.equal(lex.terms['lex-m-sensitive'].status, 'archived')
|
|
588
|
+
assert.equal(lex.terms['lex-m-trigger-default'].status, 'archived')
|
|
589
|
+
assert.equal(lex.terms['lex-m-draft'].status, 'archived', 'revision drafts keep their audit trail as archived')
|
|
590
|
+
assert.equal(lex.terms['lex-m-draft'].revisionOf, 'lex-m-new')
|
|
591
|
+
assert.equal(lex.terms['lex-m-active'].status, 'active')
|
|
592
|
+
assert.equal(lex.terms['lex-m-rej'].status, 'rejected')
|
|
593
|
+
assert.ok(lex.rejected[paths.shortHash('墓碑')], 'the tombstone fingerprint is preserved')
|
|
594
|
+
// 第二遍:没有 pending → 不写盘、不 bump revision。
|
|
595
|
+
const second = lexicon.migrateLegacyLexiconStatuses(libraryId)
|
|
596
|
+
assert.equal(second.migrated, 0)
|
|
597
|
+
assert.equal(second.details, undefined)
|
|
598
|
+
assert.equal(lexicon.loadLexicon(libraryId).revision, 5, 'a no-op migration never writes')
|
|
599
|
+
// 迁移后的可见面:新提案 active;草稿/敏感/墓碑都不注入。
|
|
600
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}).map(t => t.term).sort(), ['新提案', '既有'].sort())
|
|
601
|
+
assert.deepEqual(lexicon.visibleLexiconTerms(libraryId, {}, { includeArchived: true }).map(t => t.term).sort(),
|
|
602
|
+
['敏感提案', '旧触发', '新提案', '既有', '待审草稿'].sort())
|
|
603
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId, query: '墓碑' }, {}), [])
|
|
604
|
+
// 空库 / 不存在的库:不产生文件。
|
|
605
|
+
assert.deepEqual(lexicon.migrateLegacyLexiconStatuses('lex-never-created'), { libraryId: 'lex-never-created', migrated: 0 })
|
|
606
|
+
assert.equal(fs.existsSync(lexFile('lex-never-created')), false)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
test('lexiconTurnLine clips the term to 40 and the short meaning to 80', () => {
|
|
610
|
+
const line = lexicon.lexiconTurnLine({ term: 't'.repeat(60), meaning: 'm'.repeat(200) })
|
|
611
|
+
assert.equal(line, `「${'t'.repeat(40)}」:${'m'.repeat(80)}`)
|
|
612
|
+
assert.ok(!line.includes('m'.repeat(81)), 'the short meaning is clipped to 80, not 120')
|
|
613
|
+
// usage 兜底同一把尺;两个都没有时只留 term 标题。
|
|
614
|
+
assert.equal(lexicon.lexiconTurnLine({ term: '词', usage: 'u'.repeat(100) }), `「词」:${'u'.repeat(80)}`)
|
|
615
|
+
assert.equal(lexicon.lexiconTurnLine({ term: '词' }), '「词」')
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
test('lib/migrate.js scans lexicon files, migrates them per library and survives a corrupt one', () => {
|
|
619
|
+
// 一本正常的 legacy 词典 + 一本"能扫到但读不进去"的坏词典(version 不合法)。
|
|
620
|
+
const libraryId = newLibrary()
|
|
621
|
+
paths.writeJson(lexFile(libraryId), {
|
|
622
|
+
version: 1,
|
|
623
|
+
revision: 2,
|
|
624
|
+
libraryId,
|
|
625
|
+
rejected: {},
|
|
626
|
+
terms: { 'lex-mig-a': legacyEntry('迁移甲', { status: 'pending' }) },
|
|
627
|
+
})
|
|
628
|
+
const brokenId = newLibrary()
|
|
629
|
+
paths.writeJson(lexFile(brokenId), {
|
|
630
|
+
version: 2,
|
|
631
|
+
revision: 1,
|
|
632
|
+
libraryId: brokenId,
|
|
633
|
+
rejected: {},
|
|
634
|
+
terms: { 'lex-bad-a': { status: 'pending' } },
|
|
635
|
+
})
|
|
636
|
+
const scan = migrate.scanLegacyLexiconLibraries()
|
|
637
|
+
assert.ok(scan.some(item => item.libraryId === libraryId && item.legacy === 1), 'the pending lexicon is scanned')
|
|
638
|
+
assert.ok(scan.some(item => item.libraryId === brokenId && item.legacy === 1), 'a corrupt lexicon is still scanned (it must not be silently skipped)')
|
|
639
|
+
const logs = []
|
|
640
|
+
const pass = migrate.runVisibilityMigrationPass({ log: message => logs.push(String(message)) })
|
|
641
|
+
assert.ok(Array.isArray(pass.reports), 'the existing visibility report shape survives')
|
|
642
|
+
const myReports = pass.lexicon.filter(item => item.libraryId === libraryId || item.libraryId === brokenId)
|
|
643
|
+
const good = myReports.find(item => item.libraryId === libraryId)
|
|
644
|
+
const bad = myReports.find(item => item.libraryId === brokenId)
|
|
645
|
+
assert.equal(good.migrated, 1)
|
|
646
|
+
assert.equal(good.before, 1)
|
|
647
|
+
assert.ok(good.details.some(item => item.termId === 'lex-mig-a' && item.status === 'active'))
|
|
648
|
+
assert.match(bad.error, /LEXICON_CORRUPT|could not be read|invalid data shape/)
|
|
649
|
+
assert.equal(bad.migrated, 0, 'one broken lexicon does not stop the sweep')
|
|
650
|
+
assert.ok(logs.some(message => message.includes('migrated 1 legacy lexicon term(s)') && message.includes(libraryId)))
|
|
651
|
+
assert.ok(logs.some(message => message.includes('lexicon lifecycle migration failed') && message.includes(brokenId)))
|
|
652
|
+
const migratedLex = lexicon.loadLexicon(libraryId)
|
|
653
|
+
assert.equal(migratedLex.revision, 3, 'the migration writes exactly once')
|
|
654
|
+
assert.equal(migratedLex.terms['lex-mig-a'].status, 'active')
|
|
655
|
+
// 第二遍:没有待迁移的词典 → 不再写、不再记日志。
|
|
656
|
+
const secondLogs = []
|
|
657
|
+
const second = migrate.runVisibilityMigrationPass({ log: message => secondLogs.push(String(message)) })
|
|
658
|
+
assert.deepEqual(second.lexicon.filter(item => item.libraryId === libraryId), [])
|
|
659
|
+
assert.equal(lexicon.loadLexicon(libraryId).revision, 3, 'a no-op pass never bumps the revision')
|
|
660
|
+
assert.ok(!secondLogs.some(message => message.includes('migrated 1 legacy lexicon term(s)')))
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
test('schema validation rejects junk with typed errors', () => {
|
|
664
|
+
const libraryId = newLibrary()
|
|
665
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '', kind: 'term' }), (error) => error.code === 'LEXICON_INVALID_TERM')
|
|
666
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: 'x'.repeat(81), kind: 'term' }), (error) => error.code === 'LEXICON_INVALID_TERM')
|
|
667
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '词', kind: 'banana' }), (error) => error.code === 'LEXICON_INVALID_KIND')
|
|
668
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '词', kind: 'term', visibility: 'public' }), (error) => error.code === 'LEXICON_INVALID_VISIBILITY')
|
|
669
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '词', kind: 'term', sensitivity: 'hyper' }), (error) => error.code === 'LEXICON_INVALID_SENSITIVITY')
|
|
670
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '词', kind: 'term', source: 'whisper' }), (error) => error.code === 'LEXICON_INVALID_SOURCE')
|
|
671
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '词', kind: 'term', aliases: 'not-an-array' }), (error) => error.code === 'LEXICON_INVALID_ALIASES')
|
|
672
|
+
// alias 撞了别的词条:typed 拒绝,不静默合并。
|
|
673
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '本名', kind: 'term', meaning: 'm' })
|
|
674
|
+
assert.throws(() => lexicon.upsertLexiconTerm({ libraryId, term: '另一个', kind: 'term', meaning: 'm', aliases: ['本名'] }),
|
|
675
|
+
(error) => error.code === 'LEXICON_ALIAS_CONFLICT')
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
// ---- P2 curator 写入 op(§2.1-§2.2) ----
|
|
679
|
+
|
|
680
|
+
function lexOp(extra = {}) {
|
|
681
|
+
return {
|
|
682
|
+
op: 'lexicon', action: 'upsert', term: '整理词', kind: 'meme', meaning: '整理出来的含义',
|
|
683
|
+
evidence: [{ kind: 'message', id: 'session-1', quote: '这个词出现了两次' }], ...extra,
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
test('the global curator rejects lexicon ops (and anything carrying a libraryId)', async () => {
|
|
688
|
+
const job = { step: '', abort: new AbortController() }
|
|
689
|
+
const result = await curator.commitCuratorOutput({ memories: [lexOp()] }, { job })
|
|
690
|
+
assert.equal(result.created, 0)
|
|
691
|
+
assert.equal(result.skipped.length, 1)
|
|
692
|
+
assert.match(result.skipped[0].reason, /lexicon op rejected/)
|
|
693
|
+
// 带 libraryId 的普通记忆条目同样拒绝——私有目标不属于全局 curator。
|
|
694
|
+
const mixed = await curator.commitCuratorOutput({
|
|
695
|
+
memories: [{ op: 'create', domain: 'coding', title: 't', content: 'c', evidence: [{ session_id: 's', quote: 'q' }], libraryId: 'lib_somewhere' }],
|
|
696
|
+
}, { job })
|
|
697
|
+
assert.equal(mixed.skipped.length, 1)
|
|
698
|
+
assert.match(mixed.skipped[0].reason, /lexicon op rejected/)
|
|
699
|
+
// 什么都没写进全局索引。
|
|
700
|
+
assert.deepEqual(state.loadGlobalIndex().entries, {})
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
test('the private chain commits validated lexicon ops with evidence into the bound library', async () => {
|
|
704
|
+
const libraryId = newLibrary()
|
|
705
|
+
const result = await curator.commitPrivateLexiconOps([lexOp()], { libraryId })
|
|
706
|
+
assert.equal(result.committed.length, 1)
|
|
707
|
+
assert.equal(result.committed[0].status, 'active')
|
|
708
|
+
assert.equal(result.committed[0].created, true)
|
|
709
|
+
const entries = Object.values(lexicon.loadLexicon(libraryId).terms)
|
|
710
|
+
assert.equal(entries.length, 1)
|
|
711
|
+
assert.equal(entries[0].source, 'curator')
|
|
712
|
+
assert.equal(entries[0].meaning, '整理出来的含义')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('needs_review proposals land active too (no review queue exists anymore); evidence-less ops are rejected', async () => {
|
|
716
|
+
const libraryId = newLibrary()
|
|
717
|
+
const held = await curator.commitPrivateLexiconOps([lexOp({ needs_review: true, term: '待复核词' })], { libraryId })
|
|
718
|
+
assert.equal(held.committed[0].status, 'active', 'needs_review no longer parks a term in pending')
|
|
719
|
+
assert.equal(held.committed[0].pendingRevision, false)
|
|
720
|
+
const noEvidence = await curator.commitPrivateLexiconOps([
|
|
721
|
+
lexOp({ term: '没证据', evidence: [] }),
|
|
722
|
+
lexOp({ term: '空证据', evidence: [{ kind: 'message', id: ' ', quote: ' ' }] }),
|
|
723
|
+
], { libraryId })
|
|
724
|
+
assert.deepEqual(noEvidence.committed, [])
|
|
725
|
+
assert.equal(noEvidence.rejected.length, 2)
|
|
726
|
+
assert.ok(noEvidence.rejected.every(item => item.code === 'LEXICON_EVIDENCE_REQUIRED'))
|
|
727
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
test('delete actions, foreign libraryIds and sensitive triggers are rejected in the op whitelist', async () => {
|
|
731
|
+
const libraryId = newLibrary()
|
|
732
|
+
const result = await curator.commitPrivateLexiconOps([
|
|
733
|
+
lexOp({ term: '要删词', action: 'delete' }),
|
|
734
|
+
lexOp({ term: '越权词', libraryId: 'lib_other' }),
|
|
735
|
+
lexOp({ term: '模型触发', kind: 'trigger' }),
|
|
736
|
+
{ op: 'memory', action: 'upsert', term: '不是词典' },
|
|
737
|
+
], { libraryId })
|
|
738
|
+
assert.deepEqual(result.committed, [])
|
|
739
|
+
assert.deepEqual(result.rejected.map(item => item.code), [
|
|
740
|
+
'LEXICON_ACTION_FORBIDDEN', 'LEXICON_LIBRARY_MISMATCH', 'LEXICON_SENSITIVE_FORBIDDEN',
|
|
741
|
+
])
|
|
742
|
+
assert.equal(result.skipped.length, 1, 'non-lexicon ops are skipped, not executed')
|
|
743
|
+
const rejected = await curator.commitPrivateLexiconOps([lexOp({ term: 'x' })], { libraryId: '../escape' })
|
|
744
|
+
assert.equal(rejected.rejected.length, 1)
|
|
745
|
+
assert.equal(rejected.rejected[0].code, 'LEXICON_INVALID_LIBRARY_ID')
|
|
746
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 0)
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
test('a curator op colliding with an active term cannot rewrite its sensitive fields', async () => {
|
|
750
|
+
const libraryId = newLibrary()
|
|
751
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '整理词', kind: 'meme', meaning: '既有含义', source: 'user' })
|
|
752
|
+
const result = await curator.commitPrivateLexiconOps([lexOp({ meaning: '整理出来的含义' })], { libraryId })
|
|
753
|
+
assert.deepEqual(result.committed, [])
|
|
754
|
+
assert.equal(result.rejected.length, 1)
|
|
755
|
+
assert.equal(result.rejected[0].code, 'LEXICON_SENSITIVE_FIELDS_FORBIDDEN')
|
|
756
|
+
assert.equal(Object.values(lexicon.loadLexicon(libraryId).terms)[0].meaning, '既有含义')
|
|
757
|
+
// 同样的含义 + 只改非敏感字段:原地更新成功。
|
|
758
|
+
const applied = await curator.commitPrivateLexiconOps([lexOp({ meaning: '既有含义', usage: '整理补充的用法' })], { libraryId })
|
|
759
|
+
assert.equal(applied.committed.length, 1)
|
|
760
|
+
assert.equal(applied.committed[0].created, false)
|
|
761
|
+
assert.equal(applied.committed[0].status, 'active')
|
|
762
|
+
assert.equal(Object.values(lexicon.loadLexicon(libraryId).terms)[0].usage, '整理补充的用法')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
// ---- 精确层检索(§2.2 searchLexicon / §3.3 通道 C 的数据面) ----
|
|
766
|
+
|
|
767
|
+
test('searchLexicon: normalized equality on term and aliases plus containment, query counts recorded once', () => {
|
|
768
|
+
const libraryId = newLibrary()
|
|
769
|
+
const { term } = lexicon.upsertLexiconTerm({
|
|
770
|
+
libraryId, term: '糖刀', kind: 'meme', meaning: '表面发糖、实际虐心', aliases: ['糖里藏刀'],
|
|
771
|
+
})
|
|
772
|
+
// 等值(含大小写/标点归一)、alias 等值、包含(长 query 含词条)三层都命中。
|
|
773
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '糖刀!' }, {}).length, 1)
|
|
774
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '糖里藏刀' }, {})[0].matchedBy, 'alias')
|
|
775
|
+
assert.equal(lexicon.searchLexicon({ libraryId, query: '这个糖刀梗什么意思' }, {})[0].matchedBy, 'contains')
|
|
776
|
+
// 空查询不检索也不记账。
|
|
777
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId, query: ' ' }, {}), [])
|
|
778
|
+
// 跨 scope 不可见:词条绑定 w1 时,w2 的查询拿不到。
|
|
779
|
+
const scoped = newLibrary()
|
|
780
|
+
lexicon.upsertLexiconTerm({ libraryId: scoped, term: '界词', kind: 'term', meaning: 'm', context: { worldId: 'w1' } })
|
|
781
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId: scoped, query: '界词', memoryContext: { worldId: 'w2' } }, {}), [])
|
|
782
|
+
// 归档词条只有显式 includeArchived 才可见;pending/rejected 任何模式都不可见。
|
|
783
|
+
const archivedLibrary = newLibrary()
|
|
784
|
+
const archived = lexicon.upsertLexiconTerm({ libraryId: archivedLibrary, term: '检索归档', kind: 'term', meaning: 'm' })
|
|
785
|
+
lexicon.archiveLexiconTerm({ libraryId: archivedLibrary, termId: archived.term.termId })
|
|
786
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId: archivedLibrary, query: '检索归档' }, {}), [])
|
|
787
|
+
assert.equal(lexicon.searchLexicon({ libraryId: archivedLibrary, query: '检索归档' }, { includeArchived: true }).length, 1)
|
|
788
|
+
const legacyLibrary = newLibrary()
|
|
789
|
+
const legacy = lexicon.loadLexicon(legacyLibrary)
|
|
790
|
+
legacy.terms['lex-legacy-search'] = legacyEntry('检索旧提案', { status: 'pending' })
|
|
791
|
+
paths.writeJson(lexFile(legacyLibrary), legacy)
|
|
792
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId: legacyLibrary, query: '检索旧提案' }, {}), [])
|
|
793
|
+
assert.deepEqual(lexicon.searchLexicon({ libraryId: legacyLibrary, query: '检索旧提案' }, { includeArchived: true }), [], 'pending never shows up, not even through includeArchived')
|
|
794
|
+
// 三次可见命中各记一次 query 计数(与 candidate/injection 分开),最后一次显式
|
|
795
|
+
// 记账再 +1。
|
|
796
|
+
const counts = lexicon.lexiconRecordHit(libraryId, term.termId, 'query')
|
|
797
|
+
assert.deepEqual(counts.hitCounts, { candidate: 0, injection: 0, query: 4 })
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
// ---- 锚点摘要(§3.3 通道 A 的数据面) ----
|
|
801
|
+
|
|
802
|
+
test('lexiconAnchorSummary: top 8 by strength, sensitive triggers listed separately with cap 2', () => {
|
|
803
|
+
const libraryId = newLibrary()
|
|
804
|
+
for (let i = 0; i < 10; i++) {
|
|
805
|
+
lexicon.upsertLexiconTerm({ libraryId, term: `普通词${i}`, kind: 'term', meaning: `含义${i}` })
|
|
806
|
+
}
|
|
807
|
+
// 手工拉开 strength 梯度(写入函数统一给 0.6)。
|
|
808
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
809
|
+
const ids = Object.keys(lex.terms)
|
|
810
|
+
ids.forEach((id, i) => { lex.terms[id].strength = (i + 1) / 10 })
|
|
811
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
812
|
+
for (let i = 0; i < 3; i++) {
|
|
813
|
+
lexicon.upsertLexiconTerm({ libraryId, term: `敏感词${i}`, kind: 'trigger', meaning: `触发${i}` })
|
|
814
|
+
}
|
|
815
|
+
const lex2 = lexicon.loadLexicon(libraryId)
|
|
816
|
+
for (const [id, entry] of Object.entries(lex2.terms)) {
|
|
817
|
+
if (entry.kind === 'trigger') entry.strength = 0.95 - (entry.term === '敏感词0' ? 0 : entry.term === '敏感词1' ? 0.1 : 0.2)
|
|
818
|
+
}
|
|
819
|
+
paths.writeJson(lexFile(libraryId), lex2)
|
|
820
|
+
const summary = lexicon.lexiconAnchorSummary(libraryId, {})
|
|
821
|
+
assert.equal(summary.lines.length, 8)
|
|
822
|
+
assert.equal(summary.sensitiveTriggers.length, 2)
|
|
823
|
+
// top8 按 strength 降序:最强(1.0 → 普通词9)在前,最弱的两条(0.1/0.2)被挤掉。
|
|
824
|
+
assert.match(summary.lines[0], /普通词9/)
|
|
825
|
+
assert.ok(!summary.lines.some(line => line.includes('普通词0')))
|
|
826
|
+
assert.ok(!summary.lines.some(line => line.includes('普通词1')), 'the two weakest terms are the ones dropped')
|
|
827
|
+
assert.ok(!summary.lines.some(line => line.includes('敏感')), 'sensitive triggers never mix into the main lines')
|
|
828
|
+
assert.match(summary.sensitiveTriggers[0], /敏感词0/)
|
|
829
|
+
// 敏感词不满足 contextVisible 时不出现。
|
|
830
|
+
const scoped = newLibrary()
|
|
831
|
+
lexicon.upsertLexiconTerm({ libraryId: scoped, term: '别界触发', kind: 'trigger', meaning: 'm', context: { worldId: 'w9' } })
|
|
832
|
+
assert.deepEqual(lexicon.lexiconAnchorSummary(scoped, { worldId: 'w1' }).sensitiveTriggers, [])
|
|
833
|
+
})
|
|
834
|
+
|
|
835
|
+
// ---- P1 revision-keyed 缓存:外部写必须被看见(docs/lexicon-design.md §10) ----
|
|
836
|
+
|
|
837
|
+
test('the revision-keyed cache never serves stale data after an out-of-process file write', () => {
|
|
838
|
+
const libraryId = newLibrary()
|
|
839
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '缓存词', kind: 'term', meaning: 'v1' })
|
|
840
|
+
assert.equal(Object.values(lexicon.loadLexicon(libraryId).terms)[0].meaning, 'v1', 'cache is warm')
|
|
841
|
+
// 绕过本进程直写(模拟另一个进程/备份恢复/手工修复):revision + 外来词条。
|
|
842
|
+
const onDisk = JSON.parse(fs.readFileSync(lexFile(libraryId), 'utf8'))
|
|
843
|
+
onDisk.revision = 99
|
|
844
|
+
onDisk.terms['lex-external'] = { term: '外来词', normalizedTerm: '外来词', kind: 'term', meaning: '外部写入', status: 'active', context: {} }
|
|
845
|
+
fs.writeFileSync(lexFile(libraryId), JSON.stringify(onDisk))
|
|
846
|
+
// 强制把 mtime 拨到未来:即便文件系统 mtime 粒度粗,下一条断言也必须成立。
|
|
847
|
+
const future = new Date(Date.now() + 5000)
|
|
848
|
+
fs.utimesSync(lexFile(libraryId), future, future)
|
|
849
|
+
const after = lexicon.loadLexicon(libraryId)
|
|
850
|
+
assert.equal(after.revision, 99, 'stat(mtime+size) mismatch must force a re-read')
|
|
851
|
+
assert.ok(after.terms['lex-external'], 'the external term is visible on the next read')
|
|
852
|
+
assert.equal(Object.values(lexicon.loadLexicon(libraryId).terms).length, 2)
|
|
853
|
+
// 外部删除(文件消失):绝不能拿缓存里的旧库当答案。
|
|
854
|
+
fs.rmSync(lexFile(libraryId))
|
|
855
|
+
const emptied = lexicon.loadLexicon(libraryId)
|
|
856
|
+
assert.deepEqual({ revision: emptied.revision, terms: emptied.terms }, { revision: 0, terms: {} })
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
test('in-process writes keep the cache fresh without a re-read (mutateLexicon rebuilds the snapshot)', () => {
|
|
860
|
+
const libraryId = newLibrary()
|
|
861
|
+
const { term } = lexicon.upsertLexiconTerm({ libraryId, term: '重建词', kind: 'term', meaning: 'v1' })
|
|
862
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '重建词', kind: 'term', meaning: 'v1', usage: 'v2-usage' })
|
|
863
|
+
const after = lexicon.loadLexicon(libraryId)
|
|
864
|
+
assert.equal(after.revision, 2)
|
|
865
|
+
assert.equal(after.terms[term.termId].usage, 'v2-usage', 'the write path rebuilds the cached snapshot')
|
|
866
|
+
// 返回的 terms 表是浅拷贝:调用方增删键不得污染缓存。
|
|
867
|
+
after.terms['lex-hijack'] = { term: '劫持', normalizedTerm: '劫持', kind: 'term', status: 'active', context: {} }
|
|
868
|
+
assert.equal(Object.keys(lexicon.loadLexicon(libraryId).terms).length, 1)
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
// ---- P1 语义统一(§8.3 / §10):敏感排序、作用域具体度、visibility 对齐 ----
|
|
872
|
+
|
|
873
|
+
test('sensitive ranking is unified: sensitivity=sensitive outranks exact normal terms regardless of kind', () => {
|
|
874
|
+
const libraryId = newLibrary()
|
|
875
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '敏感普通词', kind: 'term', meaning: '非触发词的敏感词条', sensitivity: 'sensitive' })
|
|
876
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '普通词条', kind: 'meme', meaning: '普通含义' })
|
|
877
|
+
// 抹平 strength/confidence/updatedAt:顺序只能由敏感优先决定。
|
|
878
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
879
|
+
for (const entry of Object.values(lex.terms)) {
|
|
880
|
+
entry.strength = 0.5
|
|
881
|
+
entry.confidence = 0.5
|
|
882
|
+
entry.updatedAt = '2026-01-01T00:00:00.000Z'
|
|
883
|
+
}
|
|
884
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
885
|
+
const hits = lexicon.matchLexiconHits(libraryId, '敏感普通词和普通词条都在这句话里', {})
|
|
886
|
+
assert.equal(hits.length, 2)
|
|
887
|
+
assert.deepEqual(hits.map(hit => hit.term), ['敏感普通词', '普通词条'],
|
|
888
|
+
'both are exact-term hits; the sensitive one (kind=term) must come first under the unified rule')
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
test('scope specificity is pinned: more defined identity fields rank first among otherwise equal hits', () => {
|
|
892
|
+
const libraryId = newLibrary()
|
|
893
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '场景词甲', kind: 'term', meaning: '限定在本世界', context: { worldId: 'w1', characterId: 'c1' } })
|
|
894
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '全局词乙', kind: 'term', meaning: '全场景共享' })
|
|
895
|
+
const lex = lexicon.loadLexicon(libraryId)
|
|
896
|
+
for (const entry of Object.values(lex.terms)) {
|
|
897
|
+
entry.strength = 0.5
|
|
898
|
+
entry.confidence = 0.5
|
|
899
|
+
entry.updatedAt = '2026-01-01T00:00:00.000Z'
|
|
900
|
+
}
|
|
901
|
+
paths.writeJson(lexFile(libraryId), lex)
|
|
902
|
+
// 同敏感档、同 exact-term、同权重:作用域具体度(IDENTITIES 非空数,2 vs 0)决定先后。
|
|
903
|
+
const hits = lexicon.matchLexiconHits(libraryId, '场景词甲和全局词乙都在这句话里', { worldId: 'w1', characterId: 'c1' })
|
|
904
|
+
assert.equal(hits.length, 2)
|
|
905
|
+
assert.deepEqual(hits.map(hit => hit.term), ['场景词甲', '全局词乙'])
|
|
906
|
+
// 空白 current context 看不到带 worldId 的词条,只剩全局词(与 contextVisible 一致)。
|
|
907
|
+
const blind = lexicon.matchLexiconHits(libraryId, '场景词甲和全局词乙都在这句话里', {})
|
|
908
|
+
assert.deepEqual(blind.map(hit => hit.term), ['全局词乙'])
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
test('anchor summary never surfaces visibility=secret terms; the B channel still explains them when named', () => {
|
|
912
|
+
const libraryId = newLibrary()
|
|
913
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '私语词', kind: 'term', meaning: '不主动浮出的词条', visibility: 'secret' })
|
|
914
|
+
lexicon.upsertLexiconTerm({ libraryId, term: '公开词', kind: 'term', meaning: '常规词条' })
|
|
915
|
+
const summary = lexicon.lexiconAnchorSummary(libraryId, {})
|
|
916
|
+
const joined = [...summary.lines, ...summary.sensitiveTriggers].join('\n')
|
|
917
|
+
assert.ok(joined.includes('公开词'))
|
|
918
|
+
assert.ok(!joined.includes('私语词'), 'secret vocabulary stays out of the anchor, like secret memories')
|
|
919
|
+
// 与 lib/memory.js 的 visibility 语义一致:secret 只是不主动浮出,被词面点名时仍然可见。
|
|
920
|
+
const hits = lexicon.matchLexiconHits(libraryId, '有人刚刚提到了私语词', {})
|
|
921
|
+
assert.deepEqual(hits.map(hit => hit.term), ['私语词'])
|
|
922
|
+
})
|