dsh-memu-memory 1.2.0 → 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 +19 -8
- package/README.zh.md +12 -6
- package/docs/contributing.md +16 -3
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +3 -1
- package/docs/memory-semantics.md +21 -2
- package/docs/troubleshooting.md +42 -3
- package/lib/activity-log.js +22 -5
- package/lib/backup.js +24 -2
- package/lib/binding-diagnostics.js +3 -3
- package/lib/client.js +1610 -117
- package/lib/consolidation-worker.js +68 -17
- package/lib/curator-evidence.js +131 -0
- package/lib/curator-policy.js +81 -0
- package/lib/curator.js +1299 -83
- 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 +190 -14
- package/lib/inbound.js +19 -0
- package/lib/index.js +33 -1
- 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 +585 -76
- package/lib/memu.js +2 -2
- package/lib/migrate.js +83 -2
- package/lib/private-links.js +587 -0
- package/lib/private.js +128 -7
- 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 +622 -13
- package/lib/state.js +4 -1
- package/lib/text-retrieval.js +661 -0
- package/lib/text-similarity.js +124 -0
- package/lib/tools.js +230 -16
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/lib/webui/standalone.css +17 -0
- package/lib/webui/standalone.js +2 -0
- package/package.json +3 -2
- 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/scripts/replay.mjs +573 -0
- package/tests/absorb-review.test.mjs +353 -0
- package/tests/appendix-b.test.mjs +6 -2
- 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-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +144 -10
- package/tests/consolidation-snapshot.test.mjs +82 -4
- 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 +324 -0
- 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 +223 -0
- package/tests/graph.test.mjs +113 -0
- package/tests/help-page.test.mjs +415 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- 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-memory.test.mjs +55 -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 +446 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/tool-schema.test.mjs +1 -1
- package/tests/tools.test.mjs +112 -3
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui-auth.test.mjs +348 -2
- package/tests/webui.test.mjs +191 -2
package/lib/state.js
CHANGED
|
@@ -101,6 +101,9 @@ export const DEFAULT_SETTINGS = {
|
|
|
101
101
|
activeMemoryStarvationMinutes: 15,
|
|
102
102
|
activeMemoryCooldownMinutes: 0,
|
|
103
103
|
activeMemoryRecallCooldownMinutes: 15,
|
|
104
|
+
// 全局提炼的审核放行(§4.10):'auto' 只暂扣冲突、缺证据与高风险改写;
|
|
105
|
+
// 'review-all' 把全部内容操作送进复核。硬门在两档下都生效。
|
|
106
|
+
curatorReviewMode: 'auto',
|
|
104
107
|
autoCurateEveryHours: 0,
|
|
105
108
|
autoCurateSessionLimit: 8,
|
|
106
109
|
// 'local' keeps every memory on this machine; 'cloud' hands MemU's own backend
|
|
@@ -353,7 +356,7 @@ export function updateSettings(patch) {
|
|
|
353
356
|
activeMemoryMinScore: [0, 10], activeMemoryStarvationMinutes: [1, 1440],
|
|
354
357
|
activeMemoryCooldownMinutes: [0, 10080], activeMemoryRecallCooldownMinutes: [0, 10080],
|
|
355
358
|
}
|
|
356
|
-
const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'] }
|
|
359
|
+
const enums = { autoInject: ['smart', 'anchor-only', 'per-turn', 'off'], memoryMode: ['local', 'cloud'], memoryBackend: ['agent', 'api'], vectorSource: ['local', 'api'], newSessionDefault: ['off', 'auto', 'global', 'both'], curatorReviewMode: ['auto', 'review-all'] }
|
|
357
360
|
for (const [key, value] of Object.entries(patch || {})) {
|
|
358
361
|
if (!Object.hasOwn(DEFAULT_SETTINGS, key)) continue
|
|
359
362
|
if (enums[key]) {
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
// Local candidate retrieval for the global curator (spec §4.1-§4.4, §4.8).
|
|
2
|
+
//
|
|
3
|
+
// Pipeline position: the LLM only arbitrates; everything here is model-free.
|
|
4
|
+
// tokenize + BM25 give the ranking spine, character-Dice/token/entity features
|
|
5
|
+
// give the pair score, weighted RRF fuses the three rankings, and the greedy
|
|
6
|
+
// all-pairs clique turns edges into candidate clusters. The commit layer
|
|
7
|
+
// (curator.js planCuratorOps) reuses rankPairCandidates as the final
|
|
8
|
+
// duplicate/conflict gate — one ruler from blocking to commit.
|
|
9
|
+
//
|
|
10
|
+
// Dependency discipline: this module imports only lib/text-similarity.js (the
|
|
11
|
+
// shared similarity ruler) and lib/curator-policy.js (the single constant
|
|
12
|
+
// table). No state, no IO, no clock — same input, same output, always.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
bigrams,
|
|
16
|
+
dice,
|
|
17
|
+
lengthGap,
|
|
18
|
+
similarityDetail,
|
|
19
|
+
similarTexts,
|
|
20
|
+
} from './text-similarity.js'
|
|
21
|
+
import {
|
|
22
|
+
BM25_B,
|
|
23
|
+
BM25_K1,
|
|
24
|
+
BM25_MAX_QUERY_TERMS,
|
|
25
|
+
BLOCK_DF_RATIO,
|
|
26
|
+
CLUSTER_BLOCK_MIN_SHARED,
|
|
27
|
+
CLUSTER_EXACT_PAIR_LIMIT,
|
|
28
|
+
CLUSTER_MAX,
|
|
29
|
+
CLUSTER_MAX_MEMBERS,
|
|
30
|
+
CLUSTER_MIN_PAIR,
|
|
31
|
+
CLUSTER_RECENT_LIMIT,
|
|
32
|
+
CROSS_DOMAIN_LINK_MIN,
|
|
33
|
+
PAIR_ABSORB_MIN,
|
|
34
|
+
PAIR_DUPLICATE_MIN,
|
|
35
|
+
PAIR_LINK_MIN,
|
|
36
|
+
PAIR_SCAN_LIMIT,
|
|
37
|
+
PAIR_W_ENTITY,
|
|
38
|
+
PAIR_W_LEXICAL,
|
|
39
|
+
PAIR_W_TITLE,
|
|
40
|
+
PAIR_W_TOKEN,
|
|
41
|
+
RRF_K,
|
|
42
|
+
RRF_W_BM25,
|
|
43
|
+
RRF_W_DICE,
|
|
44
|
+
RRF_W_ENTITY,
|
|
45
|
+
} from './curator-policy.js'
|
|
46
|
+
|
|
47
|
+
// —— §4.1 分词 ——
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verbatim §4.1: NFKC + lowercase; latin runs of ≥2 chars keep `._-` joins as
|
|
51
|
+
* one token; CJK runs become single chars when isolated, otherwise overlapping
|
|
52
|
+
* bigrams; the result is deduplicated (a set — Jaccard/blocking only care
|
|
53
|
+
* about presence). Build tf from tokenStream below, never from this.
|
|
54
|
+
*/
|
|
55
|
+
export function tokenize(text) {
|
|
56
|
+
const s = String(text || '').normalize('NFKC').toLowerCase()
|
|
57
|
+
const out = []
|
|
58
|
+
for (const m of s.matchAll(/[a-z0-9]+(?:[._-][a-z0-9]+)*/g)) {
|
|
59
|
+
if (m[0].length >= 2) out.push(m[0])
|
|
60
|
+
}
|
|
61
|
+
for (const m of s.matchAll(/[\u3400-\u9fff\uf900-\ufaff]+/g)) {
|
|
62
|
+
const run = m[0]
|
|
63
|
+
if (run.length === 1) out.push(run)
|
|
64
|
+
else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
|
|
65
|
+
}
|
|
66
|
+
return [...new Set(out)]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Raw token stream (no dedupe) — the frequency source for BM25's tf. */
|
|
70
|
+
function tokenStream(text) {
|
|
71
|
+
const s = String(text || '').normalize('NFKC').toLowerCase()
|
|
72
|
+
const out = []
|
|
73
|
+
for (const m of s.matchAll(/[a-z0-9]+(?:[._-][a-z0-9]+)*/g)) {
|
|
74
|
+
if (m[0].length >= 2) out.push(m[0])
|
|
75
|
+
}
|
|
76
|
+
for (const m of s.matchAll(/[\u3400-\u9fff\uf900-\ufaff]+/g)) {
|
|
77
|
+
const run = m[0]
|
|
78
|
+
if (run.length === 1) out.push(run)
|
|
79
|
+
else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// —— §4.1 语料与 BM25 ——
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* entries: [{ id, title, content, ...rest }] — every extra field travels on
|
|
88
|
+
* doc.entry so callers can reach track/domain/appliesTo/updatedAt without a
|
|
89
|
+
* second lookup. Returns { docs, df, inverted, avgdl, size }.
|
|
90
|
+
*/
|
|
91
|
+
export function buildCorpus(entries) {
|
|
92
|
+
const docs = new Map()
|
|
93
|
+
const df = new Map()
|
|
94
|
+
const inverted = new Map()
|
|
95
|
+
let totalLen = 0
|
|
96
|
+
for (const entry of entries || []) {
|
|
97
|
+
const id = entry?.id
|
|
98
|
+
if (id == null || docs.has(id)) continue
|
|
99
|
+
const stream = tokenStream(`${entry.title || ''} ${entry.content || ''}`)
|
|
100
|
+
const tf = new Map()
|
|
101
|
+
for (const term of stream) tf.set(term, (tf.get(term) || 0) + 1)
|
|
102
|
+
docs.set(id, { id, tokens: new Set(stream), tf, len: stream.length, entry })
|
|
103
|
+
totalLen += stream.length
|
|
104
|
+
for (const term of tf.keys()) {
|
|
105
|
+
df.set(term, (df.get(term) || 0) + 1)
|
|
106
|
+
if (!inverted.has(term)) inverted.set(term, new Set())
|
|
107
|
+
inverted.get(term).add(id)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
docs,
|
|
112
|
+
df,
|
|
113
|
+
inverted,
|
|
114
|
+
avgdl: docs.size ? totalLen / docs.size : 0,
|
|
115
|
+
size: docs.size,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function idfOf(corpus, term) {
|
|
120
|
+
const df = corpus.df.get(term) || 0
|
|
121
|
+
return Math.log(1 + (corpus.size - df + 0.5) / (df + 0.5))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Verbatim §4.1 formula. Queries wider than BM25_MAX_QUERY_TERMS keep only the
|
|
126
|
+
* 64 highest-IDF terms — measured among terms the corpus actually knows
|
|
127
|
+
* (df > 0): an absent term scores 0 no matter its nominal IDF, so letting it
|
|
128
|
+
* occupy the quota would only shrink the effective query. Ties break by term.
|
|
129
|
+
*/
|
|
130
|
+
export function bm25(queryTokens, doc, corpus, { k1 = BM25_K1, b = BM25_B } = {}) {
|
|
131
|
+
if (!queryTokens?.length || !doc || !corpus || corpus.size === 0) return 0
|
|
132
|
+
let terms = [...new Set(queryTokens)].filter((term) => corpus.df.has(term))
|
|
133
|
+
if (terms.length === 0) return 0
|
|
134
|
+
if (terms.length > BM25_MAX_QUERY_TERMS) {
|
|
135
|
+
terms = terms
|
|
136
|
+
.map((term) => ({ term, idf: idfOf(corpus, term) }))
|
|
137
|
+
.sort((a, b2) => b2.idf - a.idf || (a.term < b2.term ? -1 : 1))
|
|
138
|
+
.slice(0, BM25_MAX_QUERY_TERMS)
|
|
139
|
+
.map((item) => item.term)
|
|
140
|
+
}
|
|
141
|
+
let score = 0
|
|
142
|
+
for (const term of terms) {
|
|
143
|
+
const tf = doc.tf.get(term) || 0
|
|
144
|
+
if (!tf) continue
|
|
145
|
+
const idf = idfOf(corpus, term)
|
|
146
|
+
score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc.len / corpus.avgdl))
|
|
147
|
+
}
|
|
148
|
+
return score
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// —— §4.2 成对特征 ——
|
|
152
|
+
|
|
153
|
+
function jaccard(a, b) {
|
|
154
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
155
|
+
let shared = 0
|
|
156
|
+
for (const item of a) if (b.has(item)) shared += 1
|
|
157
|
+
return shared / (a.size + b.size - shared)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function entitySet(appliesTo) {
|
|
161
|
+
const out = new Set()
|
|
162
|
+
for (const item of Array.isArray(appliesTo) ? appliesTo : []) {
|
|
163
|
+
const v = String(item || '').trim().toLowerCase()
|
|
164
|
+
if (v) out.add(v)
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizedTitle(entry) {
|
|
170
|
+
return String(entry?.title || '').trim().toLowerCase()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* §4.2 pair features over two index-shaped entries
|
|
175
|
+
* ({ title, content, track, domain, appliesTo }).
|
|
176
|
+
* charDice — similarTexts(title+\n+content):length-prune + containment floor
|
|
177
|
+
* containment — the probe floor when the length prune fired, else 0
|
|
178
|
+
* lexical — max(charDice, containment)
|
|
179
|
+
* tokenJaccard — tokenize() set Jaccard over title+content
|
|
180
|
+
* titleDice — bigram Dice over the raw titles
|
|
181
|
+
* titleExact — trimmed/lowercase title equality (non-empty)
|
|
182
|
+
* entityJaccard — appliesTo set Jaccard; both empty → 0.5 neutral
|
|
183
|
+
*/
|
|
184
|
+
export function pairFeatures(a, b) {
|
|
185
|
+
const aText = `${a?.title || ''}\n${a?.content || ''}`
|
|
186
|
+
const bText = `${b?.title || ''}\n${b?.content || ''}`
|
|
187
|
+
const detail = similarityDetail(aText, bText)
|
|
188
|
+
return {
|
|
189
|
+
charDice: detail.dice,
|
|
190
|
+
containment: detail.containment,
|
|
191
|
+
lexical: Math.max(detail.dice, detail.containment),
|
|
192
|
+
tokenJaccard: jaccard(new Set(tokenize(aText)), new Set(tokenize(bText))),
|
|
193
|
+
titleDice: dice(bigrams(a?.title || ''), bigrams(b?.title || '')),
|
|
194
|
+
titleExact: normalizedTitle(a) !== '' && normalizedTitle(a) === normalizedTitle(b),
|
|
195
|
+
entityJaccard: entityScore(a?.appliesTo, b?.appliesTo),
|
|
196
|
+
lengthGap: detail.lengthGap,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function entityScore(aApplies, bApplies) {
|
|
201
|
+
const a = entitySet(aApplies)
|
|
202
|
+
const b = entitySet(bApplies)
|
|
203
|
+
if (a.size === 0 && b.size === 0) return 0.5
|
|
204
|
+
return jaccard(a, b)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* §4.2 weighted sum. Accepts a features object (preferred — pairFeatures is
|
|
209
|
+
* already computed on hot paths) or the two entries directly.
|
|
210
|
+
*/
|
|
211
|
+
export function pairScore(aOrFeatures, b) {
|
|
212
|
+
const f = aOrFeatures && typeof aOrFeatures === 'object' && 'lexical' in aOrFeatures
|
|
213
|
+
? aOrFeatures
|
|
214
|
+
: pairFeatures(aOrFeatures, b)
|
|
215
|
+
return PAIR_W_LEXICAL * f.lexical
|
|
216
|
+
+ PAIR_W_TOKEN * f.tokenJaccard
|
|
217
|
+
+ PAIR_W_TITLE * f.titleDice
|
|
218
|
+
+ PAIR_W_ENTITY * f.entityJaccard
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isRpCodingClash(aDomain, bDomain) {
|
|
222
|
+
return (aDomain === 'rp' && bDomain === 'coding') || (aDomain === 'coding' && bDomain === 'rp')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* §4.2 pairDecision, verbatim bands:
|
|
227
|
+
* track differs → none
|
|
228
|
+
* domain differs → link only at pairScore ≥0.65(跨域只 link 不 absorb;
|
|
229
|
+
* rp↔coding 永不 link——§4.9 的校验层再拦一次)
|
|
230
|
+
* titleExact && lexical <0.55 → conflict(同标题正文迥异;lexical 而非裸 charDice,
|
|
231
|
+
* 因为 §4.8 明确 containment ≥0.92 是重复/细化不是冲突)
|
|
232
|
+
* ≥0.90 duplicate / ≥0.82 absorb / ≥0.55 link / else none
|
|
233
|
+
*/
|
|
234
|
+
export function pairDecision(a, b, features = pairFeatures(a, b)) {
|
|
235
|
+
if ((a?.track || 'memory') !== (b?.track || 'memory')) return 'none'
|
|
236
|
+
const aDomain = a?.domain || 'unknown'
|
|
237
|
+
const bDomain = b?.domain || 'unknown'
|
|
238
|
+
const score = pairScore(features)
|
|
239
|
+
if (aDomain !== bDomain) {
|
|
240
|
+
if (isRpCodingClash(aDomain, bDomain)) return 'none'
|
|
241
|
+
return score >= CROSS_DOMAIN_LINK_MIN ? 'link' : 'none'
|
|
242
|
+
}
|
|
243
|
+
if (features.titleExact && features.lexical < PAIR_LINK_MIN) return 'conflict'
|
|
244
|
+
if (score >= PAIR_DUPLICATE_MIN) return 'duplicate'
|
|
245
|
+
if (score >= PAIR_ABSORB_MIN) return 'absorb'
|
|
246
|
+
if (score >= PAIR_LINK_MIN) return 'link'
|
|
247
|
+
return 'none'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// —— §4.8 全局冲突预筛 ——
|
|
251
|
+
|
|
252
|
+
// Same pattern as memory.js's NEGATION_RE (not exported there; lib/memory.js is
|
|
253
|
+
// outside this package's edit scope). One ruler for "opposite claim".
|
|
254
|
+
const NEGATION_RE = /不|没|别|无|never|not|no longer|doesn't|don't/i
|
|
255
|
+
|
|
256
|
+
/** True when the text carries a negation marker (memory.js findConflict 口径). */
|
|
257
|
+
export function negationPolarity(text) {
|
|
258
|
+
return NEGATION_RE.test(String(text || ''))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 数字、周X/星期X、YYYY-MM-DD、M月D日 —— 抽出来比集合(§4.8 numericSetsDiffer)。 */
|
|
262
|
+
export function numericSet(text) {
|
|
263
|
+
const s = String(text || '')
|
|
264
|
+
const out = new Set()
|
|
265
|
+
for (const m of s.matchAll(/\d{4}-\d{1,2}-\d{1,2}/g)) out.add(m[0])
|
|
266
|
+
for (const m of s.matchAll(/\d{1,2}月\d{1,2}[日号]/g)) out.add(m[0])
|
|
267
|
+
for (const m of s.matchAll(/周[一二三四五六日天]|星期[一二三四五六日天]/g)) out.add(m[0])
|
|
268
|
+
for (const m of s.matchAll(/\d+(?:\.\d+)?/g)) out.add(m[0])
|
|
269
|
+
return out
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function numericSetsDiffer(a, b) {
|
|
273
|
+
const sa = numericSet(a)
|
|
274
|
+
const sb = numericSet(b)
|
|
275
|
+
if (sa.size === 0 || sb.size === 0) return false
|
|
276
|
+
for (const item of sa) if (!sb.has(item)) return true
|
|
277
|
+
for (const item of sb) if (!sa.has(item)) return true
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* §4.8 全局冲突预筛(同 domain/track 前提由调用方保证)。返回 'same_title' |
|
|
283
|
+
* 'negation' | 'value' 或 null。containment ≥0.92 或 charDice ≥0.95 一票豁免
|
|
284
|
+
* (重复/细化,不是冲突)。预筛只决定“送不送人审”,最终由 LLM + 人审裁决。
|
|
285
|
+
*/
|
|
286
|
+
export function globalConflictCheck(a, b, features = pairFeatures(a, b)) {
|
|
287
|
+
if (features.containment >= 0.92 || features.charDice >= 0.95) return null
|
|
288
|
+
if (features.titleExact && features.charDice < 0.95) return 'same_title'
|
|
289
|
+
const aBody = String(a?.content || '')
|
|
290
|
+
const bBody = String(b?.content || '')
|
|
291
|
+
if (features.charDice >= 0.55 && negationPolarity(aBody) !== negationPolarity(bBody)) return 'negation'
|
|
292
|
+
if (features.titleDice >= 0.8 && features.charDice >= 0.55
|
|
293
|
+
&& numericSetsDiffer(`${a?.title || ''} ${aBody}`, `${b?.title || ''} ${bBody}`)) return 'value'
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// —— §4.3 候选召回 ——
|
|
298
|
+
|
|
299
|
+
function trackOf(entry) {
|
|
300
|
+
return entry?.track || 'memory'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function updatedDesc(a, b) {
|
|
304
|
+
return String(b?.updatedAt || '').localeCompare(String(a?.updatedAt || ''))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function idAsc(a, b) {
|
|
308
|
+
return String(a.id).localeCompare(String(b.id))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* §4.3 rankPairCandidates({ corpus, proposal, limit })。
|
|
313
|
+
*
|
|
314
|
+
* 1) blocking:同 track;N ≤ exactPairLimit 时精确两两,否则查询 token 中
|
|
315
|
+
* df ≤ 30%N 的稀有词、共享 ≥2 token,或 exact title。
|
|
316
|
+
* 2) 三路排名 bm25 / charDice / entityJaccard(tiebreak containment)。
|
|
317
|
+
* 3) 加权 RRF(k=60):0.5/(60+rankBm25) + 0.3/(60+rankDice) + 0.2/(60+rankEntity)。
|
|
318
|
+
* 4) 取前 limit 算完整 pairFeatures/pairScore/pairDecision,按 pairScore 降序。
|
|
319
|
+
* 5) 不足 limit 用同域最近条目补齐(“多看几眼”,不降低门槛)。
|
|
320
|
+
*
|
|
321
|
+
* 返回 [{ id, entry, score, decision, features, rrf, fallback }],score 降序,
|
|
322
|
+
* 平手 rrf 降序 → id 升序——完全确定。
|
|
323
|
+
*/
|
|
324
|
+
export function rankPairCandidates({ corpus, proposal, limit = PAIR_SCAN_LIMIT }) {
|
|
325
|
+
const max = Number(limit) > 0 ? Math.floor(Number(limit)) : PAIR_SCAN_LIMIT
|
|
326
|
+
const out = []
|
|
327
|
+
if (!corpus || corpus.size === 0 || !proposal) return out
|
|
328
|
+
const n = corpus.size
|
|
329
|
+
const proposalTrack = trackOf(proposal)
|
|
330
|
+
const proposalDomain = proposal?.domain || 'unknown'
|
|
331
|
+
const proposalTitle = normalizedTitle(proposal)
|
|
332
|
+
const proposalId = proposal?.id ?? null
|
|
333
|
+
const queryTokens = tokenize(`${proposal.title || ''} ${proposal.content || ''}`)
|
|
334
|
+
|
|
335
|
+
// 1) blocking
|
|
336
|
+
const candidates = new Map()
|
|
337
|
+
if (n <= CLUSTER_EXACT_PAIR_LIMIT) {
|
|
338
|
+
for (const doc of corpus.docs.values()) {
|
|
339
|
+
if (proposalId != null && doc.id === proposalId) continue
|
|
340
|
+
if (trackOf(doc.entry) !== proposalTrack) continue
|
|
341
|
+
candidates.set(doc.id, 0)
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
const dfCap = Math.ceil(BLOCK_DF_RATIO * n)
|
|
345
|
+
const shared = new Map()
|
|
346
|
+
for (const term of queryTokens) {
|
|
347
|
+
const posting = corpus.inverted.get(term)
|
|
348
|
+
if (!posting || posting.size === 0 || posting.size > dfCap) continue
|
|
349
|
+
for (const id of posting) {
|
|
350
|
+
if (proposalId != null && id === proposalId) continue
|
|
351
|
+
shared.set(id, (shared.get(id) || 0) + 1)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const [id, count] of shared) {
|
|
355
|
+
if (count >= CLUSTER_BLOCK_MIN_SHARED) candidates.set(id, count)
|
|
356
|
+
}
|
|
357
|
+
if (proposalTitle) {
|
|
358
|
+
for (const doc of corpus.docs.values()) {
|
|
359
|
+
if (proposalId != null && doc.id === proposalId) continue
|
|
360
|
+
if (trackOf(doc.entry) !== proposalTrack) continue
|
|
361
|
+
if (normalizedTitle(doc.entry) === proposalTitle) candidates.set(doc.id, Math.max(candidates.get(doc.id) || 0, CLUSTER_BLOCK_MIN_SHARED))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (candidates.size === 0) return backfill({ corpus, proposal, proposalDomain, proposalId, max, out: [], taken: new Set(), queryTokens })
|
|
366
|
+
|
|
367
|
+
// 2) three rankings
|
|
368
|
+
const ids = [...candidates.keys()]
|
|
369
|
+
const bm25Scores = ids.map((id) => ({ id, s: bm25(queryTokens, corpus.docs.get(id), corpus) }))
|
|
370
|
+
bm25Scores.sort((a, b) => b.s - a.s || idAsc(a, b))
|
|
371
|
+
const rankBm25 = new Map()
|
|
372
|
+
bm25Scores.forEach((item, index) => rankBm25.set(item.id, index + 1))
|
|
373
|
+
|
|
374
|
+
const probes = ids.map((id) => {
|
|
375
|
+
const doc = corpus.docs.get(id)
|
|
376
|
+
const detail = similarityDetail(`${proposal.title || ''}\n${proposal.content || ''}`, `${doc.entry?.title || ''}\n${doc.entry?.content || ''}`)
|
|
377
|
+
return { id, diceScore: detail.dice, containment: detail.containment, lexical: Math.max(detail.dice, detail.containment) }
|
|
378
|
+
})
|
|
379
|
+
const diceRankOrder = [...probes].sort((a, b) => b.diceScore - a.diceScore || b.containment - a.containment || idAsc(a, b))
|
|
380
|
+
const rankDice = new Map()
|
|
381
|
+
diceRankOrder.forEach((item, index) => rankDice.set(item.id, index + 1))
|
|
382
|
+
|
|
383
|
+
const entityOrder = ids.map((id) => {
|
|
384
|
+
const doc = corpus.docs.get(id)
|
|
385
|
+
return {
|
|
386
|
+
id,
|
|
387
|
+
entity: entityScore(proposal.appliesTo, doc.entry?.appliesTo),
|
|
388
|
+
containment: containmentOf(proposal, doc.entry),
|
|
389
|
+
}
|
|
390
|
+
}).sort((a, b) => b.entity - a.entity || b.containment - a.containment || idAsc(a, b))
|
|
391
|
+
const rankEntity = new Map()
|
|
392
|
+
entityOrder.forEach((item, index) => rankEntity.set(item.id, index + 1))
|
|
393
|
+
|
|
394
|
+
// 3) weighted RRF
|
|
395
|
+
const rrf = new Map()
|
|
396
|
+
for (const id of ids) {
|
|
397
|
+
rrf.set(id, RRF_W_BM25 / (RRF_K + rankBm25.get(id))
|
|
398
|
+
+ RRF_W_DICE / (RRF_K + rankDice.get(id))
|
|
399
|
+
+ RRF_W_ENTITY / (RRF_K + rankEntity.get(id)))
|
|
400
|
+
}
|
|
401
|
+
ids.sort((a, b) => rrf.get(b) - rrf.get(a) || idAsc(a, b))
|
|
402
|
+
|
|
403
|
+
// 4) features for the top slice only
|
|
404
|
+
const taken = new Set()
|
|
405
|
+
for (const id of ids.slice(0, max)) {
|
|
406
|
+
const doc = corpus.docs.get(id)
|
|
407
|
+
const features = pairFeatures(proposal, doc.entry)
|
|
408
|
+
const score = pairScore(features)
|
|
409
|
+
taken.add(id)
|
|
410
|
+
out.push({ id, entry: doc.entry, score, decision: pairDecision(proposal, doc.entry, features), features, rrf: rrf.get(id), fallback: false })
|
|
411
|
+
}
|
|
412
|
+
out.sort((a, b) => b.score - a.score || b.rrf - a.rrf || idAsc(a, b))
|
|
413
|
+
// 5) same-domain recent backfill — extra eyes, never a lower bar
|
|
414
|
+
return backfill({ corpus, proposal, proposalDomain, proposalId, max, out, taken, queryTokens })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function containmentOf(a, b) {
|
|
418
|
+
const detail = similarityDetail(`${a?.title || ''}\n${a?.content || ''}`, `${b?.title || ''}\n${b?.content || ''}`)
|
|
419
|
+
return detail.containment
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function backfill({ corpus, proposal, proposalDomain, proposalId, max, out, taken, queryTokens }) {
|
|
423
|
+
if (out.length >= max) return out
|
|
424
|
+
const recent = [...corpus.docs.values()]
|
|
425
|
+
.filter((doc) => proposalId == null || doc.id !== proposalId)
|
|
426
|
+
.filter((doc) => (doc.entry?.domain || 'unknown') === proposalDomain)
|
|
427
|
+
.filter((doc) => !taken.has(doc.id))
|
|
428
|
+
.sort((a, b) => updatedDesc(a.entry, b.entry) || idAsc(a, b))
|
|
429
|
+
for (const doc of recent) {
|
|
430
|
+
if (out.length >= max) break
|
|
431
|
+
const features = pairFeatures(proposal, doc.entry)
|
|
432
|
+
const score = pairScore(features)
|
|
433
|
+
taken.add(doc.id)
|
|
434
|
+
out.push({ id: doc.id, entry: doc.entry, score, decision: pairDecision(proposal, doc.entry, features), features, rrf: 0, fallback: true })
|
|
435
|
+
}
|
|
436
|
+
out.sort((a, b) => b.score - a.score || b.rrf - a.rrf || idAsc(a, b))
|
|
437
|
+
return out
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// —— §4.4 候选簇 ——
|
|
441
|
+
|
|
442
|
+
function pairKey(aId, bId) {
|
|
443
|
+
return aId < bId ? `${aId}\u0000${bId}` : `${bId}\u0000${aId}`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function round2(value) {
|
|
447
|
+
return Math.round(value * 100) / 100
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* §4.4 buildCandidateClusters(index, options)。
|
|
452
|
+
*
|
|
453
|
+
* 1) active 条目按 updatedAt 降序取最近 recentLimit(2000) 条,按 track 分组。
|
|
454
|
+
* 2) 同 track 内:N ≤ exactPairLimit(300) 全对;否则倒排 blocking(共享
|
|
455
|
+
* ≥ blockMinShared(2) token)。
|
|
456
|
+
* 3) 候选对算 pairFeatures/pairScore,保留 ≥ minPairScore(0.55) 的边。
|
|
457
|
+
* 4) 边排序用三路 RRF(同 §4.3:bm25 双向和 / charDice / entityJaccard)。
|
|
458
|
+
* 5) 贪心成簇:边按 RRF 降序;种子=边两端;新成员必须与簇内**所有**成员
|
|
459
|
+
* pairScore ≥ 0.55(全对约束,禁传递闭包);每簇 ≤6;每条目只进一个簇。
|
|
460
|
+
* 6) medoid = 成员两两平均分最高者(平手 updatedAt 降序 → id 升序)。
|
|
461
|
+
* 7) suggested_op:≥3 成员且 avg ≥0.82 且同 domain → absorb;2+ 成员且
|
|
462
|
+
* avg ≥0.55 → link;否则 none。medoid 平均 <0.55 整簇丢弃。
|
|
463
|
+
* 8) 按 avg_score 降序最多输出 maxClusters(12) 簇。
|
|
464
|
+
*
|
|
465
|
+
* 返回 { clusters, truncated, scanned }。
|
|
466
|
+
*/
|
|
467
|
+
export function buildCandidateClusters(index, options = {}) {
|
|
468
|
+
const maxClusters = options.maxClusters ?? CLUSTER_MAX
|
|
469
|
+
const maxMembers = options.maxMembers ?? CLUSTER_MAX_MEMBERS
|
|
470
|
+
const minPair = options.minPairScore ?? CLUSTER_MIN_PAIR
|
|
471
|
+
const blockMinShared = options.blockMinShared ?? CLUSTER_BLOCK_MIN_SHARED
|
|
472
|
+
const exactPairLimit = options.exactPairLimit ?? CLUSTER_EXACT_PAIR_LIMIT
|
|
473
|
+
const recentLimit = options.recentLimit ?? CLUSTER_RECENT_LIMIT
|
|
474
|
+
|
|
475
|
+
const active = Object.entries(index?.entries || index || {})
|
|
476
|
+
.filter(([, e]) => e && e.status === 'active')
|
|
477
|
+
.map(([id, entry]) => ({ id, entry }))
|
|
478
|
+
.sort((a, b) => updatedDesc(a.entry, b.entry) || idAsc(a, b))
|
|
479
|
+
const truncated = active.length > recentLimit
|
|
480
|
+
const pool = active.slice(0, recentLimit)
|
|
481
|
+
|
|
482
|
+
const byTrack = new Map()
|
|
483
|
+
for (const item of pool) {
|
|
484
|
+
const track = trackOf(item.entry)
|
|
485
|
+
if (!byTrack.has(track)) byTrack.set(track, [])
|
|
486
|
+
byTrack.get(track).push(item)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// edges: { a, b, aId, bId, features, score, rrf }
|
|
490
|
+
const edges = []
|
|
491
|
+
for (const [track, items] of byTrack) {
|
|
492
|
+
const n = items.length
|
|
493
|
+
if (n < 2) continue
|
|
494
|
+
const tokenSets = new Map()
|
|
495
|
+
const inverted = new Map()
|
|
496
|
+
for (const item of items) {
|
|
497
|
+
const tokens = tokenize(`${item.entry?.title || ''} ${item.entry?.content || ''}`)
|
|
498
|
+
tokenSets.set(item.id, new Set(tokens))
|
|
499
|
+
for (const term of tokens) {
|
|
500
|
+
if (!inverted.has(term)) inverted.set(term, [])
|
|
501
|
+
inverted.get(term).push(item.id)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const byId = new Map(items.map((item) => [item.id, item]))
|
|
505
|
+
const pairSet = new Set()
|
|
506
|
+
if (n <= exactPairLimit) {
|
|
507
|
+
for (let i = 0; i < n; i++) {
|
|
508
|
+
for (let j = i + 1; j < n; j++) pairSet.add(pairKey(items[i].id, items[j].id))
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
const shared = new Map()
|
|
512
|
+
for (const posting of inverted.values()) {
|
|
513
|
+
if (posting.length < 2) continue
|
|
514
|
+
for (let i = 0; i < posting.length; i++) {
|
|
515
|
+
for (let j = i + 1; j < posting.length; j++) {
|
|
516
|
+
const key = pairKey(posting[i], posting[j])
|
|
517
|
+
shared.set(key, (shared.get(key) || 0) + 1)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
for (const [key, count] of shared) {
|
|
522
|
+
if (count >= blockMinShared) pairSet.add(key)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (pairSet.size === 0) continue
|
|
526
|
+
|
|
527
|
+
const corpus = buildCorpus(items.map((item) => ({ id: item.id, title: item.entry?.title || '', content: item.entry?.content || '', entry: item.entry })))
|
|
528
|
+
const pairList = [...pairSet].map((key) => {
|
|
529
|
+
const [aId, bId] = key.split('\u0000')
|
|
530
|
+
const aItem = byId.get(aId)
|
|
531
|
+
const bItem = byId.get(bId)
|
|
532
|
+
const features = pairFeatures(aItem.entry, bItem.entry)
|
|
533
|
+
const score = pairScore(features)
|
|
534
|
+
return { key, aId, bId, aItem, bItem, features, score }
|
|
535
|
+
}).filter((pair) => pair.score >= minPair)
|
|
536
|
+
if (pairList.length === 0) continue
|
|
537
|
+
|
|
538
|
+
// three route rankings for edge order only
|
|
539
|
+
const bm25Route = pairList.map((pair) => ({
|
|
540
|
+
key: pair.key,
|
|
541
|
+
s: bm25(tokenStream(`${pair.aItem.entry?.title || ''} ${pair.aItem.entry?.content || ''}`), corpus.docs.get(pair.bId), corpus)
|
|
542
|
+
+ bm25(tokenStream(`${pair.bItem.entry?.title || ''} ${pair.bItem.entry?.content || ''}`), corpus.docs.get(pair.aId), corpus),
|
|
543
|
+
})).sort((a, b) => b.s - a.s || (a.key < b.key ? -1 : 1))
|
|
544
|
+
const rankBm25 = new Map()
|
|
545
|
+
bm25Route.forEach((item, i) => rankBm25.set(item.key, i + 1))
|
|
546
|
+
const diceRoute = [...pairList].sort((a, b) => b.features.charDice - a.features.charDice
|
|
547
|
+
|| b.features.containment - a.features.containment || (a.key < b.key ? -1 : 1))
|
|
548
|
+
const rankDice = new Map()
|
|
549
|
+
diceRoute.forEach((item, i) => rankDice.set(item.key, i + 1))
|
|
550
|
+
const entityRoute = [...pairList].sort((a, b) => b.features.entityJaccard - a.features.entityJaccard
|
|
551
|
+
|| b.features.containment - a.features.containment || (a.key < b.key ? -1 : 1))
|
|
552
|
+
const rankEntity = new Map()
|
|
553
|
+
entityRoute.forEach((item, i) => rankEntity.set(item.key, i + 1))
|
|
554
|
+
for (const pair of pairList) {
|
|
555
|
+
pair.rrf = RRF_W_BM25 / (RRF_K + rankBm25.get(pair.key))
|
|
556
|
+
+ RRF_W_DICE / (RRF_K + rankDice.get(pair.key))
|
|
557
|
+
+ RRF_W_ENTITY / (RRF_K + rankEntity.get(pair.key))
|
|
558
|
+
}
|
|
559
|
+
pairList.sort((a, b) => b.rrf - a.rrf || b.score - a.score || (a.key < b.key ? -1 : 1))
|
|
560
|
+
edges.push(...pairList)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// greedy all-pairs clique growth
|
|
564
|
+
edges.sort((a, b) => b.rrf - a.rrf || b.score - a.score || (a.key < b.key ? -1 : 1))
|
|
565
|
+
const memberOf = new Map()
|
|
566
|
+
const clusters = []
|
|
567
|
+
for (const edge of edges) {
|
|
568
|
+
const clusterA = memberOf.get(edge.aId)
|
|
569
|
+
const clusterB = memberOf.get(edge.bId)
|
|
570
|
+
if (clusterA && clusterB) continue
|
|
571
|
+
if (!clusterA && !clusterB) {
|
|
572
|
+
if (clusters.length >= maxClusters) continue
|
|
573
|
+
const cluster = { members: [edge.aItem, edge.bItem], scores: new Map([[edge.key, edge.score]]) }
|
|
574
|
+
clusters.push(cluster)
|
|
575
|
+
memberOf.set(edge.aId, cluster)
|
|
576
|
+
memberOf.set(edge.bId, cluster)
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
const cluster = clusterA || clusterB
|
|
580
|
+
if (cluster.members.length >= maxMembers) continue
|
|
581
|
+
const candidate = clusterA ? edge.bItem : edge.aItem
|
|
582
|
+
let ok = true
|
|
583
|
+
for (const member of cluster.members) {
|
|
584
|
+
const key = pairKey(candidate.id, member.id)
|
|
585
|
+
let score = cluster.scores.get(key)
|
|
586
|
+
if (score == null) {
|
|
587
|
+
score = pairScore(pairFeatures(candidate.entry, member.entry))
|
|
588
|
+
cluster.scores.set(key, score)
|
|
589
|
+
}
|
|
590
|
+
if (score < minPair) { ok = false; break }
|
|
591
|
+
}
|
|
592
|
+
if (!ok) continue
|
|
593
|
+
cluster.members.push(candidate)
|
|
594
|
+
memberOf.set(candidate.id, cluster)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// medoid + output
|
|
598
|
+
const out = []
|
|
599
|
+
for (const cluster of clusters) {
|
|
600
|
+
const members = [...cluster.members].sort((a, b) => idAsc(a, b))
|
|
601
|
+
const pairScores = {}
|
|
602
|
+
let sum = 0
|
|
603
|
+
let count = 0
|
|
604
|
+
for (let i = 0; i < members.length; i++) {
|
|
605
|
+
for (let j = i + 1; j < members.length; j++) {
|
|
606
|
+
const key = pairKey(members[i].id, members[j].id)
|
|
607
|
+
let score = cluster.scores.get(key)
|
|
608
|
+
if (score == null) {
|
|
609
|
+
score = pairScore(pairFeatures(members[i].entry, members[j].entry))
|
|
610
|
+
cluster.scores.set(key, score)
|
|
611
|
+
}
|
|
612
|
+
pairScores[`${members[i].id}|${members[j].id}`] = round2(score)
|
|
613
|
+
sum += score
|
|
614
|
+
count += 1
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const avg = count ? sum / count : 0
|
|
618
|
+
let medoid = null
|
|
619
|
+
let bestAvg = -1
|
|
620
|
+
for (const member of members) {
|
|
621
|
+
let mSum = 0
|
|
622
|
+
for (const other of members) {
|
|
623
|
+
if (other.id === member.id) continue
|
|
624
|
+
const key = pairKey(member.id, other.id)
|
|
625
|
+
mSum += cluster.scores.get(key) ?? 0
|
|
626
|
+
}
|
|
627
|
+
const mAvg = members.length > 1 ? mSum / (members.length - 1) : 0
|
|
628
|
+
if (mAvg > bestAvg + 1e-9
|
|
629
|
+
|| (Math.abs(mAvg - bestAvg) <= 1e-9 && medoid
|
|
630
|
+
&& (updatedDesc(member.entry, medoid.entry) < 0
|
|
631
|
+
|| (updatedDesc(member.entry, medoid.entry) === 0 && String(member.id).localeCompare(String(medoid.id)) < 0)))) {
|
|
632
|
+
bestAvg = mAvg
|
|
633
|
+
medoid = member
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
// medoid 平均 <0.55 → 整簇不输出(§4.4.9;全对约束下属防御分支)
|
|
637
|
+
if (members.length > 1 && bestAvg < minPair) continue
|
|
638
|
+
const sameDomain = members.every((m) => (m.entry?.domain || 'unknown') === (members[0].entry?.domain || 'unknown'))
|
|
639
|
+
const suggestedOp = members.length >= 3 && avg >= PAIR_ABSORB_MIN && sameDomain
|
|
640
|
+
? 'absorb'
|
|
641
|
+
: (members.length >= 2 && avg >= minPair ? 'link' : 'none')
|
|
642
|
+
out.push({
|
|
643
|
+
cluster_id: '',
|
|
644
|
+
members: members.map((m) => ({
|
|
645
|
+
id: m.id,
|
|
646
|
+
title: m.entry?.title || '',
|
|
647
|
+
track: trackOf(m.entry),
|
|
648
|
+
domain: m.entry?.domain || 'unknown',
|
|
649
|
+
updatedAt: m.entry?.updatedAt || null,
|
|
650
|
+
content: m.entry?.content || '',
|
|
651
|
+
})),
|
|
652
|
+
pair_scores: pairScores,
|
|
653
|
+
medoid_id: medoid?.id || members[0]?.id || '',
|
|
654
|
+
avg_score: round2(avg),
|
|
655
|
+
suggested_op: suggestedOp,
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
out.sort((a, b) => b.avg_score - a.avg_score || String(a.medoid_id).localeCompare(String(b.medoid_id)))
|
|
659
|
+
for (let i = 0; i < out.length; i++) out[i].cluster_id = `cl-${i + 1}`
|
|
660
|
+
return { clusters: out.slice(0, maxClusters), truncated, scanned: pool.length }
|
|
661
|
+
}
|