mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
1 parent
05d9b34f30
commit
f9ba12e535
12 files changed
+952
-34
No files matched your search
@@ -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>[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
Reference in new issue
Block a user