fix(topics): tolerate conflicting message assignments

This commit is contained in:
digua committed 2026-08-11 11:44:20 +08:00
1 parent c170b6f480
commit 35492a0ec6
2 files changed
+90 -40

No files matched your search

@@ -196,43 +196,88 @@ test('exact message assignments preserve interleaved topic membership beyond rep
)
})
test('one message cannot be assigned to multiple topics in the same block', () => {
assert.throws(
() =>
applyTopicOperations(
createEmptyTopicLedger(),
test('the first primary assignment wins when the model repeats a message across topics', () => {
const ledger = applyTopicOperations(
createEmptyTopicLedger(),
{
operations: [
{
operations: [
{
operation: 'create',
localId: 'first',
title: '第一个话题',
summary: '第一个话题。',
state: 'active',
evidence: [{ messageId: 1, timestamp: 100, role: 'primary' }],
},
{
operation: 'create',
localId: 'second',
title: '第二个话题',
summary: '第二个话题。',
state: 'active',
evidence: [{ messageId: 2, timestamp: 200, role: 'primary' }],
},
],
assignments: [
{ topicRef: 'first', messageIds: [1, 2] },
{ topicRef: 'second', messageIds: [2] },
],
operation: 'create',
localId: 'first',
title: '第一个话题',
summary: '第一个话题。',
state: 'active',
evidence: [{ messageId: 1, timestamp: 100, role: 'primary' }],
},
{
sessionId: 'session',
dayKey: '2026-08-09',
localIdNamespace: 'block:0',
currentMessages: messages,
}
),
/multiple topics/
operation: 'create',
localId: 'second',
title: '第二个话题',
summary: '第二个话题。',
state: 'active',
evidence: [{ messageId: 3, timestamp: 8_000, role: 'primary' }],
},
],
assignments: [
{ topicRef: 'first', messageIds: [1, 2] },
{ topicRef: 'second', messageIds: [2, 3] },
],
},
{
sessionId: 'session',
dayKey: '2026-08-09',
localIdNamespace: 'block:0',
currentMessages: messages,
}
)
assert.deepEqual(
materializeChatTopics(ledger, messages).map((topic) => topic.messageIds),
[[1, 2], [3]]
)
})
test('primary assignments override conflicting representative evidence without failing the day', () => {
const ledger = applyTopicOperations(
createEmptyTopicLedger(),
{
operations: [
{
operation: 'create',
localId: 'travel',
title: '出行安排',
summary: '讨论假期出行安排。',
state: 'active',
evidence: [{ messageId: 1, timestamp: 100, role: 'primary' }],
},
{
operation: 'create',
localId: 'hiking',
title: '徒步备选',
summary: '讨论徒步备选方案。',
state: 'active',
evidence: [
{ messageId: 2, timestamp: 200, role: 'primary' },
{ messageId: 3, timestamp: 8_000, role: 'supporting' },
],
},
],
assignments: [
{ topicRef: 'travel', messageIds: [1, 2] },
{ topicRef: 'hiking', messageIds: [3] },
],
},
{
sessionId: 'session',
dayKey: '2026-08-09',
localIdNamespace: 'block:0',
currentMessages: messages,
}
)
assert.deepEqual(
materializeChatTopics(ledger, messages).map((topic) => topic.messageIds),
[[1, 2], [3]]
)
})
@@ -92,6 +92,7 @@ export function applyTopicOperations(
const localTopicIds = new Map<string, string>()
const mergedTopicIds = new Map<string, string>()
const assignedTopicsByMessageId = new Map<number, string>()
const evidenceMembershipFallbacks: Array<{ evidence: ChatTopicEvidence[]; topicId: string }> = []
for (const operation of response.operations) {
validateNewEvidence(operation.evidence, currentMessageIds)
@@ -151,6 +152,12 @@ export function applyTopicOperations(
for (const messageId of assignment.messageIds) assignMessage(messageId, topicId)
}
// assignments define primary membership. Evidence only fills omissions and never overrides
// the model's explicit assignment, because one message may support a secondary topic.
for (const fallback of evidenceMembershipFallbacks) {
for (const item of fallback.evidence) assignMessage(item.messageId, fallback.topicId)
}
for (const [messageId, topicId] of assignedTopicsByMessageId) {
const topic = requireLedgerTopic(next, followMergedTopicId(topicId))
topic.messageIds = deduplicateMessageIds([...topic.messageIds, messageId])
@@ -177,7 +184,7 @@ export function applyTopicOperations(
}
function assignEvidence(evidence: ChatTopicEvidence[], topicId: string): void {
for (const item of evidence) assignMessage(item.messageId, topicId)
evidenceMembershipFallbacks.push({ evidence, topicId })
}
function assignMessage(messageId: number, topicId: string): void {
@@ -185,11 +192,9 @@ export function applyTopicOperations(
throw new Error(`Assigned message is not in the current block: ${messageId}`)
}
const resolvedTopicId = followMergedTopicId(topicId)
const existingTopicId = assignedTopicsByMessageId.get(messageId)
if (existingTopicId && followMergedTopicId(existingTopicId) !== resolvedTopicId) {
throw new Error(`Message ${messageId} is assigned to multiple topics`)
}
assignedTopicsByMessageId.set(messageId, resolvedTopicId)
// Keep the first explicit assignment (or evidence fallback) deterministically when the
// model repeats an ID. A recoverable classification conflict must not fail the whole day.
if (!assignedTopicsByMessageId.has(messageId)) assignedTopicsByMessageId.set(messageId, resolvedTopicId)
}
}