diff --git a/packages/node-runtime/src/services/topics/ledger.test.ts b/packages/node-runtime/src/services/topics/ledger.test.ts index a0651821..796b3498 100644 --- a/packages/node-runtime/src/services/topics/ledger.test.ts +++ b/packages/node-runtime/src/services/topics/ledger.test.ts @@ -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]] ) }) diff --git a/packages/node-runtime/src/services/topics/ledger.ts b/packages/node-runtime/src/services/topics/ledger.ts index 5811e948..0897bf04 100644 --- a/packages/node-runtime/src/services/topics/ledger.ts +++ b/packages/node-runtime/src/services/topics/ledger.ts @@ -92,6 +92,7 @@ export function applyTopicOperations( const localTopicIds = new Map() const mergedTopicIds = new Map() const assignedTopicsByMessageId = new Map() + 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) } }