mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(import): preserve fallback message multiplicity
Preserve repeated same-second WeFlow and EchoTrace messages while consuming existing fallback occurrences one for one across overlapping imports and merged sources.
This commit is contained in:
1 parent
af68f13d09
commit
dcee433174
8 files changed
+318
-33
No files matched your search
@@ -67,6 +67,27 @@ function writeTempDuplicateChatFile(dir: string): string {
|
||||
return filePath
|
||||
}
|
||||
|
||||
function writeWeFlowImageExport(dir: string, filename: string, messageCount: number): string {
|
||||
const filePath = path.join(dir, filename)
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
weflow: { version: '1.0.0' },
|
||||
session: { wxid: 'room@chatroom', displayName: 'Image Room', type: '群聊' },
|
||||
messages: Array.from({ length: messageCount }, (_, index) => ({
|
||||
localId: index + 1,
|
||||
createTime: 1711468800,
|
||||
type: '图片消息',
|
||||
content: '[图片]',
|
||||
isSend: 0,
|
||||
senderUsername: 'wxid_alice',
|
||||
senderDisplayName: 'Alice',
|
||||
})),
|
||||
})
|
||||
)
|
||||
return filePath
|
||||
}
|
||||
|
||||
function writeBatchChatFile(dir: string, filename: string, groupId: string, content: string): string {
|
||||
const filePath = path.join(dir, filename)
|
||||
fs.writeFileSync(
|
||||
@@ -475,6 +496,37 @@ test('autoImport reports the same exact-message deduplication on create and incr
|
||||
assert.equal(row.count, 1)
|
||||
})
|
||||
|
||||
test('autoImport preserves same-second WeFlow message counts across overlapping exports', async (t) => {
|
||||
const root = makeTempDir()
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true })
|
||||
const manager = new DatabaseManager(createPathProvider(root), {
|
||||
nativeBinding,
|
||||
runtime: { version: '0.25.1', kind: 'cli' },
|
||||
})
|
||||
const firstExport = writeWeFlowImageExport(root, 'weflow-first.json', 2)
|
||||
const overlappingExport = writeWeFlowImageExport(root, 'weflow-overlap.json', 3)
|
||||
|
||||
const created = await autoImport(manager, firstExport, { nativeBinding })
|
||||
const incremental = await autoImport(manager, overlappingExport, { nativeBinding })
|
||||
const repeated = await autoImport(manager, overlappingExport, { nativeBinding })
|
||||
|
||||
assert.equal(created.success, true)
|
||||
assert.equal(created.newMessageCount, 2)
|
||||
assert.equal(created.duplicateCount, 0)
|
||||
assert.equal(incremental.success, true)
|
||||
assert.equal(incremental.newMessageCount, 1)
|
||||
assert.equal(incremental.duplicateCount, 2)
|
||||
assert.equal(repeated.success, true)
|
||||
assert.equal(repeated.newMessageCount, 0)
|
||||
assert.equal(repeated.duplicateCount, 3)
|
||||
|
||||
const db = manager.openRawSessionDatabase(created.sessionId!, { readonly: true })
|
||||
const row = db.prepare('SELECT COUNT(*) AS count FROM message').get() as { count: number }
|
||||
db.close()
|
||||
assert.equal(row.count, 3)
|
||||
})
|
||||
|
||||
test('autoImport uses an explicit id for create and then forces incremental import', async () => {
|
||||
const root = makeTempDir()
|
||||
fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true })
|
||||
|
||||
@@ -512,6 +512,60 @@ test('preserves canonical dedup semantics while checking existing database candi
|
||||
}
|
||||
})
|
||||
|
||||
test('bridges stable IDs to repeated fallback-only occurrences one for one', async (t) => {
|
||||
const tempDir = makeTempDir()
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const dbPath = path.join(tempDir, 'session.db')
|
||||
const filePath = path.join(tempDir, 'incoming.json')
|
||||
seedSessionDb(dbPath)
|
||||
seedExistingMessage(dbPath, {})
|
||||
|
||||
const seededDb = openBetterSqliteDatabase(dbPath, { nativeBinding })
|
||||
const member = seededDb.prepare('SELECT id FROM member WHERE platform_id = ?').get('wxid_alice') as { id: number }
|
||||
seededDb
|
||||
.prepare('INSERT INTO message (sender_id, ts, type, content) VALUES (?, ?, ?, ?)')
|
||||
.run(member.id, 1780330832, 0, 'same message')
|
||||
seededDb.close()
|
||||
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
weflow: { version: '1.0.0' },
|
||||
session: { wxid: 'wxid_alice', displayName: 'Alice', type: '私聊' },
|
||||
messages: ['msg-1', 'msg-2', 'msg-3'].map((platformMessageId, index) => ({
|
||||
localId: index + 1,
|
||||
platformMessageId,
|
||||
createTime: 1780330832,
|
||||
type: '文本消息',
|
||||
content: 'same message',
|
||||
senderUsername: 'wxid_alice',
|
||||
senderDisplayName: 'Alice',
|
||||
})),
|
||||
})
|
||||
)
|
||||
|
||||
const deps = createDeps(dbPath)
|
||||
const analysis = await analyzeIncrementalImport('session', filePath, deps, { formatId: 'weflow' })
|
||||
assert.equal(analysis.newMessageCount, 1)
|
||||
assert.equal(analysis.duplicateCount, 2)
|
||||
|
||||
const result = await incrementalImport('session', filePath, deps, { formatId: 'weflow' })
|
||||
assert.equal(result.success, true)
|
||||
assert.equal(result.newMessageCount, 1)
|
||||
assert.equal(result.batch?.duplicateCount, 2)
|
||||
|
||||
const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding })
|
||||
const rows = db.prepare('SELECT platform_message_id FROM message ORDER BY id').all() as Array<{
|
||||
platform_message_id: string | null
|
||||
}>
|
||||
db.close()
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.platform_message_id),
|
||||
[null, null, 'msg-3']
|
||||
)
|
||||
})
|
||||
|
||||
test('deduplicates repeated input across parser message batches', async (t) => {
|
||||
const tempDir = makeTempDir()
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
type DedupMessage,
|
||||
type MessageDedupState,
|
||||
} from './message-deduplicator'
|
||||
import { normalizeSystemMemberName, SYSTEM_MEMBER_NAME, type ImportProgressCallback } from './streaming-importer'
|
||||
import {
|
||||
normalizeSystemMemberName,
|
||||
shouldPreserveFallbackMultiplicity,
|
||||
SYSTEM_MEMBER_NAME,
|
||||
type ImportProgressCallback,
|
||||
} from './streaming-importer'
|
||||
|
||||
// ==================== Public interfaces ====================
|
||||
|
||||
@@ -109,8 +114,9 @@ interface ExistingMessageLookup {
|
||||
db: DatabaseAdapter
|
||||
maxMessageId: number
|
||||
hasFallbackOnlyMessages: boolean
|
||||
consumedFallbackOnlyKeys: Set<string>
|
||||
consumedFallbackOnlyOccurrenceCounts: Map<string, number>
|
||||
bridgedPlatformMessageIds: Set<string>
|
||||
consumedFallbackOccurrenceCounts: Map<string, number>
|
||||
candidateStatement: PreparedStatement
|
||||
platformIdStatements: Map<number, PreparedStatement>
|
||||
}
|
||||
@@ -159,8 +165,9 @@ function createExistingMessageLookup(db: DatabaseAdapter): ExistingMessageLookup
|
||||
db,
|
||||
maxMessageId,
|
||||
hasFallbackOnlyMessages,
|
||||
consumedFallbackOnlyKeys: new Set<string>(),
|
||||
consumedFallbackOnlyOccurrenceCounts: new Map<string, number>(),
|
||||
bridgedPlatformMessageIds: new Set<string>(),
|
||||
consumedFallbackOccurrenceCounts: new Map<string, number>(),
|
||||
candidateStatement: db.prepare(
|
||||
`SELECT msg.type, msg.content, msg.reply_to_message_id, msg.platform_message_id
|
||||
FROM member m
|
||||
@@ -229,7 +236,8 @@ function isExistingMessageDuplicate(
|
||||
lookup: ExistingMessageLookup,
|
||||
message: DedupMessage,
|
||||
existingBatchPlatformIds: Set<string>,
|
||||
candidateCache: CandidateCache
|
||||
candidateCache: CandidateCache,
|
||||
preserveFallbackMultiplicity: boolean
|
||||
): boolean {
|
||||
if (
|
||||
message.platformMessageId &&
|
||||
@@ -241,20 +249,30 @@ function isExistingMessageDuplicate(
|
||||
|
||||
const fallbackKey = generateFallbackMessageKey(message)
|
||||
if (message.platformMessageId) {
|
||||
if (!lookup.hasFallbackOnlyMessages || lookup.consumedFallbackOnlyKeys.has(fallbackKey)) return false
|
||||
const matchesFallbackOnly = getExistingCandidates(lookup, message, candidateCache).some(
|
||||
if (!lookup.hasFallbackOnlyMessages) return false
|
||||
const matchingFallbackOnlyCount = getExistingCandidates(lookup, message, candidateCache).filter(
|
||||
(candidate) => !candidate.platform_message_id && candidateFallbackKey(message, candidate) === fallbackKey
|
||||
)
|
||||
).length
|
||||
const consumedCount = lookup.consumedFallbackOnlyOccurrenceCounts.get(fallbackKey) ?? 0
|
||||
const matchesFallbackOnly = preserveFallbackMultiplicity
|
||||
? consumedCount < matchingFallbackOnlyCount
|
||||
: consumedCount === 0 && matchingFallbackOnlyCount > 0
|
||||
if (matchesFallbackOnly) {
|
||||
lookup.consumedFallbackOnlyKeys.add(fallbackKey)
|
||||
lookup.consumedFallbackOnlyOccurrenceCounts.set(fallbackKey, consumedCount + 1)
|
||||
lookup.bridgedPlatformMessageIds.add(message.platformMessageId)
|
||||
}
|
||||
return matchesFallbackOnly
|
||||
}
|
||||
|
||||
return getExistingCandidates(lookup, message, candidateCache).some(
|
||||
const matchingCount = getExistingCandidates(lookup, message, candidateCache).filter(
|
||||
(candidate) => candidateFallbackKey(message, candidate) === fallbackKey
|
||||
)
|
||||
).length
|
||||
if (!preserveFallbackMultiplicity) return matchingCount > 0
|
||||
|
||||
const consumedCount = lookup.consumedFallbackOccurrenceCounts.get(fallbackKey) ?? 0
|
||||
if (consumedCount >= matchingCount) return false
|
||||
lookup.consumedFallbackOccurrenceCounts.set(fallbackKey, consumedCount + 1)
|
||||
return true
|
||||
}
|
||||
|
||||
function isDuplicate(
|
||||
@@ -262,9 +280,13 @@ function isDuplicate(
|
||||
incomingState: MessageDedupState,
|
||||
message: DedupMessage,
|
||||
existingBatchPlatformIds: Set<string>,
|
||||
candidateCache: CandidateCache
|
||||
candidateCache: CandidateCache,
|
||||
preserveFallbackMultiplicity: boolean
|
||||
): boolean {
|
||||
if (isExistingMessageDuplicate(lookup, message, existingBatchPlatformIds, candidateCache)) return true
|
||||
if (
|
||||
isExistingMessageDuplicate(lookup, message, existingBatchPlatformIds, candidateCache, preserveFallbackMultiplicity)
|
||||
)
|
||||
return true
|
||||
return registerMessageAndCheckDuplicate(message, incomingState)
|
||||
}
|
||||
|
||||
@@ -298,10 +320,11 @@ export async function analyzeIncrementalImport(
|
||||
let duplicateCount = 0
|
||||
let platform = formatFeature.platform
|
||||
const mapPlatformId = createPlatformIdMapper(options?.senderPlatformIdMappings)
|
||||
const preserveFallbackMultiplicity = shouldPreserveFallbackMultiplicity(formatFeature.id)
|
||||
|
||||
try {
|
||||
const existingLookup = createExistingMessageLookup(db)
|
||||
const incomingState = createMessageDedupState()
|
||||
const incomingState = createMessageDedupState([], [], [], { preserveFallbackMultiplicity })
|
||||
|
||||
await streamParseFile(
|
||||
filePath,
|
||||
@@ -336,7 +359,8 @@ export async function analyzeIncrementalImport(
|
||||
incomingState,
|
||||
{ ...msg, timestamp },
|
||||
existingBatchPlatformIds,
|
||||
candidateCache
|
||||
candidateCache,
|
||||
preserveFallbackMultiplicity
|
||||
)
|
||||
) {
|
||||
duplicateCount++
|
||||
@@ -378,10 +402,11 @@ export async function incrementalImport(
|
||||
const metaUpdateMode = options?.metaUpdateMode ?? 'patch'
|
||||
const memberUpdateMode = options?.memberUpdateMode ?? 'upsert'
|
||||
const mapPlatformId = createPlatformIdMapper(options?.senderPlatformIdMappings)
|
||||
const preserveFallbackMultiplicity = shouldPreserveFallbackMultiplicity(formatFeature.id)
|
||||
|
||||
try {
|
||||
const existingLookup = createExistingMessageLookup(db)
|
||||
const incomingState = createMessageDedupState()
|
||||
const incomingState = createMessageDedupState([], [], [], { preserveFallbackMultiplicity })
|
||||
|
||||
const memberIdMap = new Map<string, number>()
|
||||
const existingMembers = db.prepare('SELECT id, platform_id FROM member').all() as Array<{
|
||||
@@ -526,7 +551,8 @@ export async function incrementalImport(
|
||||
incomingState,
|
||||
{ ...msg, timestamp },
|
||||
existingBatchPlatformIds,
|
||||
candidateCache
|
||||
candidateCache,
|
||||
preserveFallbackMultiplicity
|
||||
)
|
||||
) {
|
||||
duplicateCount++
|
||||
|
||||
@@ -33,3 +33,26 @@ test('bridges one fallback-only copy without collapsing later distinct platform
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-1' }, state), true)
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-2' }, state), false)
|
||||
})
|
||||
|
||||
test('preserves repeated fallback occurrences when multiplicity is enabled', () => {
|
||||
const state = createMessageDedupState([], [], [], { preserveFallbackMultiplicity: true })
|
||||
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, state), false)
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, state), false)
|
||||
})
|
||||
|
||||
test('bridges fallback-only and stable-ID occurrences one for one', () => {
|
||||
const fallbackFirst = createMessageDedupState([], [], [], { preserveFallbackMultiplicity: true })
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, fallbackFirst), false)
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, fallbackFirst), false)
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-1' }, fallbackFirst), true)
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-2' }, fallbackFirst), true)
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-3' }, fallbackFirst), false)
|
||||
|
||||
const stableFirst = createMessageDedupState([], [], [], { preserveFallbackMultiplicity: true })
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-1' }, stableFirst), false)
|
||||
assert.equal(registerMessageAndCheckDuplicate({ ...baseMessage, platformMessageId: 'msg-2' }, stableFirst), false)
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, stableFirst), true)
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, stableFirst), true)
|
||||
assert.equal(registerMessageAndCheckDuplicate(baseMessage, stableFirst), false)
|
||||
})
|
||||
@@ -8,6 +8,14 @@ export interface MessageDedupState {
|
||||
fallbackKeys: Set<string>
|
||||
/** Unmatched fallback keys whose accepted message did not have a stable platform ID. */
|
||||
fallbackOnlyKeys: Set<string>
|
||||
preserveFallbackMultiplicity: boolean
|
||||
/** Positive = unmatched ID-less occurrences; negative = unmatched stable-ID occurrences. */
|
||||
fallbackMultiplicityBalances: Map<string, number>
|
||||
}
|
||||
|
||||
export interface MessageDedupOptions {
|
||||
/** Preserve repeated ID-less messages that share a fallback fingerprint. */
|
||||
preserveFallbackMultiplicity?: boolean
|
||||
}
|
||||
|
||||
export interface DedupMessage {
|
||||
@@ -48,15 +56,36 @@ export function generateFallbackMessageKey(message: Omit<DedupMessage, 'platform
|
||||
export function createMessageDedupState(
|
||||
platformMessageIds: Iterable<string> = [],
|
||||
fallbackKeys: Iterable<string> = [],
|
||||
fallbackOnlyKeys: Iterable<string> = []
|
||||
fallbackOnlyKeys: Iterable<string> = [],
|
||||
options: MessageDedupOptions = {}
|
||||
): MessageDedupState {
|
||||
const fallbackKeySet = new Set(fallbackKeys)
|
||||
const fallbackOnlyKeySet = new Set(fallbackOnlyKeys)
|
||||
const fallbackMultiplicityBalances = new Map<string, number>()
|
||||
if (options.preserveFallbackMultiplicity) {
|
||||
for (const key of fallbackKeySet) fallbackMultiplicityBalances.set(key, -1)
|
||||
for (const key of fallbackOnlyKeySet) fallbackMultiplicityBalances.set(key, 1)
|
||||
}
|
||||
return {
|
||||
platformMessageIds: new Set(platformMessageIds),
|
||||
fallbackKeys: new Set(fallbackKeys),
|
||||
fallbackOnlyKeys: new Set(fallbackOnlyKeys),
|
||||
fallbackKeys: fallbackKeySet,
|
||||
fallbackOnlyKeys: fallbackOnlyKeySet,
|
||||
preserveFallbackMultiplicity: options.preserveFallbackMultiplicity ?? false,
|
||||
fallbackMultiplicityBalances,
|
||||
}
|
||||
}
|
||||
|
||||
function setFallbackMultiplicityBalance(state: MessageDedupState, key: string, balance: number): void {
|
||||
if (balance === 0) state.fallbackMultiplicityBalances.delete(key)
|
||||
else state.fallbackMultiplicityBalances.set(key, balance)
|
||||
}
|
||||
|
||||
export function hasUnmatchedFallbackOnlyMessage(state: MessageDedupState, fallbackKey: string): boolean {
|
||||
return state.preserveFallbackMultiplicity
|
||||
? (state.fallbackMultiplicityBalances.get(fallbackKey) ?? 0) > 0
|
||||
: state.fallbackOnlyKeys.has(fallbackKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical import dedup rule shared by file imports and JSON Push: prefer stable platform
|
||||
* message IDs and fall back to timestamp + sender platform ID + type + normalized content + reply target.
|
||||
@@ -68,6 +97,16 @@ export function registerMessageAndCheckDuplicate(message: DedupMessage, state: M
|
||||
if (state.platformMessageIds.has(message.platformMessageId)) return true
|
||||
state.platformMessageIds.add(message.platformMessageId)
|
||||
|
||||
if (state.preserveFallbackMultiplicity) {
|
||||
const balance = state.fallbackMultiplicityBalances.get(key) ?? 0
|
||||
if (balance > 0) {
|
||||
setFallbackMultiplicityBalance(state, key, balance - 1)
|
||||
return true
|
||||
}
|
||||
setFallbackMultiplicityBalance(state, key, balance - 1)
|
||||
return false
|
||||
}
|
||||
|
||||
// Only bridge ID-bearing messages to fallback-only copies. Comparing this
|
||||
// key against other ID-bearing messages would incorrectly merge distinct
|
||||
// platform messages that happen to have identical content and timestamps.
|
||||
@@ -76,6 +115,16 @@ export function registerMessageAndCheckDuplicate(message: DedupMessage, state: M
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.preserveFallbackMultiplicity) {
|
||||
const balance = state.fallbackMultiplicityBalances.get(key) ?? 0
|
||||
if (balance < 0) {
|
||||
setFallbackMultiplicityBalance(state, key, balance + 1)
|
||||
return true
|
||||
}
|
||||
setFallbackMultiplicityBalance(state, key, balance + 1)
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.fallbackKeys.has(key)) return true
|
||||
state.fallbackKeys.add(key)
|
||||
state.fallbackOnlyKeys.add(key)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getPreprocessor,
|
||||
needsPreprocess,
|
||||
isNativeFormatAvailable,
|
||||
PARSER_FORMAT_IDS,
|
||||
type ParsedMeta,
|
||||
type FormatFeature,
|
||||
type ParseProgress,
|
||||
@@ -33,6 +34,10 @@ import { performance } from 'node:perf_hooks'
|
||||
import { MessageBatchInserter, type MessageInsertRow } from './message-batch-inserter'
|
||||
import { createMessageDedupState, registerMessageAndCheckDuplicate, type DedupMessage } from './message-deduplicator'
|
||||
|
||||
export function shouldPreserveFallbackMultiplicity(formatId: string): boolean {
|
||||
return formatId === PARSER_FORMAT_IDS.WEFLOW || formatId === PARSER_FORMAT_IDS.ECHOTRACE
|
||||
}
|
||||
|
||||
// ==================== Public interfaces ====================
|
||||
|
||||
export interface SkipReasons {
|
||||
@@ -524,7 +529,9 @@ async function streamImportSingle(
|
||||
skippedInvalidTimestamp: 0,
|
||||
skippedNoType: 0,
|
||||
}
|
||||
const dedupState = createMessageDedupState()
|
||||
const dedupState = createMessageDedupState([], [], [], {
|
||||
preserveFallbackMultiplicity: shouldPreserveFallbackMultiplicity(formatFeature.id),
|
||||
})
|
||||
|
||||
logger?.info('Starting streamParseFile...')
|
||||
const parsePipelineStartedAt = now()
|
||||
@@ -940,7 +947,9 @@ export async function analyzeNewImport(
|
||||
|
||||
let meta: { name: string; platform: string; type: string } | null = null
|
||||
const memberSet = new Set<string>()
|
||||
const dedupState = createMessageDedupState()
|
||||
const dedupState = createMessageDedupState([], [], [], {
|
||||
preserveFallbackMultiplicity: shouldPreserveFallbackMultiplicity(formatFeature.id),
|
||||
})
|
||||
let totalMessages = 0
|
||||
let newMessageCount = 0
|
||||
let duplicateCount = 0
|
||||
|
||||
@@ -111,6 +111,38 @@ describe('buildMergedOutput', () => {
|
||||
assert.equal(result.chatLabData.meta.sources.length, 2)
|
||||
})
|
||||
|
||||
it('preserves the maximum fallback occurrence count across overlapping sources', () => {
|
||||
const meta: MergerSourceMeta = { name: 'chat', platform: 'wechat', type: 'group', groupId: 'room' }
|
||||
const repeatedImage: MergerInputMessage = {
|
||||
senderPlatformId: 'u1',
|
||||
timestamp: 100,
|
||||
type: 1,
|
||||
content: '[图片]',
|
||||
}
|
||||
const source1 = createMockSource(
|
||||
meta,
|
||||
[{ platformId: 'u1', accountName: 'Alice' }],
|
||||
[{ ...repeatedImage }, { ...repeatedImage }]
|
||||
)
|
||||
const source2 = createMockSource(
|
||||
meta,
|
||||
[{ platformId: 'u1', accountName: 'Alice' }],
|
||||
[{ ...repeatedImage }, { ...repeatedImage }, { ...repeatedImage }]
|
||||
)
|
||||
|
||||
const sources = [
|
||||
{ source: source1, filename: 'first.json' },
|
||||
{ source: source2, filename: 'overlap.json' },
|
||||
]
|
||||
const result = buildMergedOutput(sources, 'Merged')
|
||||
const reversed = buildMergedOutput([...sources].reverse(), 'Merged')
|
||||
const conflicts = checkConflictsFromSources(sources)
|
||||
|
||||
assert.equal(result.chatLabData.messages.length, 3)
|
||||
assert.equal(reversed.chatLabData.messages.length, 3)
|
||||
assert.equal(conflicts.totalMessages, 3)
|
||||
})
|
||||
|
||||
it('detects mixed platform when sources differ', () => {
|
||||
const s1 = createMockSource(
|
||||
{ name: 'a', platform: 'qq', type: 'group' },
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CHATLAB_FORMAT_VERSION } from '@openchatlab/shared-types'
|
||||
import {
|
||||
createMessageDedupState,
|
||||
generateFallbackMessageKey,
|
||||
hasUnmatchedFallbackOnlyMessage,
|
||||
registerMessageAndCheckDuplicate,
|
||||
} from '../import/message-deduplicator'
|
||||
|
||||
@@ -95,6 +96,37 @@ interface MessageScopeContext {
|
||||
messageBridgeFamily?: string
|
||||
}
|
||||
|
||||
function createFallbackOccurrenceTracker(): (message: MergerInputMessage, sourceIndex: number) => boolean {
|
||||
// Overlapping exports contribute the maximum occurrence count for each
|
||||
// fallback key, rather than collapsing to one message or summing all copies.
|
||||
const maximumOccurrences = new Map<string, number>()
|
||||
const sourceOccurrences = new Map<number, Map<string, number>>()
|
||||
|
||||
return (message, sourceIndex) => {
|
||||
if (message.platformMessageId) return false
|
||||
|
||||
const fallbackKey = generateFallbackMessageKey({
|
||||
timestamp: message.timestamp,
|
||||
senderPlatformId: message.senderPlatformId,
|
||||
type: message.type,
|
||||
content: message.content ?? null,
|
||||
replyToMessageId: message.replyToMessageId,
|
||||
})
|
||||
let occurrences = sourceOccurrences.get(sourceIndex)
|
||||
if (!occurrences) {
|
||||
occurrences = new Map<string, number>()
|
||||
sourceOccurrences.set(sourceIndex, occurrences)
|
||||
}
|
||||
const occurrence = (occurrences.get(fallbackKey) ?? 0) + 1
|
||||
occurrences.set(fallbackKey, occurrence)
|
||||
|
||||
const previousMaximum = maximumOccurrences.get(fallbackKey) ?? 0
|
||||
if (occurrence <= previousMaximum) return true
|
||||
maximumOccurrences.set(fallbackKey, occurrence)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageScopeResolver(contexts: Iterable<MessageScopeContext>): {
|
||||
addMessage: (message: MergerInputMessage, context: MessageScopeContext) => void
|
||||
complete: () => void
|
||||
@@ -241,23 +273,24 @@ export function checkConflictsFromSources(
|
||||
const normalizeMessageId = createMessageIdNormalizer(
|
||||
preparedSources.map(({ sourceIndex }) => scopeResolver.resolve(sourceIndex))
|
||||
)
|
||||
const dedupState = createMessageDedupState()
|
||||
const dedupState = createMessageDedupState([], [], [], { preserveFallbackMultiplicity: true })
|
||||
const isCoveredFallbackOccurrence = createFallbackOccurrenceTracker()
|
||||
let totalMessages = 0
|
||||
|
||||
for (const { msg, sourceIndex, platform } of allMessages) {
|
||||
const messageScope = scopeResolver.resolve(sourceIndex)
|
||||
const senderPlatformId = normalizePlatformId(msg.senderPlatformId, platform, collidingIds)
|
||||
const duplicate = registerMessageAndCheckDuplicate(
|
||||
{
|
||||
const dedupMessage = {
|
||||
platformMessageId: normalizeMessageId(msg.platformMessageId, messageScope),
|
||||
timestamp: msg.timestamp,
|
||||
senderPlatformId,
|
||||
type: msg.type,
|
||||
content: msg.content ?? null,
|
||||
replyToMessageId: normalizeMessageId(msg.replyToMessageId, messageScope),
|
||||
},
|
||||
dedupState
|
||||
)
|
||||
}
|
||||
const duplicate =
|
||||
isCoveredFallbackOccurrence(dedupMessage, sourceIndex) ||
|
||||
registerMessageAndCheckDuplicate(dedupMessage, dedupState)
|
||||
if (!duplicate) totalMessages++
|
||||
}
|
||||
|
||||
@@ -324,8 +357,9 @@ export function buildMergedOutput(
|
||||
|
||||
// Streaming dedup: prefer stable platform message IDs and use the content
|
||||
// fingerprint only for messages that do not have one.
|
||||
const dedupState = createMessageDedupState()
|
||||
const fallbackOnlyMessageIndexes = new Map<string, number>()
|
||||
const dedupState = createMessageDedupState([], [], [], { preserveFallbackMultiplicity: true })
|
||||
const isCoveredFallbackOccurrence = createFallbackOccurrenceTracker()
|
||||
const fallbackOnlyMessageIndexes = new Map<string, number[]>()
|
||||
const mergedMessages: ChatLabMergedMessage[] = []
|
||||
|
||||
for (const { source, meta, sourceIndex } of metas) {
|
||||
@@ -345,18 +379,20 @@ export function buildMergedOutput(
|
||||
replyToMessageId,
|
||||
}
|
||||
const fallbackKey = generateFallbackMessageKey(dedupMessage)
|
||||
if (isCoveredFallbackOccurrence(dedupMessage, sourceIndex)) continue
|
||||
const shouldBackfillId = Boolean(
|
||||
platformMessageId &&
|
||||
!dedupState.platformMessageIds.has(platformMessageId) &&
|
||||
dedupState.fallbackOnlyKeys.has(fallbackKey)
|
||||
hasUnmatchedFallbackOnlyMessage(dedupState, fallbackKey)
|
||||
)
|
||||
|
||||
if (registerMessageAndCheckDuplicate(dedupMessage, dedupState)) {
|
||||
if (shouldBackfillId) {
|
||||
const retainedIndex = fallbackOnlyMessageIndexes.get(fallbackKey)
|
||||
const retainedIndexes = fallbackOnlyMessageIndexes.get(fallbackKey)
|
||||
const retainedIndex = retainedIndexes?.shift()
|
||||
if (retainedIndex !== undefined) {
|
||||
mergedMessages[retainedIndex].platformMessageId = platformMessageId
|
||||
fallbackOnlyMessageIndexes.delete(fallbackKey)
|
||||
if (retainedIndexes?.length === 0) fallbackOnlyMessageIndexes.delete(fallbackKey)
|
||||
}
|
||||
}
|
||||
continue
|
||||
@@ -373,7 +409,11 @@ export function buildMergedOutput(
|
||||
content: msg.content,
|
||||
replyToMessageId,
|
||||
})
|
||||
if (!platformMessageId) fallbackOnlyMessageIndexes.set(fallbackKey, retainedIndex)
|
||||
if (!platformMessageId) {
|
||||
const retainedIndexes = fallbackOnlyMessageIndexes.get(fallbackKey) ?? []
|
||||
retainedIndexes.push(retainedIndex)
|
||||
fallbackOnlyMessageIndexes.set(fallbackKey, retainedIndexes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user