mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): rank private contacts globally
This commit is contained in:
1 parent
3b50a722c9
commit
6d64333675
19 files changed
+528
-6
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
createContactsService,
|
||||
createCrossChatAnalysisService,
|
||||
createDatabaseManagerAdapter,
|
||||
PreferencesManager,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import type { SemanticIndexRuntime } from '@openchatlab/node-runtime'
|
||||
import { createServer } from './server'
|
||||
@@ -146,9 +147,11 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
runtimeIdentity: runtime,
|
||||
nativeBinding,
|
||||
})
|
||||
const preferencesManager = new PreferencesManager(pathProvider.getSystemDir())
|
||||
const crossChatAnalysisService = createCrossChatAnalysisService({
|
||||
adapter: sessionAdapter,
|
||||
contactsService,
|
||||
getExcludedSessionIds: () => preferencesManager.load().ownerExcludedSessionIds,
|
||||
})
|
||||
|
||||
const aiDataDir = pathProvider.getAiDataDir()
|
||||
@@ -200,6 +203,7 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{
|
||||
nativeBinding,
|
||||
runtimeIdentity: runtime,
|
||||
contactsService,
|
||||
preferencesManager,
|
||||
semanticIndexService,
|
||||
aiContext: {
|
||||
aiDataDir,
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
CustomProviderStore,
|
||||
CustomModelStore,
|
||||
ContactsService,
|
||||
PreferencesManager,
|
||||
PendingDataDirMigration,
|
||||
RuntimeIdentity,
|
||||
} from '@openchatlab/node-runtime'
|
||||
@@ -73,6 +74,7 @@ export function registerWebRoutes(
|
||||
nativeBinding?: string
|
||||
runtimeIdentity?: RuntimeIdentity
|
||||
contactsService?: ContactsService
|
||||
preferencesManager?: PreferencesManager
|
||||
aiContext?: AiContextOptions
|
||||
/** 由 server 入口注入的共享语义索引运行时;传入时由调用方管理生命周期 */
|
||||
semanticIndexService?: SemanticIndexRuntime
|
||||
@@ -135,6 +137,7 @@ export function registerWebRoutes(
|
||||
nativeBinding: options?.nativeBinding,
|
||||
semanticIndexService,
|
||||
contactsService: options?.contactsService,
|
||||
preferencesManager: options?.preferencesManager,
|
||||
analyticsService,
|
||||
getCurrentAiLogPath: () => getServerAiLogger()?.getExistingLogPath() ?? null,
|
||||
openDirectory: openDirectoryPath,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
appLogger,
|
||||
createContactsService,
|
||||
createCrossChatAnalysisService,
|
||||
PreferencesManager,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime'
|
||||
import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec'
|
||||
@@ -99,9 +100,11 @@ export async function startInternalServer(
|
||||
runtimeIdentity: runtime,
|
||||
nativeBinding,
|
||||
})
|
||||
const preferencesManager = new PreferencesManager(pathProvider.getSystemDir())
|
||||
const crossChatAnalysisService = createCrossChatAnalysisService({
|
||||
adapter: sessionAdapter,
|
||||
contactsService,
|
||||
getExcludedSessionIds: () => preferencesManager.load().ownerExcludedSessionIds,
|
||||
})
|
||||
|
||||
const aiDataDir = pathProvider.getAiDataDir()
|
||||
@@ -192,6 +195,7 @@ export async function startInternalServer(
|
||||
},
|
||||
semanticIndexService: newSemanticIndexService ?? undefined,
|
||||
contactsService,
|
||||
preferencesManager,
|
||||
openDirectory: (dirPath) => shell.openPath(dirPath).then(() => {}),
|
||||
showInFolder: (filePath) => {
|
||||
shell.showItemInFolder(filePath)
|
||||
|
||||
@@ -68,6 +68,7 @@ export {
|
||||
resolveOwnerMember,
|
||||
buildContactKey,
|
||||
shouldScopeContactToSession,
|
||||
getCrossChatSessionActivityFacts,
|
||||
aggregateAnnualSummaryFacts,
|
||||
getAnnualSummarySessionFacts,
|
||||
MIN_GROUP_SESSIONS_FOR_CONTACTS,
|
||||
@@ -176,6 +177,9 @@ export type {
|
||||
ParticipantInteractionPairFacts,
|
||||
ParticipantSetInteractionFacts,
|
||||
ParticipantSetInteractionFactsOptions,
|
||||
CrossChatMemberActivityFacts,
|
||||
CrossChatSessionActivityFacts,
|
||||
CrossChatStatisticsQueryOptions,
|
||||
RelationshipGraphEdgeFact,
|
||||
RelationshipGraphMemberFact,
|
||||
AnnualSummaryAggregatedData,
|
||||
|
||||
@@ -144,6 +144,7 @@ test('returns bounded session and per-member activity without system messages',
|
||||
{ memberId: 3, memberName: 'Bob', messageCount: 1, activeDays: 1 },
|
||||
]
|
||||
)
|
||||
assert.deepEqual(facts.activeDayKeys, ['2024-01-01', '2024-01-02'])
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
@@ -165,6 +166,7 @@ test('returns zero range facts while preserving the imported data cutoff', () =>
|
||||
assert.equal(facts.firstMessageTs, null)
|
||||
assert.equal(facts.lastMessageTs, null)
|
||||
assert.deepEqual(facts.members, [])
|
||||
assert.deepEqual(facts.activeDayKeys, [])
|
||||
assert.equal(facts.dataLatestMessageTs, localTs(2024, 1, 1))
|
||||
} finally {
|
||||
db.close()
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface CrossChatSessionActivityFacts {
|
||||
lastMessageTs: number | null
|
||||
dataEarliestMessageTs: number | null
|
||||
dataLatestMessageTs: number | null
|
||||
activeDayKeys: string[]
|
||||
members: CrossChatMemberActivityFacts[]
|
||||
}
|
||||
|
||||
@@ -85,6 +86,17 @@ export function getCrossChatSessionActivityFacts(
|
||||
WHERE ${messageCondition}`
|
||||
)
|
||||
.get() as { firstMessageTs: number | null; lastMessageTs: number | null } | undefined
|
||||
const activeDayKeys = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as activeDay
|
||||
FROM message msg
|
||||
JOIN member m ON msg.sender_id = m.id
|
||||
WHERE ${messageCondition}${timeFilter.sql}
|
||||
ORDER BY activeDay`
|
||||
)
|
||||
.all(...timeFilter.params) as Array<{ activeDay: string }>
|
||||
).map((row) => row.activeDay)
|
||||
|
||||
return {
|
||||
totalMessages: summary?.totalMessages ?? 0,
|
||||
@@ -94,6 +106,7 @@ export function getCrossChatSessionActivityFacts(
|
||||
lastMessageTs: summary?.lastMessageTs ?? null,
|
||||
dataEarliestMessageTs: dataRange?.firstMessageTs ?? null,
|
||||
dataLatestMessageTs: dataRange?.lastMessageTs ?? null,
|
||||
activeDayKeys,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('cross-chat agent prompt', () => {
|
||||
'read_recent_session',
|
||||
'inspect_contact_sessions',
|
||||
'inspect_shared_interactions',
|
||||
'rank_private_contacts',
|
||||
'search_messages_globally',
|
||||
'get_cross_chat_message_context',
|
||||
'get_cross_chat_overview',
|
||||
@@ -35,6 +36,9 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.match(prompt, /不要仅因为消息中出现了 @联系人就机械调用/)
|
||||
assert.match(prompt, /roster_only/)
|
||||
assert.match(prompt, /不要为了多人问题机械地先对每个人调用 inspect_contact_sessions/)
|
||||
assert.match(prompt, /私聊频率排行/)
|
||||
assert.match(prompt, /不要用 search_messages_globally 的关键词命中量/)
|
||||
assert.match(prompt, /coverage\.complete=true/)
|
||||
assert.match(prompt, /所有参与者都出现.*严格交集/)
|
||||
assert.match(prompt, /群名称、成员结构、共同活跃和相邻发言只是调查导航信号/)
|
||||
assert.match(prompt, /partial\/skipped_budget 不是零/)
|
||||
@@ -61,13 +65,14 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.doesNotMatch(prompt, /可以使用.*execute_sql/)
|
||||
})
|
||||
|
||||
it('keeps the English prompt on the same seven-tool investigation contract', () => {
|
||||
it('keeps the English prompt on the same eight-tool investigation contract', () => {
|
||||
const prompt = buildCrossChatSystemPrompt('en-US', new Date('2026-08-23T12:00:00Z'))
|
||||
for (const tool of [
|
||||
'resolve_chat_entities',
|
||||
'read_recent_session',
|
||||
'inspect_contact_sessions',
|
||||
'inspect_shared_interactions',
|
||||
'rank_private_contacts',
|
||||
'search_messages_globally',
|
||||
'get_cross_chat_message_context',
|
||||
'get_cross_chat_overview',
|
||||
@@ -75,6 +80,9 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.match(prompt, new RegExp(tool))
|
||||
}
|
||||
assert.match(prompt, /strict intersection containing every participant/)
|
||||
assert.match(prompt, /private-chat frequency ranking/)
|
||||
assert.match(prompt, /never substitute keyword hits/i)
|
||||
assert.match(prompt, /coverage\.complete=true/)
|
||||
assert.match(prompt, /investigation signals, not proof/)
|
||||
assert.match(prompt, /partial or skipped_budget never means zero/)
|
||||
assert.match(prompt, /current date is.*August 23, 2026/i)
|
||||
|
||||
@@ -222,16 +222,18 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()):
|
||||
- 当用户问“我和某人最近聊了什么”或明确要求回顾两人的私聊内容时,只使用 resolve_chat_entities 返回的对应私聊 session,不要混入该联系人所在的群聊。用户没有给出明确日期或天数时,这里的“最近”指已导入私聊中最新一段可用内容:解析到唯一私聊后直接调用 read_recent_session,不要先用 search_messages_globally 填满跨对话证据预算。只有明确说“最近 N 天”、给出日期范围、要求更早内容或深度调查时才改用 search_messages_globally。没有对应私聊时先明确说明,再询问是否改查共同群。
|
||||
- 用户询问某个联系人出现在哪些已导入会话、分别说了多少或最近在哪里活跃时,先用 inspect_contact_sessions 盘点来源。不要仅因为消息中出现了 @联系人就机械调用;普通事实题仍按问题使用搜索与上下文工具。
|
||||
- 用户询问 2—5 人的关系、相识背景、共同圈子或互动变化时,先用 inspect_shared_interactions 找全员共同会话、逐人活跃、直接回复、相邻发言和消息锚点;不要为了多人问题机械地先对每个人调用 inspect_contact_sessions。
|
||||
- 用户询问“我和谁私聊最多”“私聊频率排行”或“和哪些联系人聊天的活跃天数最多”时,直接调用 rank_private_contacts,并按用户给出的自然年、日期或 recent_days 统计。不要把“我”解析成联系人,也不要用 search_messages_globally 的关键词命中量、抽样消息或 Contacts 综合分数代替全局排行。
|
||||
- 三人及以上的 inspect_shared_interactions 结果是“所有参与者都出现”的严格交集;如果用户要比较多组两两关系,应按成员对分别调查,不要把只含部分人的会话混进全员共同来源。
|
||||
- 只有用户明确表达“忘了和谁聊过”“在所有聊天里找”等全局发现意图时,才允许不带 scopes 调用 search_messages_globally。全局发现必须提供至少一个关键词;已经限定 scopes 时,可以不提供关键词来抽取近期消息样本。
|
||||
- 除上述双人私聊内容回顾外,用户使用“最近”“近期”等相对时间但没有给出具体范围时,第一次支持时间范围的搜索或结构调查工具调用必须传 recent_days=30,并在回答中说明按最近 30 天统计;禁止根据数据库截止时间或 lastMessageTs 手工计算 start_time,也禁止先扫描全部历史再事后主观截取。只有 30 天内没有结果时,才能询问用户是否扩大范围。
|
||||
- 用户把自己作为事件主体(例如“我最近跟多少人聊过我买房”)时,第一次搜索必须传 sender=owner,把本人发言作为检索种子;不要先搜索所有人的同类话题。搜索工具会自动附带周边消息以识别真正参与对话的人;只有需要更深原文时才继续使用 get_cross_chat_message_context,上下文不限制为本人发言。
|
||||
|
||||
工具与结论规则:
|
||||
- 你只有 resolve_chat_entities、read_recent_session、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview 七个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。
|
||||
- 你只有 resolve_chat_entities、read_recent_session、rank_private_contacts、inspect_contact_sessions、inspect_shared_interactions、search_messages_globally、get_cross_chat_message_context、get_cross_chat_overview 八个工具。不要声称可以使用单会话 SQL、语义索引、技能、图表或热力图。
|
||||
- read_recent_session 是单个已解析会话的轻量近期回顾:它内部读取最新一小段原文和最多 5 个已有段落摘要,模型不能指定消息预算。不要仅因为 selection.hasEarlierMessages=true 就自动扩大搜索;先回答用户当前的普通近期回顾,只有用户要求具体时间、关键词、更早内容或深挖时再使用 search_messages_globally。
|
||||
- inspect_contact_sessions 返回的是已导入会话中的本人发言和成员表结构事实,不返回聊天正文,也不能证明现实世界中的全部群聊。roster_only 只说明导入成员表记录过此人;存在 nextCursor 或 coverage 不完整时不能表述成全量结果。
|
||||
- inspect_shared_interactions 的群名称、成员结构、共同活跃和相邻发言只是调查导航信号,不能直接证明同事、同学、亲属、朋友或亲密关系。优先对 direct_reply 和 proximity 锚点调用 get_cross_chat_message_context 读取原文;回复目标可能由 relatedMessageId 指向较远消息,必要时分别展开两个消息 ID。
|
||||
- rank_private_contacts 返回完整候选扫描下的确定性私聊计数、双方消息分项、活跃天数和身份 coverage。只有 coverage.complete=true 时才能称为全局绝对排行;缺少或无法解析“我”、私聊对象歧义、会话失败或 time_budget 截断都必须明确披露。该工具不返回聊天正文,用户继续追问“为什么”时再解析对应联系人并读取必要原文。
|
||||
- 没有直接回复或原文证据时,只能确认共同来源或同场活跃,并明确具体关系仍不确定。只有 coverage 完整且时间范围明确,才允许给出“没有发生过”或“没有互动”的强负面判断;partial/skipped_budget 不是零。
|
||||
- search_messages_globally 提供关键词时是字面 LIKE 检索,不是语义搜索。用户问“聊过什么”等开放问题时,先限定联系人或群聊 scopes,再无关键词抽取指定时间范围内的消息;工具自行控制证据量,优先返回私聊证据,剩余容量按目标人物在群聊中的匹配活跃度分配,并自动附带命中消息周围的上下文。不要传入消息数量、会话数量或执行时长预算,也不要对全部会话做无关键词扫描。
|
||||
- 比较联系人或群聊时,优先分别给出概览并检索有来源的证据。需要理解命中消息时,用 session_id + message_id 获取上下文。
|
||||
@@ -239,7 +241,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()):
|
||||
- 涉及单个联系人的限时搜索返回 0 条时,不要只说“什么都没有”。必须从 resolve_chat_entities 返回的私聊 session 元数据中说明最近一条已导入私聊的具体时间,以及它是否落在查询窗口之外;没有私聊 session 或没有可用时间时也要明确说明。之后再询问用户是否扩大范围。
|
||||
- sender=owner 时还要检查 coverage.ownerResolution;存在未设置或无法解析“我”的会话时,必须说明对应覆盖缺口,禁止通过昵称猜测本人。
|
||||
- 引用证据时说明来源会话;不要泄露工具未返回的信息,也不要编造联系人、群聊或消息。
|
||||
- 如果问题不需要聊天数据,直接回答;如果现有七个工具不足,坦诚说明当前能力边界。
|
||||
- 如果问题不需要聊天数据,直接回答;如果现有八个工具不足,坦诚说明当前能力边界。
|
||||
|
||||
你可以使用工具。如果需要你没有的信息,请调用提供的函数。`
|
||||
}
|
||||
@@ -255,16 +257,18 @@ Data and scope rules:
|
||||
- When the user asks “what have this person and I been talking about recently” or clearly requests a dyadic private-chat recap, use only the resolved direct private session and do not mix in groups containing that contact. Without an explicit calendar range or duration, “recent” means the latest available imported private conversation: after resolving one direct private session, call read_recent_session instead of filling the cross-chat search evidence budget. Use search_messages_globally only when the user gives dates or a duration, asks for earlier content, or requests deeper investigation. If no direct private session exists, say so before asking whether to search shared groups.
|
||||
- When the user asks which imported sessions contain one contact, how much that person spoke in each session, or where they were active, use inspect_contact_sessions first. Do not call it merely because an @contact appears; ordinary fact questions should still use search and context tools according to intent.
|
||||
- For relationship, shared-background, shared-circle, or interaction-change questions about two to five people, call inspect_shared_interactions first to find sessions containing everyone, per-person activity, replies, proximity signals, and message anchors. Do not mechanically call inspect_contact_sessions once per person first.
|
||||
- When the user asks who they privately chatted with most, requests a private-chat frequency ranking, or asks which contacts span the most private-chat active days, call rank_private_contacts directly with the requested calendar year, explicit dates, or recent_days. Never resolve the user as a contact, and never substitute keyword hits, sampled search evidence, or Contacts scores for the deterministic ranking.
|
||||
- For three or more people, inspect_shared_interactions returns the strict intersection containing every participant. If the user wants several pairwise comparisons, inspect each pair separately rather than mixing sessions that contain only part of the cohort into the all-participant result.
|
||||
- Call search_messages_globally without scopes only for explicit global discovery such as "I forgot who I discussed this with". Global discovery always requires at least one keyword; scoped searches may omit keywords to sample recent messages.
|
||||
- Except for the dyadic private-chat recap above, when the user says "recent" or "recently" without a specific range, the first time-ranged search or structural-inspection call must pass recent_days=30 and the answer must state that it covers the last 30 days. Never calculate start_time from a dataset cutoff or lastMessageTs, and never scan all history first and apply a subjective cutoff afterward. Ask before widening only when the 30-day search finds nothing.
|
||||
- When the user is the subject of the event, such as "who did I discuss my home purchase with", the first search must pass sender=owner and treat the owner's messages as discovery seeds. Search automatically includes surrounding messages to identify actual participants; call get_cross_chat_message_context only when deeper source text is needed. Surrounding context is not owner-only.
|
||||
|
||||
Tool and conclusion rules:
|
||||
- You only have resolve_chat_entities, read_recent_session, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, and get_cross_chat_overview. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps.
|
||||
- You only have resolve_chat_entities, read_recent_session, rank_private_contacts, inspect_contact_sessions, inspect_shared_interactions, search_messages_globally, get_cross_chat_message_context, and get_cross_chat_overview. Do not claim access to session SQL, semantic search, skills, charts, or heatmaps.
|
||||
- read_recent_session is the lightweight recent-recap path for one resolved session. It internally reads a small newest message slice and up to five existing segment summaries; the model cannot set message budgets. Do not expand merely because selection.hasEarlierMessages is true. Answer the ordinary recent recap first, then use search_messages_globally only for explicit dates, keywords, earlier content, or deeper investigation.
|
||||
- inspect_contact_sessions returns the contact's own activity and imported member-roster facts, not message text or every real-world chat. roster_only only means that the imported member table records the person. Never describe a paged or incomplete result as exhaustive.
|
||||
- Group names, member structure, co-active days, and proximity from inspect_shared_interactions are investigation signals, not proof of colleague, classmate, family, friend, or intimate relationships. Expand direct_reply and proximity anchors with get_cross_chat_message_context; a distant reply target may require separately opening relatedMessageId.
|
||||
- rank_private_contacts returns deterministic private-chat counts, owner/contact splits, active days, and identity coverage after scanning all eligible candidates. Describe it as a global absolute ranking only when coverage.complete=true. Disclose missing or unresolved owners, ambiguous private contacts, failed sessions, and time-budget truncation. It returns no message text; resolve the relevant contact and read only the needed evidence if the user asks why.
|
||||
- Without direct replies or source text, state only shared-source or co-activity facts and keep the relationship uncertain. Strong negative claims require complete coverage and an explicit time range. partial or skipped_budget never means zero.
|
||||
- Search with keywords is literal LIKE matching, not semantic retrieval. For open-ended questions such as "what did we discuss", first resolve contact or session scopes, then sample messages inside the requested time range without keywords. The tool controls evidence volume, prioritizes private-chat evidence, allocates remaining capacity to groups by the target person's matching activity, and automatically includes surrounding context. Do not pass message-count, session-count, or execution-time budgets, and never scan every session without keywords.
|
||||
- For comparisons, prefer separate overviews and source-backed evidence. Expand a hit with session_id plus message_id when context is needed.
|
||||
@@ -272,7 +276,7 @@ Tool and conclusion rules:
|
||||
- When a time-bounded search about one contact returns zero messages, do not merely say that nothing was found. From the direct private session metadata returned by resolve_chat_entities, state the latest imported private-chat timestamp as an exact date and whether it falls outside the requested window. Explicitly say when no private session or timestamp is available, then ask before widening the range.
|
||||
- For sender=owner, inspect coverage.ownerResolution and disclose sessions where the owner is missing or unresolved. Never guess the owner from display names.
|
||||
- Name the source session when citing evidence. Never invent people, sessions, or messages.
|
||||
- Answer directly when no chat data is needed. If the seven tools are insufficient, explain the current limitation honestly.
|
||||
- Answer directly when no chat data is needed. If the eight tools are insufficient, explain the current limitation honestly.
|
||||
|
||||
You have access to tools. If you need information you don't have, use the provided functions.`
|
||||
}
|
||||
@@ -543,6 +543,10 @@ export type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOperationOptions,
|
||||
CrossChatPrivateContactRankItem,
|
||||
CrossChatPrivateContactsRankBy,
|
||||
CrossChatPrivateContactsRankingRequest,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatOverviewItem,
|
||||
CrossChatOverviewRequest,
|
||||
CrossChatOverviewResult,
|
||||
|
||||
@@ -7,6 +7,10 @@ export type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOperationOptions,
|
||||
CrossChatPrivateContactRankItem,
|
||||
CrossChatPrivateContactsRankBy,
|
||||
CrossChatPrivateContactsRankingRequest,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatOverviewItem,
|
||||
CrossChatOverviewRequest,
|
||||
CrossChatOverviewResult,
|
||||
|
||||
@@ -277,6 +277,128 @@ test('contact lookup resolves a unique alias and preserves same-name ambiguity',
|
||||
}
|
||||
})
|
||||
|
||||
test('ranks private contacts by exact message totals, merges stable identities, and respects owner exclusions', async () => {
|
||||
const { env, contactsService } = createFixture()
|
||||
const day = 1_780_000_000
|
||||
env.seed({
|
||||
id: 'rank-alice-1',
|
||||
name: 'Alice private 1',
|
||||
type: 'private',
|
||||
ownerPlatformId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', name: 'Me' },
|
||||
{ id: 2, platformId: 'alice', name: 'Alice' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 1, ts: day + 10, content: 'hello' },
|
||||
{ id: 2, senderId: 2, ts: day + 20, content: 'hi' },
|
||||
],
|
||||
})
|
||||
env.seed({
|
||||
id: 'rank-alice-2',
|
||||
name: 'Alice private 2',
|
||||
type: 'private',
|
||||
ownerPlatformId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', name: 'Me' },
|
||||
{ id: 2, platformId: 'alice', name: 'Alice' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 1, senderId: 2, ts: day + 30, content: 'again' },
|
||||
{ id: 2, senderId: 1, ts: day + 40, content: 'reply' },
|
||||
],
|
||||
})
|
||||
env.seed({
|
||||
id: 'rank-bob',
|
||||
name: 'Bob private',
|
||||
type: 'private',
|
||||
ownerPlatformId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', name: 'Me' },
|
||||
{ id: 2, platformId: 'bob', name: 'Bob' },
|
||||
],
|
||||
messages: Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
senderId: index % 2 === 0 ? 1 : 2,
|
||||
ts: day + 86_400 + index,
|
||||
content: `bob-${index}`,
|
||||
})),
|
||||
})
|
||||
env.seed({
|
||||
id: 'rank-excluded',
|
||||
name: 'Excluded private',
|
||||
type: 'private',
|
||||
ownerPlatformId: 'owner',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', name: 'Me' },
|
||||
{ id: 2, platformId: 'charlie', name: 'Charlie' },
|
||||
],
|
||||
messages: Array.from({ length: 8 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
senderId: 2,
|
||||
ts: day + index,
|
||||
content: `excluded-${index}`,
|
||||
})),
|
||||
})
|
||||
env.seed({
|
||||
id: 'rank-missing-owner',
|
||||
name: 'Missing owner private',
|
||||
type: 'private',
|
||||
members: [
|
||||
{ id: 1, platformId: 'owner', name: 'Me' },
|
||||
{ id: 2, platformId: 'dave', name: 'Dave' },
|
||||
],
|
||||
messages: [{ id: 1, senderId: 2, ts: day, content: 'missing owner' }],
|
||||
})
|
||||
|
||||
try {
|
||||
const service = createCrossChatAnalysisService({
|
||||
adapter: env.adapter,
|
||||
contactsService,
|
||||
getExcludedSessionIds: () => ['private-alice', 'rank-excluded'],
|
||||
now: () => 1_790_000_000_000,
|
||||
})
|
||||
const result = await service.rankPrivateContacts({ startTs: day, endTs: day + 200_000, limit: 10 })
|
||||
|
||||
assert.deepEqual(
|
||||
result.items.map((item) => ({
|
||||
contactKey: item.contactKey,
|
||||
totalMessages: item.totalMessages,
|
||||
ownerMessages: item.ownerMessages,
|
||||
contactMessages: item.contactMessages,
|
||||
activeDays: item.activeDays,
|
||||
sessionIds: item.sessionIds,
|
||||
})),
|
||||
[
|
||||
{
|
||||
contactKey: 'test:bob',
|
||||
totalMessages: 5,
|
||||
ownerMessages: 3,
|
||||
contactMessages: 2,
|
||||
activeDays: 1,
|
||||
sessionIds: ['rank-bob'],
|
||||
},
|
||||
{
|
||||
contactKey: 'test:alice',
|
||||
totalMessages: 4,
|
||||
ownerMessages: 2,
|
||||
contactMessages: 2,
|
||||
activeDays: 1,
|
||||
sessionIds: ['rank-alice-1', 'rank-alice-2'],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert.equal(result.appliedRange.currentTs, 1_790_000_000)
|
||||
assert.equal(result.coverage.candidateSessions, 6)
|
||||
assert.equal(result.coverage.analyzedSessions, 3)
|
||||
assert.equal(result.coverage.excludedSessions, 2)
|
||||
assert.equal(result.coverage.missingOwnerSessions, 1)
|
||||
assert.equal(result.coverage.complete, false)
|
||||
} 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> = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
buildContactKey,
|
||||
getCrossChatSessionActivityFacts,
|
||||
getNonSystemMembersForContacts,
|
||||
getParticipantSessionFacts,
|
||||
getParticipantSetInteractionFacts,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
resolveContactMember,
|
||||
resolveOwnerMember,
|
||||
searchMessagesByKeywords,
|
||||
shouldScopeContactToSession,
|
||||
type ContactMemberRef,
|
||||
type DatabaseAdapter,
|
||||
type ParticipantSetInteractionFacts,
|
||||
@@ -32,6 +35,9 @@ import {
|
||||
type CrossChatOverviewRequest,
|
||||
type CrossChatOverviewResult,
|
||||
type CrossChatParticipantRef,
|
||||
type CrossChatPrivateContactRankItem,
|
||||
type CrossChatPrivateContactsRankingRequest,
|
||||
type CrossChatPrivateContactsRankingResult,
|
||||
type CrossChatRecentSessionResult,
|
||||
type CrossChatResolvedContact,
|
||||
type CrossChatResolvedSession,
|
||||
@@ -71,10 +77,15 @@ const DEFAULT_SHARED_INTERACTIONS_PAGE_SIZE = 20
|
||||
const MAX_SHARED_INTERACTIONS_PAGE_SIZE = 50
|
||||
const DEFAULT_MAX_ANCHORS_PER_PAIR = 4
|
||||
const MAX_MAX_ANCHORS_PER_PAIR = 8
|
||||
const PRIVATE_CONTACTS_RANKING_ALGORITHM_VERSION = 'private-contacts-ranking-v1'
|
||||
const DEFAULT_RANKING_LIMIT = 10
|
||||
const MAX_RANKING_LIMIT = 50
|
||||
const RANKING_MAX_WALL_TIME_MS = 30_000
|
||||
|
||||
export interface CrossChatAnalysisServiceDeps {
|
||||
adapter: SessionRuntimeAdapter
|
||||
contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'>
|
||||
getExcludedSessionIds?: () => readonly string[]
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
@@ -90,6 +101,10 @@ export interface CrossChatAnalysisService {
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatSharedInteractionsResult>
|
||||
readRecentSession(sessionId: string): CrossChatRecentSessionResult
|
||||
rankPrivateContacts(
|
||||
request: CrossChatPrivateContactsRankingRequest,
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatPrivateContactsRankingResult>
|
||||
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
||||
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
||||
@@ -798,6 +813,170 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
async rankPrivateContacts(
|
||||
request: CrossChatPrivateContactsRankingRequest,
|
||||
options: CrossChatOperationOptions = {}
|
||||
): Promise<CrossChatPrivateContactsRankingResult> {
|
||||
throwIfAborted(options.signal)
|
||||
const range = normalizeInspectionRange(request.startTs, request.endTs, request.recentDays, () => this.now())
|
||||
const rankBy = request.rankBy === 'active_days' ? 'active_days' : 'message_count'
|
||||
const limit = clampInteger(request.limit, DEFAULT_RANKING_LIMIT, 1, MAX_RANKING_LIMIT)
|
||||
const excludedSessionIds = new Set(this.deps.getExcludedSessionIds?.() ?? [])
|
||||
const sessionIds = this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds()
|
||||
const deadline = this.now() + RANKING_MAX_WALL_TIME_MS
|
||||
const accumulators = new Map<string, PrivateContactRankAccumulator>()
|
||||
const failedSessionIds: string[] = []
|
||||
const coverage = {
|
||||
candidateSessions: 0,
|
||||
scannedSessions: 0,
|
||||
analyzedSessions: 0,
|
||||
excludedSessions: 0,
|
||||
missingOwnerSessions: 0,
|
||||
unresolvedOwnerSessions: 0,
|
||||
missingContactSessions: 0,
|
||||
ambiguousContactSessions: 0,
|
||||
failedSessions: 0,
|
||||
failedSessionIds,
|
||||
complete: true,
|
||||
truncated: false,
|
||||
truncatedReasons: [] as Array<'time_budget'>,
|
||||
}
|
||||
let dataEarliestMessageTs: number | null = null
|
||||
let dataLatestMessageTs: number | null = null
|
||||
let processedSessions = 0
|
||||
|
||||
options.onProgress?.({ processedSessions: 0, totalSessions: sessionIds.length })
|
||||
for (const [index, sessionId] of sessionIds.entries()) {
|
||||
throwIfAborted(options.signal)
|
||||
if (this.now() >= deadline) {
|
||||
coverage.truncated = true
|
||||
coverage.truncatedReasons.push('time_budget')
|
||||
break
|
||||
}
|
||||
options.onProgress?.({ processedSessions: index, totalSessions: sessionIds.length, currentSessionId: sessionId })
|
||||
try {
|
||||
const db = this.deps.adapter.openReadonly(sessionId)
|
||||
if (!db) {
|
||||
coverage.failedSessions++
|
||||
failedSessionIds.push(sessionId)
|
||||
continue
|
||||
}
|
||||
const descriptor = getSessionDescriptor(sessionId, db)
|
||||
if (!descriptor || descriptor.sessionType !== ChatType.PRIVATE) continue
|
||||
coverage.candidateSessions++
|
||||
coverage.scannedSessions++
|
||||
if (excludedSessionIds.has(sessionId)) {
|
||||
coverage.excludedSessions++
|
||||
continue
|
||||
}
|
||||
|
||||
const facts = getCrossChatSessionActivityFacts(db, range)
|
||||
dataEarliestMessageTs = minNullable(dataEarliestMessageTs, facts.dataEarliestMessageTs)
|
||||
dataLatestMessageTs = maxNullable(dataLatestMessageTs, facts.dataLatestMessageTs)
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta?.ownerId?.trim()) {
|
||||
coverage.missingOwnerSessions++
|
||||
continue
|
||||
}
|
||||
const owner = resolveOwnerMember(db)
|
||||
if (!owner) {
|
||||
coverage.unresolvedOwnerSessions++
|
||||
continue
|
||||
}
|
||||
const candidates = getNonSystemMembersForContacts(db).filter((member) => member.id !== owner.id)
|
||||
if (candidates.length === 0) {
|
||||
coverage.missingContactSessions++
|
||||
continue
|
||||
}
|
||||
const activeMemberIds = new Set(
|
||||
facts.members.filter((member) => member.messageCount > 0).map((member) => member.memberId)
|
||||
)
|
||||
const activeCandidates = candidates.filter((candidate) => activeMemberIds.has(candidate.id))
|
||||
const resolvedCandidates = activeCandidates.length > 0 ? activeCandidates : candidates
|
||||
if (resolvedCandidates.length > 1) {
|
||||
coverage.ambiguousContactSessions++
|
||||
continue
|
||||
}
|
||||
|
||||
coverage.analyzedSessions++
|
||||
if (facts.totalMessages === 0) continue
|
||||
const contact = resolvedCandidates[0]
|
||||
const contactKey = buildContactKey(
|
||||
descriptor.platform,
|
||||
contact.platformId,
|
||||
shouldScopeContactToSession(descriptor.platform, contact) ? sessionId : undefined
|
||||
)
|
||||
const ownerActivity = facts.members.find((member) => member.memberId === owner.id)
|
||||
const contactActivity = facts.members.find((member) => member.memberId === contact.id)
|
||||
const accumulator = accumulators.get(contactKey) ?? {
|
||||
contactKey,
|
||||
displayName: contact.name,
|
||||
platform: descriptor.platform,
|
||||
totalMessages: 0,
|
||||
ownerMessages: 0,
|
||||
contactMessages: 0,
|
||||
activeDayKeys: new Set<string>(),
|
||||
firstMessageTs: null,
|
||||
lastMessageTs: null,
|
||||
sessionIds: [],
|
||||
}
|
||||
accumulator.totalMessages += facts.totalMessages
|
||||
accumulator.ownerMessages += ownerActivity?.messageCount ?? 0
|
||||
accumulator.contactMessages += contactActivity?.messageCount ?? 0
|
||||
facts.activeDayKeys.forEach((day) => accumulator.activeDayKeys.add(day))
|
||||
accumulator.firstMessageTs = minNullable(accumulator.firstMessageTs, facts.firstMessageTs)
|
||||
accumulator.lastMessageTs = maxNullable(accumulator.lastMessageTs, facts.lastMessageTs)
|
||||
accumulator.sessionIds.push(sessionId)
|
||||
accumulators.set(contactKey, accumulator)
|
||||
} catch (error) {
|
||||
coverage.failedSessions++
|
||||
failedSessionIds.push(sessionId)
|
||||
appLogger.warn('cross-chat-analysis', `failed to rank private session: ${sessionId}`, error)
|
||||
} finally {
|
||||
processedSessions = index + 1
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
options.onProgress?.({ processedSessions, totalSessions: sessionIds.length })
|
||||
|
||||
coverage.complete =
|
||||
!coverage.truncated &&
|
||||
coverage.failedSessions === 0 &&
|
||||
coverage.missingOwnerSessions === 0 &&
|
||||
coverage.unresolvedOwnerSessions === 0 &&
|
||||
coverage.missingContactSessions === 0 &&
|
||||
coverage.ambiguousContactSessions === 0
|
||||
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 currentTs = Math.floor(this.now() / 1000)
|
||||
return {
|
||||
algorithmVersion: PRIVATE_CONTACTS_RANKING_ALGORITHM_VERSION,
|
||||
rankBy,
|
||||
appliedRange: {
|
||||
...range,
|
||||
dataEarliestMessageTs,
|
||||
dataLatestMessageTs,
|
||||
currentTs,
|
||||
},
|
||||
items,
|
||||
coverage,
|
||||
}
|
||||
}
|
||||
|
||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult {
|
||||
const db = this.deps.adapter.ensureReadonly(request.sessionId)
|
||||
const descriptor = getSessionDescriptor(request.sessionId, db)
|
||||
@@ -986,6 +1165,35 @@ interface SearchCandidate {
|
||||
memberIds?: number[]
|
||||
}
|
||||
|
||||
interface PrivateContactRankAccumulator {
|
||||
contactKey: string
|
||||
displayName: string
|
||||
platform: CrossChatPrivateContactRankItem['platform']
|
||||
totalMessages: number
|
||||
ownerMessages: number
|
||||
contactMessages: number
|
||||
activeDayKeys: Set<string>
|
||||
firstMessageTs: number | null
|
||||
lastMessageTs: number | null
|
||||
sessionIds: string[]
|
||||
}
|
||||
|
||||
function comparePrivateContactRankAccumulators(
|
||||
left: PrivateContactRankAccumulator,
|
||||
right: PrivateContactRankAccumulator,
|
||||
rankBy: CrossChatPrivateContactsRankingResult['rankBy']
|
||||
): number {
|
||||
const primaryDifference =
|
||||
rankBy === 'active_days'
|
||||
? right.activeDayKeys.size - left.activeDayKeys.size
|
||||
: right.totalMessages - left.totalMessages
|
||||
return (
|
||||
primaryDifference ||
|
||||
(right.lastMessageTs ?? Number.NEGATIVE_INFINITY) - (left.lastMessageTs ?? Number.NEGATIVE_INFINITY) ||
|
||||
left.contactKey.localeCompare(right.contactKey)
|
||||
)
|
||||
}
|
||||
|
||||
interface SessionSearchResult {
|
||||
candidate: SearchCandidate
|
||||
db: DatabaseAdapter
|
||||
|
||||
@@ -4,6 +4,10 @@ export type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOperationOptions,
|
||||
CrossChatPrivateContactRankItem,
|
||||
CrossChatPrivateContactsRankBy,
|
||||
CrossChatPrivateContactsRankingRequest,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatOverviewItem,
|
||||
CrossChatOverviewRequest,
|
||||
CrossChatOverviewResult,
|
||||
|
||||
@@ -63,6 +63,10 @@ export type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOperationOptions,
|
||||
CrossChatPrivateContactRankItem,
|
||||
CrossChatPrivateContactsRankBy,
|
||||
CrossChatPrivateContactsRankingRequest,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatOverviewItem,
|
||||
CrossChatOverviewRequest,
|
||||
CrossChatOverviewResult,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOverviewResult,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatRecentSessionResult,
|
||||
CrossChatSearchResult,
|
||||
CrossChatSharedInteractionsResult,
|
||||
@@ -128,6 +129,50 @@ function recentSessionResult(): CrossChatRecentSessionResult {
|
||||
}
|
||||
}
|
||||
|
||||
function privateContactsRankingResult(): CrossChatPrivateContactsRankingResult {
|
||||
return {
|
||||
algorithmVersion: 'test',
|
||||
rankBy: 'message_count',
|
||||
appliedRange: {
|
||||
startTs: null,
|
||||
endTs: null,
|
||||
dataEarliestMessageTs: 100,
|
||||
dataLatestMessageTs: 200,
|
||||
currentTs: 300,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
rank: 1,
|
||||
contactKey: 'test:alice',
|
||||
displayName: 'Alice',
|
||||
platform: 'test',
|
||||
totalMessages: 20,
|
||||
ownerMessages: 9,
|
||||
contactMessages: 11,
|
||||
activeDays: 3,
|
||||
firstMessageTs: 100,
|
||||
lastMessageTs: 200,
|
||||
sessionIds: ['private-alice'],
|
||||
},
|
||||
],
|
||||
coverage: {
|
||||
candidateSessions: 1,
|
||||
scannedSessions: 1,
|
||||
analyzedSessions: 1,
|
||||
excludedSessions: 0,
|
||||
missingOwnerSessions: 0,
|
||||
unresolvedOwnerSessions: 0,
|
||||
missingContactSessions: 0,
|
||||
ambiguousContactSessions: 0,
|
||||
failedSessions: 0,
|
||||
failedSessionIds: [],
|
||||
complete: true,
|
||||
truncated: false,
|
||||
truncatedReasons: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sharedInteractionsResult(anchorsTruncated: boolean): CrossChatSharedInteractionsResult {
|
||||
return {
|
||||
algorithmVersion: 'test',
|
||||
@@ -286,6 +331,7 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
|
||||
},
|
||||
}),
|
||||
readRecentSession: (): CrossChatRecentSessionResult => recentSessionResult(),
|
||||
rankPrivateContacts: async (): Promise<CrossChatPrivateContactsRankingResult> => privateContactsRankingResult(),
|
||||
searchMessages: async (): Promise<CrossChatSearchResult> => ({
|
||||
messages: [
|
||||
{
|
||||
@@ -350,7 +396,7 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
|
||||
}
|
||||
|
||||
describe('cross-chat agent registry', () => {
|
||||
it('contains only the seven dedicated tools and is isolated from session and MCP registries', () => {
|
||||
it('contains only the eight dedicated tools and is isolated from session and MCP registries', () => {
|
||||
const names = CROSS_CHAT_AGENT_TOOL_REGISTRY.map((tool) => tool.name)
|
||||
assert.deepEqual(names, [
|
||||
'resolve_chat_entities',
|
||||
@@ -358,6 +404,7 @@ describe('cross-chat agent registry', () => {
|
||||
'search_messages_globally',
|
||||
'get_cross_chat_message_context',
|
||||
'get_cross_chat_overview',
|
||||
'rank_private_contacts',
|
||||
'inspect_contact_sessions',
|
||||
'inspect_shared_interactions',
|
||||
])
|
||||
@@ -373,6 +420,34 @@ describe('cross-chat agent registry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('passes exact time and rank options to deterministic private-contact ranking', async () => {
|
||||
let captured: Parameters<CrossChatAnalysisToolService['rankPrivateContacts']>[0] | undefined
|
||||
const context = createContext({
|
||||
rankPrivateContacts: async (request) => {
|
||||
captured = request
|
||||
return privateContactsRankingResult()
|
||||
},
|
||||
})
|
||||
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'rank_private_contacts')
|
||||
assert.ok(tool)
|
||||
|
||||
const result = await tool.handler(
|
||||
{
|
||||
start_time: '2026-01-01 00:00',
|
||||
end_time: '2026-08-23 12:00',
|
||||
rank_by: 'active_days',
|
||||
limit: 5,
|
||||
},
|
||||
context
|
||||
)
|
||||
|
||||
assert.equal(captured?.rankBy, 'active_days')
|
||||
assert.equal(captured?.limit, 5)
|
||||
assert.equal(typeof captured?.startTs, 'number')
|
||||
assert.equal(typeof captured?.endTs, 'number')
|
||||
assert.deepEqual(result.data, privateContactsRankingResult())
|
||||
})
|
||||
|
||||
it('reads one resolved session through the bounded recap path and preserves evidence', async () => {
|
||||
let capturedSessionId: string | undefined
|
||||
const context = createContext({
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CrossChatEvidencePayload,
|
||||
CrossChatMessageSource,
|
||||
CrossChatParticipantRef,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatResolvedContactSession,
|
||||
CrossChatResolvedSession,
|
||||
CrossChatSearchScope,
|
||||
@@ -117,6 +118,20 @@ const overviewSchema: JsonSchema = {
|
||||
required: ['scopes'],
|
||||
}
|
||||
|
||||
const rankPrivateContactsSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
rank_by: {
|
||||
type: 'string',
|
||||
enum: ['message_count', 'active_days'],
|
||||
description: 'Ranking metric. Defaults to total private-chat message count.',
|
||||
},
|
||||
limit: { type: 'number', minimum: 1, maximum: 50, description: 'Number of ranked contacts to return' },
|
||||
recent_days: recentDaysProperty,
|
||||
...timeParamProperties,
|
||||
},
|
||||
}
|
||||
|
||||
const inspectContactSessionsSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -377,6 +392,32 @@ async function overviewHandler(
|
||||
return { content: JSON.stringify(data), data }
|
||||
}
|
||||
|
||||
async function rankPrivateContactsHandler(
|
||||
params: Record<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
const timeFilter = parseExtendedTimeParams(params)
|
||||
const result: CrossChatPrivateContactsRankingResult = await context.analysisService.rankPrivateContacts(
|
||||
{
|
||||
startTs: timeFilter?.startTs,
|
||||
endTs: timeFilter?.endTs,
|
||||
recentDays: parseOptionalNumber(params.recent_days),
|
||||
rankBy: params.rank_by === 'active_days' ? 'active_days' : 'message_count',
|
||||
limit: parseOptionalNumber(params.limit),
|
||||
},
|
||||
{
|
||||
signal: context.abortSignal,
|
||||
onProgress: (progress) =>
|
||||
context.reportProgress?.({
|
||||
phase: 'analyzing',
|
||||
current: progress.processedSessions,
|
||||
total: progress.totalSessions,
|
||||
}),
|
||||
}
|
||||
)
|
||||
return { content: JSON.stringify(result), data: result }
|
||||
}
|
||||
|
||||
async function inspectContactSessionsHandler(
|
||||
params: Record<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
@@ -514,6 +555,15 @@ export const getCrossChatOverviewTool: ToolDefinition<CrossChatToolExecutionCont
|
||||
category: 'core',
|
||||
}
|
||||
|
||||
export const rankPrivateContactsTool: ToolDefinition<CrossChatToolExecutionContext> = {
|
||||
name: 'rank_private_contacts',
|
||||
description:
|
||||
'Deterministically rank private-chat contacts across all eligible imported sessions for an exact time range. Use this for questions such as who the user chatted with most or had the most active private-chat days. This tool counts non-system messages and never uses keyword-search hits as a ranking proxy. Results include owner/contact splits, dataset cutoff, identity coverage, and completeness.',
|
||||
inputSchema: rankPrivateContactsSchema,
|
||||
handler: rankPrivateContactsHandler,
|
||||
category: 'core',
|
||||
}
|
||||
|
||||
export const inspectContactSessionsTool: ToolDefinition<CrossChatToolExecutionContext> = {
|
||||
name: 'inspect_contact_sessions',
|
||||
description:
|
||||
|
||||
@@ -45,6 +45,7 @@ export { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence'
|
||||
export {
|
||||
getCrossChatMessageContextTool,
|
||||
getCrossChatOverviewTool,
|
||||
rankPrivateContactsTool,
|
||||
readRecentSessionTool,
|
||||
resolveChatEntitiesTool,
|
||||
searchMessagesGloballyTool,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getCrossChatOverviewTool,
|
||||
inspectContactSessionsTool,
|
||||
inspectSharedInteractionsTool,
|
||||
rankPrivateContactsTool,
|
||||
readRecentSessionTool,
|
||||
resolveChatEntitiesTool,
|
||||
searchMessagesGloballyTool,
|
||||
@@ -91,6 +92,7 @@ export const CROSS_CHAT_AGENT_TOOL_REGISTRY = [
|
||||
searchMessagesGloballyTool,
|
||||
getCrossChatMessageContextTool,
|
||||
getCrossChatOverviewTool,
|
||||
rankPrivateContactsTool,
|
||||
inspectContactSessionsTool,
|
||||
inspectSharedInteractionsTool,
|
||||
]
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOperationOptions,
|
||||
CrossChatPrivateContactsRankingRequest,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
CrossChatOverviewRequest,
|
||||
CrossChatOverviewResult,
|
||||
CrossChatRecentSessionResult,
|
||||
@@ -372,6 +374,10 @@ export interface CrossChatAnalysisToolService {
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatSharedInteractionsResult>
|
||||
readRecentSession(sessionId: string): CrossChatRecentSessionResult
|
||||
rankPrivateContacts(
|
||||
request: CrossChatPrivateContactsRankingRequest,
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatPrivateContactsRankingResult>
|
||||
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
||||
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
||||
|
||||
Reference in new issue
Block a user