fix(ai): harden cross-chat analysis results

Resolve private ranking display names, apply privacy preprocessing consistently across cached recaps and structural labels, and bound model-visible overview payloads to the configured tool budget.
This commit is contained in:
digua committed 2026-08-24 00:25:35 +08:00
1 parent 05d9b34f30
commit f9ba12e535
12 files changed
+952 -34

No files matched your search

+15 -1
View File
@@ -4,7 +4,12 @@ import type {
PreprocessConfig,
SessionRuntimeAdapter,
} from '@openchatlab/node-runtime'
import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime'
import {
countTokens,
preprocessCrossChatLabel,
preprocessCrossChatMessages,
preprocessCrossChatSummaries,
} from '@openchatlab/node-runtime'
import { createCrossChatAgentToolAdapters, type CrossChatToolExecutionContext } from '@openchatlab/tools'
export function createCliCrossChatTools(options: {
@@ -26,6 +31,15 @@ export function createCliCrossChatTools(options: {
messages,
options.preprocessConfig as PreprocessConfig | undefined
),
preprocessSummariesBySession: (sessionId, summaries) =>
preprocessCrossChatSummaries(
options.sessionAdapter,
sessionId,
summaries,
options.preprocessConfig as PreprocessConfig | undefined
),
preprocessModelLabel: (value, pseudonym) =>
preprocessCrossChatLabel(value, pseudonym, options.preprocessConfig as PreprocessConfig | undefined),
}
return createCrossChatAgentToolAdapters(context) as AgentTool<any, any>[]
@@ -4,7 +4,12 @@ import type {
PreprocessConfig,
SessionRuntimeAdapter,
} from '@openchatlab/node-runtime'
import { countTokens, preprocessCrossChatMessages } from '@openchatlab/node-runtime'
import {
countTokens,
preprocessCrossChatLabel,
preprocessCrossChatMessages,
preprocessCrossChatSummaries,
} from '@openchatlab/node-runtime'
import { createCrossChatAgentToolAdapters, type CrossChatToolExecutionContext } from '@openchatlab/tools'
export function createElectronCrossChatTools(options: {
@@ -26,6 +31,15 @@ export function createElectronCrossChatTools(options: {
messages,
options.preprocessConfig as PreprocessConfig | undefined
),
preprocessSummariesBySession: (sessionId, summaries) =>
preprocessCrossChatSummaries(
options.sessionAdapter,
sessionId,
summaries,
options.preprocessConfig as PreprocessConfig | undefined
),
preprocessModelLabel: (value, pseudonym) =>
preprocessCrossChatLabel(value, pseudonym, options.preprocessConfig as PreprocessConfig | undefined),
}
return createCrossChatAgentToolAdapters(context) as AgentTool<any, any>[]
+2
View File
@@ -502,7 +502,9 @@ export {
MergeSessionCache,
createContactsService,
createCrossChatAnalysisService,
preprocessCrossChatLabel,
preprocessCrossChatMessages,
preprocessCrossChatSummaries,
createPeopleRelationshipsService,
createGlobalInsightService,
assertValidTopicDayKey,
@@ -1,5 +1,5 @@
export { createCrossChatAnalysisService } from './service'
export { preprocessCrossChatMessages } from './preprocess'
export { preprocessCrossChatLabel, preprocessCrossChatMessages, preprocessCrossChatSummaries } from './preprocess'
export type { CrossChatAnalysisService, CrossChatAnalysisServiceDeps } from './service'
export type {
CrossChatEntityResolution,
@@ -1,7 +1,7 @@
import { getSessionMeta } from '@openchatlab/core'
import type { CrossChatMessageSource } from '@openchatlab/shared-types'
import { getNonSystemMembersForContacts, getSessionMeta } from '@openchatlab/core'
import type { CrossChatMessageSource, CrossChatRecentSessionSummary } from '@openchatlab/shared-types'
import type { PreprocessConfig } from '../../ai/preprocessor'
import { preprocessMessages } from '../../ai/preprocessor'
import { desensitizeText, matchesBlacklist, preprocessMessages } from '../../ai/preprocessor'
import type { SessionRuntimeAdapter } from '../adapters'
/**
@@ -35,3 +35,136 @@ export function preprocessCrossChatMessages(
ownerPlatformId && message.senderPlatformId === ownerPlatformId ? 'Owner' : `U${message.senderId}@${sessionId}`,
}))
}
/** Apply the current privacy policy to persisted summaries before they reach an LLM. */
export function preprocessCrossChatSummaries(
adapter: SessionRuntimeAdapter,
sessionId: string,
summaries: CrossChatRecentSessionSummary[],
config?: PreprocessConfig
): CrossChatRecentSessionSummary[] {
if (!config) return summaries.map(cloneSummary)
const participantPseudonyms = config.anonymizeNames
? buildParticipantPseudonyms(
adapter,
sessionId,
summaries.flatMap((summary) => summary.participants)
)
: null
return summaries.flatMap((summary) => {
if (matchesBlacklist(summary.summary, config.blacklistKeywords)) return []
let safeSummary = config.desensitize ? desensitizeText(summary.summary, config.desensitizeRules) : summary.summary
if (participantPseudonyms) {
const anonymizedSummary = replaceKnownParticipantNames(safeSummary, participantPseudonyms)
if (anonymizedSummary === null) return []
safeSummary = anonymizedSummary
}
const participants = summary.participants
.filter((participant) => !matchesBlacklist(participant, config.blacklistKeywords))
.map((participant) => {
if (participantPseudonyms) return participantPseudonyms.get(participant) ?? `Participant@${sessionId}`
return config.desensitize ? desensitizeText(participant, config.desensitizeRules) : participant
})
.filter((participant, index, values) => participant.length > 0 && values.indexOf(participant) === index)
return [{ ...summary, participants, summary: safeSummary }]
})
}
/** Apply privacy settings to a model-visible structural label without changing its local source data. */
export function preprocessCrossChatLabel(value: string, pseudonym: string, config?: PreprocessConfig): string {
if (!config) return value
if (config.anonymizeNames || matchesBlacklist(value, config.blacklistKeywords)) return pseudonym
return config.desensitize ? desensitizeText(value, config.desensitizeRules) : value
}
function cloneSummary(summary: CrossChatRecentSessionSummary): CrossChatRecentSessionSummary {
return { ...summary, participants: [...summary.participants] }
}
function replaceKnownParticipantNames(summary: string, pseudonyms: Map<string, string>): string | null {
const replacements = new Map<string, string>()
for (const [participantName, pseudonym] of pseudonyms) {
const trimmedName = participantName.trim()
if (participantName) replacements.set(participantName, pseudonym)
if (trimmedName) replacements.set(trimmedName, pseudonym)
}
if (replacements.size === 0) return summary
const ambiguousLabels = new Set<string>()
const alternatives = [...replacements.keys()]
.sort((left, right) => Array.from(right).length - Array.from(left).length)
.map((participantName) => {
const escapedName = escapeRegExp(participantName)
if (/^[\p{Script=Latin}\p{N}_]+$/u.test(participantName)) {
return `(?<![\\p{Script=Latin}\\p{N}_])${escapedName}(?![\\p{Script=Latin}\\p{N}_])`
}
if (Array.from(participantName).length === 1 && /[\p{L}\p{N}]/u.test(participantName)) {
ambiguousLabels.add(participantName)
return `(?<![\\p{L}\\p{N}_])${escapedName}(?![\\p{L}\\p{N}_])`
}
return escapedName
})
// One alternation prevents overlapping nicknames from rewriting pseudonyms inserted earlier in the same pass.
const pattern = new RegExp(alternatives.join('|'), 'gu')
const anonymized = summary.replace(pattern, (participantName) => replacements.get(participantName) ?? participantName)
// A one-character non-Latin name cannot be distinguished safely inside an unsegmented word. Dropping the cached
// summary preserves both privacy and meaning; the recent-session tool can still answer from preprocessed messages.
return [...ambiguousLabels].some((participantName) => anonymized.includes(participantName)) ? null : anonymized
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildParticipantPseudonyms(
adapter: SessionRuntimeAdapter,
sessionId: string,
participantNames: string[]
): Map<string, string> {
const pseudonyms = new Map<string, string>()
const fallbackPseudonyms = new Map<string, string>()
let ownerPlatformId: string | null = null
let members: ReturnType<typeof getNonSystemMembersForContacts> = []
try {
const db = adapter.openReadonly(sessionId)
if (db) {
ownerPlatformId = getSessionMeta(db)?.ownerId?.trim() || null
members = getNonSystemMembersForContacts(db)
}
} catch {
// Unknown names still receive deterministic session-local pseudonyms below.
}
for (const participantName of participantNames) {
if (pseudonyms.has(participantName)) continue
const normalizedName = participantName.trim().toLocaleLowerCase()
const matches = members.filter((member) =>
[member.name, member.platformId, ...member.aliases].some(
(candidate) => candidate.trim().toLocaleLowerCase() === normalizedName
)
)
const uniqueMatches = [...new Map(matches.map((member) => [member.id, member])).values()]
if (uniqueMatches.length === 1) {
const member = uniqueMatches[0]
pseudonyms.set(
participantName,
ownerPlatformId && member.platformId === ownerPlatformId ? 'Owner' : `U${member.id}@${sessionId}`
)
continue
}
let fallback = fallbackPseudonyms.get(normalizedName)
if (!fallback) {
fallback = `Participant${fallbackPseudonyms.size + 1}@${sessionId}`
fallbackPseudonyms.set(normalizedName, fallback)
}
pseudonyms.set(participantName, fallback)
}
return pseudonyms
}
@@ -16,7 +16,7 @@ import { openBetterSqliteDatabase } from '../../better-sqlite3-adapter'
import type { ContactsService } from '../contacts'
import type { SessionRuntimeAdapter } from '../adapters'
import { createCrossChatAnalysisService } from './service'
import { preprocessCrossChatMessages } from './preprocess'
import { preprocessCrossChatLabel, preprocessCrossChatMessages, preprocessCrossChatSummaries } from './preprocess'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
@@ -26,7 +26,7 @@ interface SeedSession {
type: 'private' | 'group'
platform?: string
ownerPlatformId?: string
members: Array<{ id: number; platformId: string; name: string }>
members: Array<{ id: number; platformId: string; name: string | null }>
messages: Array<{ id: number; senderId: number; ts: number; content: string; replyToMessageId?: string }>
}
@@ -585,6 +585,33 @@ test('maps global insight cache states without waiting for background computatio
}
})
test('uses the private session name when the contact member only has a platform id', async () => {
const { env, contactsService } = createFixture()
const day = 1_780_000_000
env.seed({
id: 'rank-private-display-name',
name: '小红',
type: 'private',
platform: 'wechat',
ownerPlatformId: 'owner',
members: [
{ id: 1, platformId: 'owner', name: '我' },
{ id: 2, platformId: 'wxid_adczii0mtmoq22', name: null },
],
messages: [{ id: 1, senderId: 2, ts: day, content: 'hello' }],
})
try {
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
const result = await service.rankPrivateContacts({ startTs: day - 1, endTs: day + 1, limit: 10 })
const item = result.items.find((candidate) => candidate.contactKey === 'wechat:wxid_adczii0mtmoq22')
assert.equal(item?.displayName, '小红')
} finally {
env.cleanup()
}
})
test('contact lookup uses the all-history snapshot for typed names and candidate details', () => {
const { env } = createFixture()
const pagePresets: Array<string | undefined> = []
@@ -2014,3 +2041,112 @@ test('cross-chat anonymization namespaces local member ids by source session', (
env.cleanup()
}
})
test('cross-chat summary preprocessing applies current blacklist, desensitization, and anonymization', () => {
const { env } = createFixture()
try {
const summaries = [
{
segmentId: 1,
startTs: 1,
endTs: 2,
messageCount: 2,
participants: ['Alice'],
summary: '秘密项目的联系电话是 13812345678',
},
{
segmentId: 2,
startTs: 3,
endTs: 4,
messageCount: 2,
participants: ['Alice'],
summary: 'Alice 的联系电话是 13812345678',
},
]
const processed = preprocessCrossChatSummaries(env.adapter, 'private-alice', summaries, {
dataCleaning: false,
mergeConsecutive: false,
blacklistKeywords: ['秘密项目'],
denoise: false,
desensitize: true,
desensitizeRules: [
{
id: 'phone',
label: 'Phone',
pattern: '1\\d{10}',
replacement: '[PHONE]',
enabled: true,
builtin: false,
locales: [],
},
],
anonymizeNames: true,
})
assert.equal(processed.length, 1)
assert.equal(processed[0].summary, 'U10@private-alice 的联系电话是 [PHONE]')
assert.deepEqual(processed[0].participants, ['U10@private-alice'])
assert.equal(JSON.stringify(processed).includes('秘密项目'), false)
assert.equal(JSON.stringify(processed).includes('13812345678'), false)
assert.equal(JSON.stringify(processed).includes('Alice'), false)
} finally {
env.cleanup()
}
})
test('cross-chat summary anonymization respects word boundaries and drops ambiguous short-name text', () => {
const { env } = createFixture()
try {
const processed = preprocessCrossChatSummaries(
env.adapter,
'private-alice',
[
{
segmentId: 1,
startTs: 1,
endTs: 2,
messageCount: 2,
participants: ['Me'],
summary: 'Meeting notes say Me approved it.',
},
{
segmentId: 2,
startTs: 3,
endTs: 4,
messageCount: 2,
participants: ['我'],
summary: '我们明天继续讨论。',
},
],
{
dataCleaning: false,
mergeConsecutive: false,
blacklistKeywords: [],
denoise: false,
desensitize: false,
desensitizeRules: [],
anonymizeNames: true,
}
)
assert.equal(processed.length, 1)
assert.equal(processed[0].summary, 'Meeting notes say U1@private-alice approved it.')
assert.deepEqual(processed[0].participants, ['U1@private-alice'])
} finally {
env.cleanup()
}
})
test('cross-chat structural labels use safe pseudonyms when name anonymization is enabled', () => {
const processed = preprocessCrossChatLabel('Alice', 'Contact1', {
dataCleaning: false,
mergeConsecutive: false,
blacklistKeywords: [],
denoise: false,
desensitize: false,
desensitizeRules: [],
anonymizeNames: true,
})
assert.equal(processed, 'Contact1')
})
@@ -925,7 +925,8 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
const contactActivity = facts.members.find((member) => member.memberId === contact.id)
const accumulator = accumulators.get(contactKey) ?? {
contactKey,
displayName: contact.name,
platformId: contact.platformId,
displayName: pickPreferredContactDisplayName(contact.platformId, contact.name, descriptor.sessionName),
platform: descriptor.platform,
totalMessages: 0,
ownerMessages: 0,
@@ -935,6 +936,12 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
lastMessageTs: null,
sessionIds: [],
}
accumulator.displayName = pickPreferredContactDisplayName(
contact.platformId,
accumulator.displayName,
contact.name,
descriptor.sessionName
)
accumulator.totalMessages += facts.totalMessages
accumulator.ownerMessages += ownerActivity?.messageCount ?? 0
accumulator.contactMessages += contactActivity?.messageCount ?? 0
@@ -964,19 +971,29 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
const ranked = [...accumulators.values()].sort((left, right) =>
comparePrivateContactRankAccumulators(left, right, rankBy)
)
const items: CrossChatPrivateContactRankItem[] = ranked.slice(0, limit).map((item, index) => ({
rank: index + 1,
contactKey: item.contactKey,
displayName: item.displayName,
platform: item.platform,
totalMessages: item.totalMessages,
ownerMessages: item.ownerMessages,
contactMessages: item.contactMessages,
activeDays: item.activeDayKeys.size,
firstMessageTs: item.firstMessageTs,
lastMessageTs: item.lastMessageTs,
sessionIds: [...item.sessionIds].sort(),
}))
const items: CrossChatPrivateContactRankItem[] = ranked.slice(0, limit).map((item, index) => {
const contactDetail = this.deps.contactsService.getContactDetail(item.contactKey, {
acceptStale: true,
timeRangePreset: 'all',
})
return {
rank: index + 1,
contactKey: item.contactKey,
displayName: pickPreferredContactDisplayName(
item.platformId,
contactDetail.contact?.displayName,
item.displayName
),
platform: item.platform,
totalMessages: item.totalMessages,
ownerMessages: item.ownerMessages,
contactMessages: item.contactMessages,
activeDays: item.activeDayKeys.size,
firstMessageTs: item.firstMessageTs,
lastMessageTs: item.lastMessageTs,
sessionIds: [...item.sessionIds].sort(),
}
})
const currentTs = Math.floor(this.now() / 1000)
return {
algorithmVersion: PRIVATE_CONTACTS_RANKING_ALGORITHM_VERSION,
@@ -1369,6 +1386,15 @@ function normalizeContactName(value: string): string {
return value.trim().toLocaleLowerCase()
}
function pickPreferredContactDisplayName(platformId: string, ...candidates: Array<string | null | undefined>): string {
const normalizedPlatformId = platformId.trim()
for (const candidate of candidates) {
const value = candidate?.trim()
if (value && value !== normalizedPlatformId) return value
}
return candidates.find((candidate) => candidate?.trim())?.trim() ?? normalizedPlatformId
}
function normalizeRecentDays(value: number | undefined): number | undefined {
if (value === undefined) return undefined
if (!Number.isFinite(value) || value <= 0) throw new Error('recentDays must be a positive number')
@@ -1382,6 +1408,7 @@ interface SearchCandidate {
interface PrivateContactRankAccumulator {
contactKey: string
platformId: string
displayName: string
platform: CrossChatPrivateContactRankItem['platform']
totalMessages: number
+6 -1
View File
@@ -54,7 +54,12 @@ export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts'
export type { ContactsComputeRunner, ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts'
// Cross-chat AI analysis service
export { createCrossChatAnalysisService, preprocessCrossChatMessages } from './cross-chat-analysis'
export {
createCrossChatAnalysisService,
preprocessCrossChatLabel,
preprocessCrossChatMessages,
preprocessCrossChatSummaries,
} from './cross-chat-analysis'
export type {
CrossChatAnalysisService,
CrossChatAnalysisServiceDeps,
+2
View File
@@ -60,6 +60,8 @@ test('the shared Desktop and CLI Web adapter exposes and executes all ten cross-
analysisService,
maxToolResultTokens: 16_000,
preprocessMessagesBySession: async (_sessionId, messages) => messages,
preprocessSummariesBySession: async (_sessionId, summaries) => summaries,
preprocessModelLabel: (value) => value,
})
assert.equal(tools.length, 10)
@@ -220,7 +220,10 @@ function groupSessionsRankingResult(): CrossChatGroupSessionsRankingResult {
}
}
function globalActivitySummaryResult(dataState: CrossChatGlobalActivitySummaryResult['dataState'] = 'fresh') {
function globalActivitySummaryResult(
dataState: CrossChatGlobalActivitySummaryResult['dataState'] = 'fresh',
dailyActivityCount = 1
) {
return {
mode: 'year',
dataState,
@@ -237,7 +240,10 @@ function globalActivitySummaryResult(dataState: CrossChatGlobalActivitySummaryRe
},
monthlyActivity: [{ month: '2026-01', messageCount: 100 }],
monthlyDirectContacts: [{ month: '2026-01', contactCount: 5 }],
dailyActivity: [{ date: '2026-01-01', messageCount: 10 }],
dailyActivity: Array.from({ length: dailyActivityCount }, (_, index) => ({
date: new Date(Date.UTC(2024, 0, index + 1)).toISOString().slice(0, 10),
messageCount: index + 1,
})),
messageTypes: [{ type: 0, count: 100 }],
textLength: { textMessageCount: 100, median: 5, p90: 20, buckets: [] },
coverage: {
@@ -493,6 +499,8 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
analysisService: service,
preprocessMessagesBySession: (_sessionId, messages) =>
messages.map((message) => ({ ...message, senderName: 'U1', content: '[redacted]' })),
preprocessSummariesBySession: (_sessionId, summaries) => summaries,
preprocessModelLabel: (value) => value,
}
}
@@ -609,6 +617,82 @@ describe('cross-chat agent registry', () => {
})
})
it('keeps a multi-group overview within the injected tool token budget', async () => {
const overview: CrossChatOverviewResult = {
appliedRange: {
startTs: 100,
endTs: 200,
dataEarliestMessageTs: 100,
dataLatestMessageTs: 200,
currentTs: 300,
},
items: Array.from({ length: 24 }, (_, sessionIndex) => ({
sessionId: `group-${sessionIndex}-abcdefghijklmnop`,
sessionName: `Project collaboration group ${sessionIndex}`,
sessionType: ChatType.GROUP,
platform: 'test',
label: `Project collaboration group ${sessionIndex}`,
memberActivities: [],
totalMessages: 10_000 + sessionIndex,
activeDays: 200,
activeMembers: 80,
firstMessageTs: 100,
lastMessageTs: 200,
ownerStatus: 'resolved',
ownerMessages: 1_000,
ownerActiveDays: 150,
topMembers: Array.from({ length: 5 }, (_, memberIndex) => ({
memberId: memberIndex + 1,
platformId: `wxid-${sessionIndex}-${memberIndex}-abcdefghijklmnop`,
memberName: `Member ${memberIndex} from project group ${sessionIndex}`,
messageCount: 2_000 - memberIndex,
activeDays: 120,
firstMessageTs: 100,
lastMessageTs: 200,
})),
})),
coverage: {
candidateSessions: 24,
analyzedSessions: 24,
excludedSessions: 0,
missingOwnerSessions: 0,
unresolvedOwnerSessions: 0,
failedSessions: 0,
failedSessionIds: [],
complete: true,
truncated: false,
truncatedReasons: [],
},
}
const context = createContext({ getOverview: async () => overview })
let tokenizerCalls = 0
const countTokens = (text: string) => {
tokenizerCalls++
return Math.ceil(Array.from(text).length / 4)
}
context.maxToolResultTokens = 4_096
context.countTokens = countTokens
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_cross_chat_overview')
assert.ok(tool)
const result = await tool.handler(
{ scopes: overview.items.map((item) => ({ sessionId: item.sessionId })) },
context
)
const content = JSON.parse(result.content)
assert.ok(tokenizerCalls > 0)
assert.ok(countTokens(result.content) <= context.maxToolResultTokens)
assert.equal(content.selection.sessionItemsTotal, 24)
assert.equal(content.selection.sessionItemsReturned, 24)
assert.equal(content.selection.topMembersTotal, 120)
assert.ok(content.selection.topMembersReturned < 120)
assert.equal(content.selection.toolResultTruncated, true)
assert.ok(content.selection.truncatedReasons.includes('top_members_budget'))
assert.equal((result.data as CrossChatOverviewResult).items.length, 24)
assert.equal((result.data as CrossChatOverviewResult).items[0]?.topMembers.length, 5)
})
it('requires an explicit group-ranking mode and forwards the exact range', async () => {
let captured: Parameters<CrossChatAnalysisToolService['rankGroupSessions']>[0] | undefined
const context = createContext({
@@ -629,6 +713,97 @@ describe('cross-chat agent registry', () => {
await assert.rejects(() => tool.handler({ mode: 'unknown' }, context), /mode must be/)
})
it('preprocesses model-visible names in rankings and overview while retaining local details', async () => {
const context = createContext({
getOverview: async () => ({
appliedRange: {
startTs: null,
endTs: null,
dataEarliestMessageTs: 100,
dataLatestMessageTs: 200,
currentTs: 300,
},
items: [
{
sessionId: 'group-a',
sessionName: 'Secret Group',
sessionType: ChatType.GROUP,
platform: 'test',
lastMessageTs: 200,
label: 'Selected Alice',
memberIds: [2],
memberNames: ['Alice'],
memberActivities: [
{
memberId: 2,
platformId: 'user-2',
memberName: 'Alice',
messageCount: 20,
activeDays: 3,
firstMessageTs: 100,
lastMessageTs: 200,
},
],
totalMessages: 20,
activeDays: 3,
activeMembers: 1,
firstMessageTs: 100,
ownerStatus: 'resolved',
ownerMessages: 5,
ownerActiveDays: 2,
topMembers: [
{
memberId: 3,
platformId: 'user-3',
memberName: 'Bob',
messageCount: 30,
activeDays: 4,
firstMessageTs: 100,
lastMessageTs: 200,
},
],
},
],
coverage: {
candidateSessions: 1,
analyzedSessions: 1,
excludedSessions: 0,
missingOwnerSessions: 0,
unresolvedOwnerSessions: 0,
failedSessions: 0,
failedSessionIds: [],
complete: true,
truncated: false,
truncatedReasons: [],
},
}),
})
context.preprocessModelLabel = (_value, pseudonym) => pseudonym
const privateTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'rank_private_contacts')
const groupTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'rank_group_sessions')
const overviewTool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_cross_chat_overview')
assert.ok(privateTool)
assert.ok(groupTool)
assert.ok(overviewTool)
const privateResult = await privateTool.handler({}, context)
const groupResult = await groupTool.handler({ mode: 'owner_activity' }, context)
const overviewResult = await overviewTool.handler({ scopes: [{ sessionId: 'group-a' }] }, context)
const privateContent = JSON.parse(privateResult.content)
const groupContent = JSON.parse(groupResult.content)
const overviewContent = JSON.parse(overviewResult.content)
assert.equal(privateContent.items[0].displayName, 'Contact1')
assert.equal(groupContent.items[0].sessionName, 'Group1')
assert.equal(overviewContent.items[0].sessionName, 'Session1')
assert.equal(overviewContent.items[0].label, 'Session1')
assert.equal(overviewContent.items[0].memberActivities[0].memberName, 'U2@group-a')
assert.equal(overviewContent.items[0].topMembers[0].memberName, 'U3@group-a')
assert.equal((privateResult.data as CrossChatPrivateContactsRankingResult).items[0].displayName, 'Alice')
assert.equal((groupResult.data as CrossChatGroupSessionsRankingResult).items[0].sessionName, 'Group A')
})
it('reads the cached global activity summary without exposing cache signatures', async () => {
let captured: Parameters<CrossChatAnalysisToolService['getGlobalActivitySummary']>[0] | undefined
const context = createContext({
@@ -651,6 +826,27 @@ describe('cross-chat agent registry', () => {
assert.deepEqual(result.data, globalActivitySummaryResult('stale'))
})
it('keeps a full-year activity summary within the injected tool token budget', async () => {
const fullSummary = globalActivitySummaryResult('fresh', 366)
const context = createContext({ getGlobalActivitySummary: () => fullSummary })
const countTokens = (text: string) => Math.ceil(Array.from(text).length / 4)
context.maxToolResultTokens = 1_000
context.countTokens = countTokens
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_global_activity_summary')
assert.ok(tool)
const result = await tool.handler({ mode: 'year', year: 2024 }, context)
const content = JSON.parse(result.content)
assert.ok(countTokens(result.content) <= context.maxToolResultTokens)
assert.equal(content.metrics.sentMessageCount, 100)
assert.equal(content.selection.dailyActivityTotal, 366)
assert.ok(content.selection.dailyActivityReturned < 366)
assert.equal(content.selection.toolResultTruncated, true)
assert.ok(content.selection.truncatedReasons.includes('daily_activity_budget'))
assert.equal((result.data as CrossChatGlobalActivitySummaryResult).summary.dailyActivity.length, 366)
})
it('reads one resolved session through the bounded recap path and preserves evidence', async () => {
let capturedSessionId: string | undefined
const context = createContext({
@@ -674,6 +870,64 @@ describe('cross-chat agent registry', () => {
assert.equal(result.data?.crossChatEvidence.sources[0].snippet, '[redacted]')
})
it('uses only privacy-processed summaries in recent session tool content', async () => {
const context = createContext({
readRecentSession: () => {
const result = recentSessionResult()
return {
...result,
summaries: [
...result.summaries,
{
...result.summaries[0],
segmentId: 4,
participants: ['Bob'],
summary: 'Filtered summary',
},
],
}
},
})
context.preprocessSummariesBySession = (sessionId, summaries) => {
assert.equal(sessionId, 'session-a')
return summaries.slice(0, 1).map((summary) => ({
...summary,
participants: ['U1@session-a'],
summary: summary.summary.replace('Recent topic summary', '[safe summary]'),
}))
}
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'read_recent_session')
assert.ok(tool)
const result = await tool.handler({ session_id: 'session-a' }, context)
const content = JSON.parse(result.content)
assert.equal(content.selection.returnedSummaries, 1)
assert.equal(content.summaries[0].summary, '[safe summary]')
assert.deepEqual(content.summaries[0].participants, ['U1@session-a'])
assert.equal(result.content.includes('Recent topic summary'), false)
assert.equal(result.content.includes('Filtered summary'), false)
assert.equal(result.content.includes('Alice'), false)
assert.equal(result.content.includes('Bob'), false)
})
it('preprocesses recent-session labels only in model-visible content', async () => {
const context = createContext()
context.preprocessModelLabel = (_value, pseudonym) => pseudonym
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'read_recent_session')
assert.ok(tool)
const result = await tool.handler({ session_id: 'session-a' }, context)
const content = JSON.parse(result.content)
assert.equal(content.source.sessionName, 'Session1')
assert.equal(content.messages[0].sessionName, 'Session1')
assert.equal(result.content.includes('Session A'), false)
assert.equal(result.data?.source.sessionName, 'Session A')
assert.equal(result.data?.messages[0].sessionName, 'Session session-a')
assert.equal(result.data?.crossChatEvidence.sources[0].sessionName, 'Session session-a')
})
it('keeps latest private-chat recaps outside the default thirty-day window', () => {
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'search_messages_globally')
assert.ok(tool)
@@ -7,6 +7,8 @@ import type {
CrossChatGroupSessionsRankingResult,
CrossChatGlobalActivitySummaryResult,
CrossChatMessageSource,
CrossChatOverviewItem,
CrossChatOverviewResult,
CrossChatParticipantRef,
CrossChatPrivateContactsRankingResult,
CrossChatResolvedContactSession,
@@ -336,18 +338,28 @@ async function readRecentSessionHandler(
): Promise<ToolResult> {
const result = context.analysisService.readRecentSession(requireString(params.session_id, 'session_id'))
const safeMessages = await preprocessBySession(context, result.messages)
const safeSummaries = await context.preprocessSummariesBySession(result.source.sessionId, result.summaries)
const sessionPseudonym = 'Session1'
const modelSource = {
...result.source,
sessionName: context.preprocessModelLabel(result.source.sessionName, sessionPseudonym),
}
const toRecentModelMessage = (message: CrossChatMessageSource) => ({
...toModelMessage(message),
sessionName: context.preprocessModelLabel(message.sessionName, sessionPseudonym),
})
const buildModelData = (messages: CrossChatMessageSource[], budgetTruncated: boolean) => ({
source: result.source,
source: modelSource,
selection: {
strategy: 'latest_session_slice',
totalMessages: result.coverage.totalMessages,
returnedMessages: messages.length,
returnedSummaries: result.summaries.length,
returnedSummaries: safeSummaries.length,
hasEarlierMessages: result.coverage.hasEarlierMessages,
toolResultTruncated: budgetTruncated,
},
summaries: result.summaries,
messages: messages.map(toModelMessage),
summaries: safeSummaries,
messages: messages.map(toRecentModelMessage),
})
const limited = limitMessagesToBudget(safeMessages, context.maxToolResultTokens, buildModelData, {
countTokens: context.countTokens,
@@ -370,6 +382,8 @@ async function readRecentSessionHandler(
content: JSON.stringify(modelData),
data: {
...modelData,
source: result.source,
messages: limited.messages.map(toModelMessage),
crossChatEvidence: evidence,
},
}
@@ -426,7 +440,29 @@ async function overviewHandler(
items: result.items.map(({ memberNames: _memberNames, ...item }) => item),
coverage: result.coverage,
}
return { content: JSON.stringify(data), data }
const fullModelData: OverviewModelData = {
...data,
items: data.items.map((item, index) => {
const sessionPseudonym = `Session${index + 1}`
const preprocessMember = (member: (typeof item.memberActivities)[number]) => ({
...member,
memberName: context.preprocessModelLabel(member.memberName, `U${member.memberId}@${item.sessionId}`),
})
return {
...item,
sessionName: context.preprocessModelLabel(item.sessionName, sessionPseudonym),
label: context.preprocessModelLabel(item.label, sessionPseudonym),
memberActivities: item.memberActivities.map(preprocessMember),
topMembers: item.topMembers.map(preprocessMember),
}
}),
}
const modelData = limitOverviewToBudget(
fullModelData,
context.maxToolResultTokens,
context.countTokens ?? estimatePayloadTokens
)
return { content: JSON.stringify(modelData), data }
}
async function rankPrivateContactsHandler(
@@ -452,7 +488,14 @@ async function rankPrivateContactsHandler(
}),
}
)
return { content: JSON.stringify(result), data: result }
const modelData = {
...result,
items: result.items.map((item) => ({
...item,
displayName: context.preprocessModelLabel(item.displayName, `Contact${item.rank}`),
})),
}
return { content: JSON.stringify(modelData), data: result }
}
async function rankGroupSessionsHandler(
@@ -482,7 +525,14 @@ async function rankGroupSessionsHandler(
}),
}
)
return { content: JSON.stringify(result), data: result }
const modelData = {
...result,
items: result.items.map((item) => ({
...item,
sessionName: context.preprocessModelLabel(item.sessionName, `Group${item.rank}`),
})),
}
return { content: JSON.stringify(modelData), data: result }
}
async function globalActivitySummaryHandler(
@@ -495,8 +545,17 @@ async function globalActivitySummaryHandler(
mode,
year: parseOptionalNumber(params.year),
})
const modelData = limitGlobalActivitySummaryToBudget(
buildGlobalActivityModelData(result),
context.maxToolResultTokens,
context.countTokens ?? estimatePayloadTokens
)
return { content: JSON.stringify(modelData), data: result }
}
function buildGlobalActivityModelData(result: CrossChatGlobalActivitySummaryResult) {
const summary = result.summary
const modelData = {
return {
mode: result.mode,
dataState: result.dataState,
range: summary.range,
@@ -521,8 +580,14 @@ async function globalActivitySummaryHandler(
processedSessions: summary.task.processedSessions,
totalSessions: summary.task.totalSessions,
},
selection: {
dailyActivityTotal: summary.dailyActivity.length,
dailyActivityReturned: summary.dailyActivity.length,
dailyActivityFormat: 'objects',
toolResultTruncated: false,
truncatedReasons: [] as string[],
},
}
return { content: JSON.stringify(modelData), data: result }
}
async function inspectContactSessionsHandler(
@@ -768,6 +833,266 @@ function parseParticipants(value: unknown): CrossChatParticipantRef[] {
})
}
type GlobalActivityModelData = ReturnType<typeof buildGlobalActivityModelData>
interface OverviewModelData {
appliedRange: CrossChatOverviewResult['appliedRange']
items: Array<Omit<CrossChatOverviewItem, 'memberNames'>>
coverage: CrossChatOverviewResult['coverage']
}
type OverviewItemFormat = 'compact' | 'core' | 'identity' | 'omitted'
function limitOverviewToBudget(
fullData: OverviewModelData,
maxToolResultTokens: number | undefined,
countTokens: (text: string) => number
): unknown {
if (!maxToolResultTokens || maxToolResultTokens <= 0) return fullData
if (countTokens(JSON.stringify(fullData)) <= maxToolResultTokens) return fullData
const compactItems = fullData.items.map((item) => ({
...item,
sessionName: truncateModelLabel(item.sessionName),
label: truncateModelLabel(item.label),
memberActivities: item.memberActivities.map((member) => ({
memberId: member.memberId,
memberName: truncateModelLabel(member.memberName),
messageCount: member.messageCount,
activeDays: member.activeDays,
firstMessageTs: member.firstMessageTs,
lastMessageTs: member.lastMessageTs,
})),
topMembers: item.topMembers.map((member) => ({
memberName: truncateModelLabel(member.memberName),
messageCount: member.messageCount,
activeDays: member.activeDays,
lastMessageTs: member.lastMessageTs,
})),
}))
const buildCandidate = (items: Array<Record<string, unknown>>, itemFormat: OverviewItemFormat) => ({
appliedRange: fullData.appliedRange,
items,
coverage: fullData.coverage,
selection: buildOverviewSelection(fullData.items, items, itemFormat),
})
const fits = (candidate: unknown) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens
// 优先保留所有会话的核心统计,先均匀收缩每个会话的成员明细,避免只留下输入顺序靠前的会话。
const maxTopMembers = compactItems.reduce((max, item) => Math.max(max, item.topMembers.length), 0)
const topMemberCandidate = findLargestOverviewDetailLimit(maxTopMembers, (limit) =>
buildCandidate(
compactItems.map((item) => ({ ...item, topMembers: item.topMembers.slice(0, limit) })),
'compact'
)
)
if (topMemberCandidate && fits(topMemberCandidate)) return topMemberCandidate
const maxMemberActivities = compactItems.reduce((max, item) => Math.max(max, item.memberActivities.length), 0)
const memberActivityCandidate = findLargestOverviewDetailLimit(maxMemberActivities, (limit) =>
buildCandidate(
compactItems.map((item) => ({
...item,
memberActivities: item.memberActivities.slice(0, limit),
topMembers: [],
})),
'compact'
)
)
if (memberActivityCandidate && fits(memberActivityCandidate)) return memberActivityCandidate
const coreItems = compactItems.map((item) => ({
sessionId: item.sessionId,
sessionName: item.sessionName,
sessionType: item.sessionType,
platform: item.platform,
label: item.label,
totalMessages: item.totalMessages,
activeDays: item.activeDays,
activeMembers: item.activeMembers,
firstMessageTs: item.firstMessageTs,
lastMessageTs: item.lastMessageTs,
ownerStatus: item.ownerStatus,
ownerMessages: item.ownerMessages,
ownerActiveDays: item.ownerActiveDays,
}))
const coreCandidate = buildCandidate(coreItems, 'core')
if (fits(coreCandidate)) return coreCandidate
const identityItems = compactItems.map((item) => ({
sessionId: item.sessionId,
sessionName: item.sessionName,
sessionType: item.sessionType,
label: item.label,
totalMessages: item.totalMessages,
activeDays: item.activeDays,
lastMessageTs: item.lastMessageTs,
}))
const identityCandidate = buildCandidate(identityItems, 'identity')
if (fits(identityCandidate)) return identityCandidate
const returnedItems = findLargestOverviewItemSlice(identityItems, (items) => buildCandidate(items, 'identity'))
if (returnedItems) return returnedItems
const omitted = buildCandidate([], 'omitted')
const candidates: unknown[] = [
omitted,
{
selection: buildOverviewSelection(fullData.items, [], 'omitted'),
},
{ toolResultTruncated: true, truncatedReasons: ['tool_result_budget'] },
{},
]
return candidates.find(fits) ?? {}
function findLargestOverviewDetailLimit(
maximum: number,
build: (limit: number) => ReturnType<typeof buildCandidate>
): ReturnType<typeof buildCandidate> | null {
if (!fits(build(0))) return null
let low = 0
let high = maximum
while (low < high) {
const middle = Math.ceil((low + high) / 2)
if (fits(build(middle))) low = middle
else high = middle - 1
}
return build(low)
}
function findLargestOverviewItemSlice(
items: Array<Record<string, unknown>>,
build: (items: Array<Record<string, unknown>>) => ReturnType<typeof buildCandidate>
): ReturnType<typeof buildCandidate> | null {
if (!fits(build([]))) return null
let low = 0
let high = items.length
while (low < high) {
const middle = Math.ceil((low + high) / 2)
if (fits(build(items.slice(0, middle)))) low = middle
else high = middle - 1
}
return build(items.slice(0, low))
}
}
function buildOverviewSelection(
fullItems: OverviewModelData['items'],
returnedItems: Array<Record<string, unknown>>,
itemFormat: OverviewItemFormat
) {
const memberActivitiesTotal = fullItems.reduce((sum, item) => sum + item.memberActivities.length, 0)
const topMembersTotal = fullItems.reduce((sum, item) => sum + item.topMembers.length, 0)
const memberActivitiesReturned = returnedItems.reduce(
(sum, item) => sum + (Array.isArray(item.memberActivities) ? item.memberActivities.length : 0),
0
)
const topMembersReturned = returnedItems.reduce(
(sum, item) => sum + (Array.isArray(item.topMembers) ? item.topMembers.length : 0),
0
)
const truncatedReasons = [
...(memberActivitiesReturned < memberActivitiesTotal ? ['member_activities_budget'] : []),
...(topMembersReturned < topMembersTotal ? ['top_members_budget'] : []),
...(returnedItems.length < fullItems.length ? ['session_items_budget'] : []),
'item_fields_budget',
'tool_result_budget',
]
return {
sessionItemsTotal: fullItems.length,
sessionItemsReturned: returnedItems.length,
memberActivitiesTotal,
memberActivitiesReturned,
topMembersTotal,
topMembersReturned,
itemFormat,
toolResultTruncated: true,
truncatedReasons,
}
}
function limitGlobalActivitySummaryToBudget(
fullData: GlobalActivityModelData,
maxToolResultTokens: number | undefined,
countTokens: (text: string) => number
): unknown {
if (!maxToolResultTokens || maxToolResultTokens <= 0) return fullData
if (countTokens(JSON.stringify(fullData)) <= maxToolResultTokens) return fullData
const totalDailyPoints = fullData.dailyActivity.length
const buildCompactedDailyData = (returned: number) => ({
...fullData,
dailyActivity: sampleEvenly(fullData.dailyActivity, returned).map(
(point) => [point.date, point.messageCount] as const
),
selection: {
dailyActivityTotal: totalDailyPoints,
dailyActivityReturned: returned,
dailyActivityFormat: 'date_message_count_tuples',
toolResultTruncated: returned < totalDailyPoints,
truncatedReasons: returned < totalDailyPoints ? ['daily_activity_budget'] : [],
},
})
let low = 0
let high = totalDailyPoints
while (low < high) {
const middle = Math.ceil((low + high) / 2)
if (countTokens(JSON.stringify(buildCompactedDailyData(middle))) <= maxToolResultTokens) low = middle
else high = middle - 1
}
const compacted = buildCompactedDailyData(low)
if (countTokens(JSON.stringify(compacted)) <= maxToolResultTokens) return compacted
const truncatedReasons = [...(totalDailyPoints > 0 ? ['daily_activity_budget'] : []), 'tool_result_budget']
const selection = {
dailyActivityTotal: totalDailyPoints,
dailyActivityReturned: 0,
dailyActivityFormat: 'omitted',
toolResultTruncated: true,
truncatedReasons,
}
const candidates: unknown[] = [
{
mode: fullData.mode,
dataState: fullData.dataState,
range: fullData.range,
metrics: fullData.metrics,
monthlyActivity: fullData.monthlyActivity,
monthlyDirectContacts: fullData.monthlyDirectContacts,
messageTypes: fullData.messageTypes,
textLength: fullData.textLength,
coverage: fullData.coverage,
cache: fullData.cache,
selection,
},
{
mode: fullData.mode,
dataState: fullData.dataState,
range: fullData.range,
metrics: fullData.metrics,
coverage: fullData.coverage,
cache: fullData.cache,
selection,
},
{ mode: fullData.mode, dataState: fullData.dataState, metrics: fullData.metrics, selection },
{ dataState: fullData.dataState, selection },
{ toolResultTruncated: true, truncatedReasons: ['tool_result_budget'] },
{},
]
return candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? {}
}
function sampleEvenly<T>(items: T[], limit: number): T[] {
if (limit <= 0 || items.length === 0) return []
if (limit >= items.length) return [...items]
if (limit === 1) return [items[items.length - 1]!]
return Array.from({ length: limit }, (_, index) => {
const sourceIndex = Math.round((index * (items.length - 1)) / (limit - 1))
return items[sourceIndex]!
})
}
function limitContactPageSizeToBudget(
requestedPageSize: number | undefined,
maxToolResultTokens: number | undefined
+6
View File
@@ -24,6 +24,7 @@ import type {
CrossChatOverviewRequest,
CrossChatOverviewResult,
CrossChatRecentSessionResult,
CrossChatRecentSessionSummary,
CrossChatSearchRequest,
CrossChatSearchResult,
CrossChatSharedInteractionsRequest,
@@ -404,6 +405,11 @@ export interface CrossChatToolExecutionContext {
sessionId: string,
messages: CrossChatMessageSource[]
) => CrossChatMessageSource[] | Promise<CrossChatMessageSource[]>
preprocessSummariesBySession: (
sessionId: string,
summaries: CrossChatRecentSessionSummary[]
) => CrossChatRecentSessionSummary[] | Promise<CrossChatRecentSessionSummary[]>
preprocessModelLabel: (value: string, pseudonym: string) => string
}
// ==================== Raw Message ====================