mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): expose cached global activity summaries
This commit is contained in:
1 parent
dff4f4dfa8
commit
207c41afaa
17 files changed
+313
-6
No files matched your search
@@ -26,6 +26,7 @@ describe('cross-chat agent prompt', () => {
|
||||
'inspect_shared_interactions',
|
||||
'rank_private_contacts',
|
||||
'rank_group_sessions',
|
||||
'get_global_activity_summary',
|
||||
'search_messages_globally',
|
||||
'get_cross_chat_message_context',
|
||||
'get_cross_chat_overview',
|
||||
@@ -43,6 +44,8 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.match(prompt, /mode=owner_activity/)
|
||||
assert.match(prompt, /mode=total_activity/)
|
||||
assert.match(prompt, /两种口径不能互换/)
|
||||
assert.match(prompt, /本人总发言量、活跃天数、私聊联系人数/)
|
||||
assert.match(prompt, /preparing.*不是零数据/)
|
||||
assert.match(prompt, /get_cross_chat_overview.*明确时间范围/)
|
||||
assert.match(prompt, /不负责发现或全局排名/)
|
||||
assert.match(prompt, /所有参与者都出现.*严格交集/)
|
||||
@@ -71,7 +74,7 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.doesNotMatch(prompt, /可以使用.*execute_sql/)
|
||||
})
|
||||
|
||||
it('keeps the English prompt on the same nine-tool investigation contract', () => {
|
||||
it('keeps the English prompt on the same ten-tool investigation contract', () => {
|
||||
const prompt = buildCrossChatSystemPrompt('en-US', new Date('2026-08-23T12:00:00Z'))
|
||||
for (const tool of [
|
||||
'resolve_chat_entities',
|
||||
@@ -80,6 +83,7 @@ describe('cross-chat agent prompt', () => {
|
||||
'inspect_shared_interactions',
|
||||
'rank_private_contacts',
|
||||
'rank_group_sessions',
|
||||
'get_global_activity_summary',
|
||||
'search_messages_globally',
|
||||
'get_cross_chat_message_context',
|
||||
'get_cross_chat_overview',
|
||||
@@ -93,6 +97,8 @@ describe('cross-chat agent prompt', () => {
|
||||
assert.match(prompt, /mode=owner_activity/)
|
||||
assert.match(prompt, /mode=total_activity/)
|
||||
assert.match(prompt, /Never swap the two metrics/)
|
||||
assert.match(prompt, /total sent messages, active days, direct-contact count/)
|
||||
assert.match(prompt, /preparing means.*still being built/i)
|
||||
assert.match(prompt, /get_cross_chat_overview with the exact requested time range/)
|
||||
assert.match(prompt, /does not discover or rank sessions/)
|
||||
assert.match(prompt, /investigation signals, not proof/)
|
||||
|
||||
@@ -224,18 +224,20 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()):
|
||||
- 用户询问 2—5 人的关系、相识背景、共同圈子或互动变化时,先用 inspect_shared_interactions 找全员共同会话、逐人活跃、直接回复、相邻发言和消息锚点;不要为了多人问题机械地先对每个人调用 inspect_contact_sessions。
|
||||
- 用户询问“我和谁私聊最多”“私聊频率排行”或“和哪些联系人聊天的活跃天数最多”时,直接调用 rank_private_contacts,并按用户给出的自然年、日期或 recent_days 统计。不要把“我”解析成联系人,也不要用 search_messages_globally 的关键词命中量、抽样消息或 Contacts 综合分数代替全局排行。
|
||||
- 用户询问“我在哪些群最活跃”时调用 rank_group_sessions(mode=owner_activity);询问“哪些群最热闹、总消息最多”时调用 mode=total_activity。两种口径不能互换;句子仍无法判断是在问本人还是群整体时,先用一句话确认。
|
||||
- 用户询问自然年或最近 365 天内本人总发言量、活跃天数、私聊联系人数、月度/每日趋势、消息类型或文本长度时,直接调用 get_global_activity_summary。它不提供联系人或群聊排行;用户询问最近 30 天等其他总体窗口时,明确说明首版只支持自然年和最近 365 天,不要用关键词搜索估算总量。
|
||||
- 三人及以上的 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、rank_private_contacts、rank_group_sessions、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、rank_group_sessions、get_global_activity_summary、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 截断都必须明确披露。该工具不返回聊天正文,用户继续追问“为什么”时再解析对应联系人并读取必要原文。
|
||||
- rank_group_sessions 的 owner_activity 只按本人消息量排序,缺少或无法解析“我”的群是 coverage 缺口;total_activity 按群总消息量排序,不要求 owner 可解析。两个 mode 都默认排除用户标记“当前对话中没有我”的会话,并且只有 coverage.complete=true 时才能称为完整全局排行。
|
||||
- get_global_activity_summary 复用本地年度概览快照,不读取聊天正文。dataState=fresh 可直接使用;stale 可使用但必须披露 cache.computedAt,并根据 task.status 说明后台正在更新或刷新失败;preparing 表示首次统计仍在后台生成,failed 表示生成失败,两者都不是零数据。不要在同一次回答中轮询 preparing 状态。
|
||||
- 没有直接回复或原文证据时,只能确认共同来源或同场活跃,并明确具体关系仍不确定。只有 coverage 完整且时间范围明确,才允许给出“没有发生过”或“没有互动”的强负面判断;partial/skipped_budget 不是零。
|
||||
- search_messages_globally 提供关键词时是字面 LIKE 检索,不是语义搜索。用户问“聊过什么”等开放问题时,先限定联系人或群聊 scopes,再无关键词抽取指定时间范围内的消息;工具自行控制证据量,优先返回私聊证据,剩余容量按目标人物在群聊中的匹配活跃度分配,并自动附带命中消息周围的上下文。不要传入消息数量、会话数量或执行时长预算,也不要对全部会话做无关键词扫描。
|
||||
- 比较已经解析出的联系人或群聊时,用 get_cross_chat_overview 传入与问题一致的明确时间范围,读取精确消息数、活跃天数、成员活动与 owner 覆盖;它不负责发现或全局排名。需要解释差异时,再检索有来源的原文证据;需要理解命中消息时,用 session_id + message_id 获取上下文。
|
||||
@@ -243,7 +245,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()):
|
||||
- 涉及单个联系人的限时搜索返回 0 条时,不要只说“什么都没有”。必须从 resolve_chat_entities 返回的私聊 session 元数据中说明最近一条已导入私聊的具体时间,以及它是否落在查询窗口之外;没有私聊 session 或没有可用时间时也要明确说明。之后再询问用户是否扩大范围。
|
||||
- sender=owner 时还要检查 coverage.ownerResolution;存在未设置或无法解析“我”的会话时,必须说明对应覆盖缺口,禁止通过昵称猜测本人。
|
||||
- 引用证据时说明来源会话;不要泄露工具未返回的信息,也不要编造联系人、群聊或消息。
|
||||
- 如果问题不需要聊天数据,直接回答;如果现有九个工具不足,坦诚说明当前能力边界。
|
||||
- 如果问题不需要聊天数据,直接回答;如果现有十个工具不足,坦诚说明当前能力边界。
|
||||
|
||||
你可以使用工具。如果需要你没有的信息,请调用提供的函数。`
|
||||
}
|
||||
@@ -261,18 +263,20 @@ Data and scope rules:
|
||||
- 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.
|
||||
- Call rank_group_sessions with mode=owner_activity for “which groups was I most active in” and mode=total_activity for “which groups were busiest or had the most messages.” Never swap the two metrics. If the wording still does not establish whether the subject is the user or the whole group, ask one concise clarification.
|
||||
- For the user's total sent messages, active days, direct-contact count, monthly or daily trends, message types, or text-length distribution in a calendar year or the rolling latest 365 days, call get_global_activity_summary directly. It does not rank contacts or groups. For unsupported overall windows such as the last 30 days, state that this first version supports only calendar years and the latest 365 days; never estimate totals from keyword-search hits.
|
||||
- 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, rank_private_contacts, rank_group_sessions, 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, rank_group_sessions, get_global_activity_summary, 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.
|
||||
- rank_group_sessions owner_activity ranks only by the user's own message count and treats missing or unresolved owners as coverage gaps. total_activity ranks by total group messages and does not require owner resolution. Both modes exclude sessions marked as not containing the user, and only coverage.complete=true supports an exhaustive global-ranking claim.
|
||||
- get_global_activity_summary reuses the local annual-insight snapshot and never reads message text. fresh data is ready; stale data is usable only with cache.computedAt disclosed, while task.status says whether refresh is running or failed. preparing means the first snapshot is still being built, and failed means generation failed; neither state means zero activity. Never poll a preparing result within the same answer.
|
||||
- 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 across already resolved contacts or sessions, call get_cross_chat_overview with the exact requested time range and use its deterministic message counts, active days, member activity, and owner coverage. It does not discover or rank sessions. Retrieve source-backed message evidence only when the difference needs explanation, and expand a hit with session_id plus message_id when context is needed.
|
||||
@@ -280,7 +284,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 nine tools are insufficient, explain the current limitation honestly.
|
||||
- Answer directly when no chat data is needed. If the ten 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.`
|
||||
}
|
||||
@@ -542,6 +542,10 @@ export type {
|
||||
CrossChatGroupSessionRankItem,
|
||||
CrossChatGroupSessionsRankingRequest,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivityDataState,
|
||||
CrossChatGlobalActivitySummaryMode,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextRequest,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
|
||||
@@ -6,6 +6,10 @@ export type {
|
||||
CrossChatGroupSessionRankItem,
|
||||
CrossChatGroupSessionsRankingRequest,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivityDataState,
|
||||
CrossChatGlobalActivitySummaryMode,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextRequest,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CHAT_DB_SCHEMA, generateSessionIndex } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import {
|
||||
ChatType,
|
||||
type AnnualSummaryResponse,
|
||||
type ContactDetailResponse,
|
||||
type ContactItem,
|
||||
type ContactsResponse,
|
||||
@@ -148,6 +149,48 @@ function detail(
|
||||
}
|
||||
}
|
||||
|
||||
function annualSummaryResponse(
|
||||
cacheStatus: AnnualSummaryResponse['cache']['status'],
|
||||
taskStatus: AnnualSummaryResponse['task']['status'] = 'idle'
|
||||
): AnnualSummaryResponse {
|
||||
return {
|
||||
range: { mode: 'year', year: 2026, startTs: 1, endTs: 2 },
|
||||
availableDataYears: cacheStatus === 'missing' ? [] : [2026],
|
||||
latestDataYear: cacheStatus === 'missing' ? null : 2026,
|
||||
metrics:
|
||||
cacheStatus === 'missing'
|
||||
? null
|
||||
: {
|
||||
sentMessageCount: 10,
|
||||
activeDayCount: 2,
|
||||
directContactCount: 1,
|
||||
averageMessagesPerDay: 5,
|
||||
averageDirectContactsPerDay: 0.5,
|
||||
},
|
||||
monthlyActivity: [],
|
||||
monthlyDirectContacts: [],
|
||||
dailyActivity: [],
|
||||
messageTypes: [],
|
||||
textLength: null,
|
||||
coverage: {
|
||||
totalSessions: 1,
|
||||
analyzedSessions: cacheStatus === 'missing' ? 0 : 1,
|
||||
missingOwnerSessions: 0,
|
||||
unresolvedOwnerSessions: 0,
|
||||
failedSessions: 0,
|
||||
},
|
||||
cache: { status: cacheStatus, computedAt: cacheStatus === 'missing' ? null : 100 },
|
||||
task: {
|
||||
id: taskStatus === 'idle' ? null : 'task-1',
|
||||
status: taskStatus,
|
||||
startedAt: taskStatus === 'idle' ? null : 90,
|
||||
finishedAt: taskStatus === 'failed' ? 100 : null,
|
||||
processedSessions: 0,
|
||||
totalSessions: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture(): {
|
||||
env: TestEnvironment
|
||||
contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'>
|
||||
@@ -509,6 +552,39 @@ test('keeps owner activity and total group activity as separate deterministic ra
|
||||
}
|
||||
})
|
||||
|
||||
test('maps global insight cache states without waiting for background computation', () => {
|
||||
const { env, contactsService } = createFixture()
|
||||
const calls: unknown[] = []
|
||||
let response = annualSummaryResponse('fresh')
|
||||
try {
|
||||
const service = createCrossChatAnalysisService({
|
||||
adapter: env.adapter,
|
||||
contactsService,
|
||||
globalInsightService: {
|
||||
getAnnualSummary: (options = {}) => {
|
||||
calls.push(options)
|
||||
return response
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(service.getGlobalActivitySummary({ mode: 'recent_365' }).dataState, 'fresh')
|
||||
assert.deepEqual(calls.at(-1), { mode: 'recent', days: 365, acceptStale: true })
|
||||
|
||||
response = annualSummaryResponse('stale', 'running')
|
||||
assert.equal(service.getGlobalActivitySummary({ mode: 'year', year: 2026 }).dataState, 'stale')
|
||||
assert.deepEqual(calls.at(-1), { mode: 'year', year: 2026, acceptStale: true })
|
||||
|
||||
response = annualSummaryResponse('missing', 'running')
|
||||
assert.equal(service.getGlobalActivitySummary({}).dataState, 'preparing')
|
||||
|
||||
response = annualSummaryResponse('missing', 'failed')
|
||||
assert.equal(service.getGlobalActivitySummary({}).dataState, 'failed')
|
||||
} 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> = []
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
type CrossChatGroupSessionRankItem,
|
||||
type CrossChatGroupSessionsRankingRequest,
|
||||
type CrossChatGroupSessionsRankingResult,
|
||||
type CrossChatGlobalActivitySummaryRequest,
|
||||
type CrossChatGlobalActivitySummaryResult,
|
||||
type CrossChatPrivateContactRankItem,
|
||||
type CrossChatPrivateContactsRankingRequest,
|
||||
type CrossChatPrivateContactsRankingResult,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
import { appLogger } from '../../logging/app-logger'
|
||||
import type { SessionRuntimeAdapter } from '../adapters'
|
||||
import type { ContactsService } from '../contacts'
|
||||
import type { GlobalInsightService } from '../global-insight'
|
||||
|
||||
const DEFAULT_MAX_SESSIONS = 24
|
||||
const MAX_MAX_SESSIONS = 100
|
||||
@@ -91,6 +94,7 @@ const OVERVIEW_TOP_MEMBERS_LIMIT = 5
|
||||
export interface CrossChatAnalysisServiceDeps {
|
||||
adapter: SessionRuntimeAdapter
|
||||
contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'>
|
||||
globalInsightService?: Pick<GlobalInsightService, 'getAnnualSummary'>
|
||||
getExcludedSessionIds?: () => readonly string[]
|
||||
now?: () => number
|
||||
}
|
||||
@@ -115,6 +119,7 @@ export interface CrossChatAnalysisService {
|
||||
request: CrossChatGroupSessionsRankingRequest,
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatGroupSessionsRankingResult>
|
||||
getGlobalActivitySummary(request: CrossChatGlobalActivitySummaryRequest): CrossChatGlobalActivitySummaryResult
|
||||
searchMessages(request: CrossChatSearchRequest, options?: CrossChatOperationOptions): Promise<CrossChatSearchResult>
|
||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult
|
||||
getOverview(request: CrossChatOverviewRequest, options?: CrossChatOperationOptions): Promise<CrossChatOverviewResult>
|
||||
@@ -1115,6 +1120,32 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
getGlobalActivitySummary(request: CrossChatGlobalActivitySummaryRequest): CrossChatGlobalActivitySummaryResult {
|
||||
const service = this.deps.globalInsightService
|
||||
if (!service) throw new Error('Global activity summary is unavailable in this runtime')
|
||||
const mode = request.mode ?? 'year'
|
||||
if (mode !== 'year' && mode !== 'recent_365') {
|
||||
throw new Error('mode must be year or recent_365')
|
||||
}
|
||||
if (request.year !== undefined && (!Number.isInteger(request.year) || request.year < 1970)) {
|
||||
throw new Error('year must be an integer greater than or equal to 1970')
|
||||
}
|
||||
const summary = service.getAnnualSummary(
|
||||
mode === 'recent_365'
|
||||
? { mode: 'recent', days: 365, acceptStale: true }
|
||||
: { mode: 'year', year: request.year, acceptStale: true }
|
||||
)
|
||||
const dataState: CrossChatGlobalActivitySummaryResult['dataState'] =
|
||||
summary.cache.status === 'fresh'
|
||||
? 'fresh'
|
||||
: summary.cache.status === 'stale'
|
||||
? 'stale'
|
||||
: summary.task.status === 'failed'
|
||||
? 'failed'
|
||||
: 'preparing'
|
||||
return { mode, dataState, summary }
|
||||
}
|
||||
|
||||
getMessageContext(request: CrossChatMessageContextRequest): CrossChatMessageContextResult {
|
||||
const db = this.deps.adapter.ensureReadonly(request.sessionId)
|
||||
const descriptor = getSessionDescriptor(request.sessionId, db)
|
||||
|
||||
@@ -3,6 +3,10 @@ export type {
|
||||
CrossChatGroupSessionRankItem,
|
||||
CrossChatGroupSessionsRankingRequest,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivityDataState,
|
||||
CrossChatGlobalActivitySummaryMode,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextRequest,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
|
||||
@@ -62,6 +62,10 @@ export type {
|
||||
CrossChatGroupSessionRankItem,
|
||||
CrossChatGroupSessionsRankingRequest,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivityDataState,
|
||||
CrossChatGlobalActivitySummaryMode,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextRequest,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
|
||||
@@ -825,6 +825,20 @@ export interface CrossChatGroupSessionsRankingResult {
|
||||
}
|
||||
}
|
||||
|
||||
export type CrossChatGlobalActivitySummaryMode = 'year' | 'recent_365'
|
||||
export type CrossChatGlobalActivityDataState = 'fresh' | 'stale' | 'preparing' | 'failed'
|
||||
|
||||
export interface CrossChatGlobalActivitySummaryRequest {
|
||||
mode?: CrossChatGlobalActivitySummaryMode
|
||||
year?: number
|
||||
}
|
||||
|
||||
export interface CrossChatGlobalActivitySummaryResult {
|
||||
mode: CrossChatGlobalActivitySummaryMode
|
||||
dataState: CrossChatGlobalActivityDataState
|
||||
summary: AnnualSummaryResponse
|
||||
}
|
||||
|
||||
export type CrossChatParticipantRef =
|
||||
| { type: 'owner' }
|
||||
| {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CrossChatContactSessionsResult,
|
||||
CrossChatEntityResolution,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatOverviewResult,
|
||||
@@ -219,6 +220,46 @@ function groupSessionsRankingResult(): CrossChatGroupSessionsRankingResult {
|
||||
}
|
||||
}
|
||||
|
||||
function globalActivitySummaryResult(dataState: CrossChatGlobalActivitySummaryResult['dataState'] = 'fresh') {
|
||||
return {
|
||||
mode: 'year',
|
||||
dataState,
|
||||
summary: {
|
||||
range: { mode: 'year', year: 2026, startTs: 1, endTs: 2 },
|
||||
availableDataYears: [2026],
|
||||
latestDataYear: 2026,
|
||||
metrics: {
|
||||
sentMessageCount: 100,
|
||||
activeDayCount: 10,
|
||||
directContactCount: 5,
|
||||
averageMessagesPerDay: 10,
|
||||
averageDirectContactsPerDay: 1,
|
||||
},
|
||||
monthlyActivity: [{ month: '2026-01', messageCount: 100 }],
|
||||
monthlyDirectContacts: [{ month: '2026-01', contactCount: 5 }],
|
||||
dailyActivity: [{ date: '2026-01-01', messageCount: 10 }],
|
||||
messageTypes: [{ type: 0, count: 100 }],
|
||||
textLength: { textMessageCount: 100, median: 5, p90: 20, buckets: [] },
|
||||
coverage: {
|
||||
totalSessions: 2,
|
||||
analyzedSessions: 2,
|
||||
missingOwnerSessions: 0,
|
||||
unresolvedOwnerSessions: 0,
|
||||
failedSessions: 0,
|
||||
},
|
||||
cache: { status: dataState === 'stale' ? 'stale' : 'fresh', computedAt: 300, signature: 'secret' },
|
||||
task: {
|
||||
id: null,
|
||||
status: 'idle',
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
processedSessions: 0,
|
||||
totalSessions: 0,
|
||||
},
|
||||
},
|
||||
} satisfies CrossChatGlobalActivitySummaryResult
|
||||
}
|
||||
|
||||
function sharedInteractionsResult(anchorsTruncated: boolean): CrossChatSharedInteractionsResult {
|
||||
return {
|
||||
algorithmVersion: 'test',
|
||||
@@ -379,6 +420,7 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
|
||||
readRecentSession: (): CrossChatRecentSessionResult => recentSessionResult(),
|
||||
rankPrivateContacts: async (): Promise<CrossChatPrivateContactsRankingResult> => privateContactsRankingResult(),
|
||||
rankGroupSessions: async (): Promise<CrossChatGroupSessionsRankingResult> => groupSessionsRankingResult(),
|
||||
getGlobalActivitySummary: (): CrossChatGlobalActivitySummaryResult => globalActivitySummaryResult(),
|
||||
searchMessages: async (): Promise<CrossChatSearchResult> => ({
|
||||
messages: [
|
||||
{
|
||||
@@ -455,7 +497,7 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
|
||||
}
|
||||
|
||||
describe('cross-chat agent registry', () => {
|
||||
it('contains only the nine dedicated tools and is isolated from session and MCP registries', () => {
|
||||
it('contains only the ten 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',
|
||||
@@ -465,6 +507,7 @@ describe('cross-chat agent registry', () => {
|
||||
'get_cross_chat_overview',
|
||||
'rank_private_contacts',
|
||||
'rank_group_sessions',
|
||||
'get_global_activity_summary',
|
||||
'inspect_contact_sessions',
|
||||
'inspect_shared_interactions',
|
||||
])
|
||||
@@ -586,6 +629,28 @@ describe('cross-chat agent registry', () => {
|
||||
await assert.rejects(() => tool.handler({ mode: 'unknown' }, context), /mode must be/)
|
||||
})
|
||||
|
||||
it('reads the cached global activity summary without exposing cache signatures', async () => {
|
||||
let captured: Parameters<CrossChatAnalysisToolService['getGlobalActivitySummary']>[0] | undefined
|
||||
const context = createContext({
|
||||
getGlobalActivitySummary: (request) => {
|
||||
captured = request
|
||||
return globalActivitySummaryResult('stale')
|
||||
},
|
||||
})
|
||||
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: 2026 }, context)
|
||||
const content = JSON.parse(result.content)
|
||||
|
||||
assert.deepEqual(captured, { mode: 'year', year: 2026 })
|
||||
assert.equal(content.dataState, 'stale')
|
||||
assert.equal(content.metrics.sentMessageCount, 100)
|
||||
assert.equal(content.cache.computedAt, 300)
|
||||
assert.equal(content.cache.signature, undefined)
|
||||
assert.deepEqual(result.data, globalActivitySummaryResult('stale'))
|
||||
})
|
||||
|
||||
it('reads one resolved session through the bounded recap path and preserves evidence', async () => {
|
||||
let capturedSessionId: string | undefined
|
||||
const context = createContext({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CrossChatEntityResolution,
|
||||
CrossChatEvidencePayload,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageSource,
|
||||
CrossChatParticipantRef,
|
||||
CrossChatPrivateContactsRankingResult,
|
||||
@@ -149,6 +150,22 @@ const rankGroupSessionsSchema: JsonSchema = {
|
||||
required: ['mode'],
|
||||
}
|
||||
|
||||
const globalActivitySummarySchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['year', 'recent_365'],
|
||||
description: 'Use year for a calendar year and recent_365 for the rolling latest 365 days. Defaults to year.',
|
||||
},
|
||||
year: {
|
||||
type: 'number',
|
||||
minimum: 1970,
|
||||
description: 'Calendar year for mode=year. Omit it to use the current year.',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const inspectContactSessionsSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -468,6 +485,46 @@ async function rankGroupSessionsHandler(
|
||||
return { content: JSON.stringify(result), data: result }
|
||||
}
|
||||
|
||||
async function globalActivitySummaryHandler(
|
||||
params: Record<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
): Promise<ToolResult> {
|
||||
const mode = params.mode ?? 'year'
|
||||
if (mode !== 'year' && mode !== 'recent_365') throw new Error('mode must be year or recent_365')
|
||||
const result: CrossChatGlobalActivitySummaryResult = context.analysisService.getGlobalActivitySummary({
|
||||
mode,
|
||||
year: parseOptionalNumber(params.year),
|
||||
})
|
||||
const summary = result.summary
|
||||
const modelData = {
|
||||
mode: result.mode,
|
||||
dataState: result.dataState,
|
||||
range: summary.range,
|
||||
availableDataYears: summary.availableDataYears,
|
||||
latestDataYear: summary.latestDataYear,
|
||||
metrics: summary.metrics,
|
||||
monthlyActivity: summary.monthlyActivity,
|
||||
monthlyDirectContacts: summary.monthlyDirectContacts,
|
||||
dailyActivity: summary.dailyActivity,
|
||||
messageTypes: summary.messageTypes,
|
||||
textLength: summary.textLength,
|
||||
coverage: summary.coverage,
|
||||
cache: {
|
||||
status: summary.cache.status,
|
||||
computedAt: summary.cache.computedAt,
|
||||
staleReason: summary.cache.staleReason,
|
||||
},
|
||||
task: {
|
||||
status: summary.task.status,
|
||||
startedAt: summary.task.startedAt,
|
||||
finishedAt: summary.task.finishedAt,
|
||||
processedSessions: summary.task.processedSessions,
|
||||
totalSessions: summary.task.totalSessions,
|
||||
},
|
||||
}
|
||||
return { content: JSON.stringify(modelData), data: result }
|
||||
}
|
||||
|
||||
async function inspectContactSessionsHandler(
|
||||
params: Record<string, unknown>,
|
||||
context: CrossChatToolExecutionContext
|
||||
@@ -623,6 +680,15 @@ export const rankGroupSessionsTool: ToolDefinition<CrossChatToolExecutionContext
|
||||
category: 'core',
|
||||
}
|
||||
|
||||
export const getGlobalActivitySummaryTool: ToolDefinition<CrossChatToolExecutionContext> = {
|
||||
name: 'get_global_activity_summary',
|
||||
description:
|
||||
'Read the existing deterministic owner activity snapshot for the current calendar year, another calendar year, or the rolling latest 365 days. Use it for total sent messages, active days, direct-contact counts, monthly/daily trends, message types, and text-length distribution. It never reads chat text or returns contact/group rankings. fresh and stale states contain usable metrics; preparing and failed do not and must not be described as zero activity. Do not poll a preparing result in the same answer.',
|
||||
inputSchema: globalActivitySummarySchema,
|
||||
handler: globalActivitySummaryHandler,
|
||||
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,
|
||||
getGlobalActivitySummaryTool,
|
||||
rankGroupSessionsTool,
|
||||
rankPrivateContactsTool,
|
||||
readRecentSessionTool,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence'
|
||||
import {
|
||||
getCrossChatMessageContextTool,
|
||||
getCrossChatOverviewTool,
|
||||
getGlobalActivitySummaryTool,
|
||||
inspectContactSessionsTool,
|
||||
inspectSharedInteractionsTool,
|
||||
rankGroupSessionsTool,
|
||||
@@ -95,6 +96,7 @@ export const CROSS_CHAT_AGENT_TOOL_REGISTRY = [
|
||||
getCrossChatOverviewTool,
|
||||
rankPrivateContactsTool,
|
||||
rankGroupSessionsTool,
|
||||
getGlobalActivitySummaryTool,
|
||||
inspectContactSessionsTool,
|
||||
inspectSharedInteractionsTool,
|
||||
]
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
CrossChatEntityResolution,
|
||||
CrossChatGroupSessionsRankingRequest,
|
||||
CrossChatGroupSessionsRankingResult,
|
||||
CrossChatGlobalActivitySummaryRequest,
|
||||
CrossChatGlobalActivitySummaryResult,
|
||||
CrossChatMessageContextRequest,
|
||||
CrossChatMessageContextResult,
|
||||
CrossChatMessageSource,
|
||||
@@ -384,6 +386,7 @@ export interface CrossChatAnalysisToolService {
|
||||
request: CrossChatGroupSessionsRankingRequest,
|
||||
options?: CrossChatOperationOptions
|
||||
): Promise<CrossChatGroupSessionsRankingResult>
|
||||
getGlobalActivitySummary(request: CrossChatGlobalActivitySummaryRequest): CrossChatGlobalActivitySummaryResult
|
||||
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