From 3b50a722c9891211b5f50761a7e916924c71fc75 Mon Sep 17 00:00:00 2001 From: digua Date: Sun, 23 Aug 2026 21:14:33 +0800 Subject: [PATCH] feat(ai): add cross-chat statistics facts --- .../__tests__/cross-chat-statistics.test.ts | 172 ++++++++++++++++++ packages/core/src/query/contact-queries.ts | 2 +- .../core/src/query/cross-chat-statistics.ts | 116 ++++++++++++ packages/core/src/query/index.ts | 7 + packages/shared-types/index.ts | 93 ++++++++++ 5 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/query/__tests__/cross-chat-statistics.test.ts create mode 100644 packages/core/src/query/cross-chat-statistics.ts diff --git a/packages/core/src/query/__tests__/cross-chat-statistics.test.ts b/packages/core/src/query/__tests__/cross-chat-statistics.test.ts new file mode 100644 index 00000000..e1ed64d0 --- /dev/null +++ b/packages/core/src/query/__tests__/cross-chat-statistics.test.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import { MessageType } from '@openchatlab/shared-types' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' +import { getCrossChatSessionActivityFacts } from '../cross-chat-statistics' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +class StatementAdapter implements PreparedStatement { + readonly?: boolean + + constructor(private readonly statement: Database.Statement) { + this.readonly = statement.readonly + } + + get(...params: unknown[]) { + return this.statement.get(...params) as Record | undefined + } + + all(...params: unknown[]) { + return this.statement.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.statement.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(readonly raw: Database.Database) {} + + exec(sql: string) { + this.raw.exec(sql) + } + + prepare(sql: string) { + return new StatementAdapter(this.raw.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.raw.transaction(fn)() + } + + pragma(query: string) { + return this.raw.pragma(query) + } + + close() { + this.raw.close() + } +} + +function createSession(): Adapter { + const raw = new Database(':memory:', { nativeBinding }) + raw.exec(` + CREATE TABLE meta (name TEXT, platform TEXT, type TEXT, imported_at INTEGER, owner_id TEXT); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT, + account_name TEXT, + group_nickname TEXT, + aliases TEXT DEFAULT '[]', + avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER, + ts INTEGER, + type INTEGER, + content TEXT, + platform_message_id TEXT, + reply_to_message_id TEXT + ); + INSERT INTO meta VALUES ('Test Group', 'wechat', 'group', 1700000000, 'owner'); + INSERT INTO member VALUES + (1, 'owner', 'Me', NULL, '[]', NULL), + (2, 'alice', 'Alice', 'Alice G', '[]', NULL), + (3, 'bob', 'Bob', NULL, '[]', NULL), + (4, 'system', 'System', NULL, '[]', NULL); + `) + return new Adapter(raw) +} + +function localTs(year: number, month: number, day: number, hour = 12): number { + return Math.floor(new Date(year, month - 1, day, hour).getTime() / 1000) +} + +function addMessage(db: Adapter, id: number, senderId: number, ts: number, type: MessageType = MessageType.TEXT): void { + db.raw + .prepare('INSERT INTO message VALUES (?, ?, ?, ?, ?, ?, NULL)') + .run(id, senderId, ts, type, `message-${id}`, `platform-${id}`) +} + +test('returns bounded session and per-member activity without system messages', () => { + const db = createSession() + try { + addMessage(db, 1, 1, localTs(2023, 12, 31)) + addMessage(db, 2, 1, localTs(2024, 1, 1, 8)) + addMessage(db, 3, 2, localTs(2024, 1, 1, 9), MessageType.IMAGE) + addMessage(db, 4, 1, localTs(2024, 1, 2, 10)) + addMessage(db, 5, 3, localTs(2024, 1, 2, 11)) + addMessage(db, 6, 2, localTs(2024, 1, 2, 12), MessageType.SYSTEM) + addMessage(db, 7, 4, localTs(2024, 1, 2, 13)) + addMessage(db, 8, 2, localTs(2024, 1, 3, 0)) + + const facts = getCrossChatSessionActivityFacts(db, { + startTs: localTs(2024, 1, 1, 0), + endTs: localTs(2024, 1, 2, 23), + }) + + assert.deepEqual( + { + totalMessages: facts.totalMessages, + activeDays: facts.activeDays, + activeMembers: facts.activeMembers, + firstMessageTs: facts.firstMessageTs, + lastMessageTs: facts.lastMessageTs, + dataEarliestMessageTs: facts.dataEarliestMessageTs, + dataLatestMessageTs: facts.dataLatestMessageTs, + }, + { + totalMessages: 4, + activeDays: 2, + activeMembers: 3, + firstMessageTs: localTs(2024, 1, 1, 8), + lastMessageTs: localTs(2024, 1, 2, 11), + dataEarliestMessageTs: localTs(2023, 12, 31), + dataLatestMessageTs: localTs(2024, 1, 3, 0), + } + ) + assert.deepEqual( + facts.members.map((member) => ({ + memberId: member.memberId, + memberName: member.memberName, + messageCount: member.messageCount, + activeDays: member.activeDays, + })), + [ + { memberId: 1, memberName: 'Me', messageCount: 2, activeDays: 2 }, + { memberId: 2, memberName: 'Alice G', messageCount: 1, activeDays: 1 }, + { memberId: 3, memberName: 'Bob', messageCount: 1, activeDays: 1 }, + ] + ) + } finally { + db.close() + } +}) + +test('returns zero range facts while preserving the imported data cutoff', () => { + const db = createSession() + try { + addMessage(db, 1, 1, localTs(2024, 1, 1)) + + const facts = getCrossChatSessionActivityFacts(db, { + startTs: localTs(2025, 1, 1, 0), + endTs: localTs(2025, 12, 31, 23), + }) + + assert.equal(facts.totalMessages, 0) + assert.equal(facts.activeDays, 0) + assert.equal(facts.activeMembers, 0) + assert.equal(facts.firstMessageTs, null) + assert.equal(facts.lastMessageTs, null) + assert.deepEqual(facts.members, []) + assert.equal(facts.dataLatestMessageTs, localTs(2024, 1, 1)) + } finally { + db.close() + } +}) diff --git a/packages/core/src/query/contact-queries.ts b/packages/core/src/query/contact-queries.ts index c5b4afdb..b8779cbf 100644 --- a/packages/core/src/query/contact-queries.ts +++ b/packages/core/src/query/contact-queries.ts @@ -948,7 +948,7 @@ function nonSystemContactMemberCondition(db: DatabaseAdapter, memberAlias: strin ))` } -function nonSystemMessageCondition(db: DatabaseAdapter, messageAlias: string, memberAlias: string): string { +export function nonSystemMessageCondition(db: DatabaseAdapter, messageAlias: string, memberAlias: string): string { return `(${messageAlias}.type NOT IN (${SYSTEM_MESSAGE_TYPES_SQL}) AND ${nonSystemMemberIdentityCondition(db, memberAlias)})` } diff --git a/packages/core/src/query/cross-chat-statistics.ts b/packages/core/src/query/cross-chat-statistics.ts new file mode 100644 index 00000000..f966519c --- /dev/null +++ b/packages/core/src/query/cross-chat-statistics.ts @@ -0,0 +1,116 @@ +import type { DatabaseAdapter } from '../interfaces' +import { nonSystemMessageCondition } from './contact-queries' + +export interface CrossChatStatisticsQueryOptions { + startTs?: number | null + endTs?: number | null +} + +export interface CrossChatMemberActivityFacts { + memberId: number + platformId: string + memberName: string + messageCount: number + activeDays: number + firstMessageTs: number | null + lastMessageTs: number | null +} + +export interface CrossChatSessionActivityFacts { + totalMessages: number + activeDays: number + activeMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + dataEarliestMessageTs: number | null + dataLatestMessageTs: number | null + members: CrossChatMemberActivityFacts[] +} + +/** + * 返回一个会话在指定时间范围内的确定性活动事实。 + * 查询只读取计数、成员和时间,不物化聊天正文;跨会话聚合由 Node Runtime 负责。 + */ +export function getCrossChatSessionActivityFacts( + db: DatabaseAdapter, + options: CrossChatStatisticsQueryOptions = {} +): CrossChatSessionActivityFacts { + const timeFilter = createTimeFilter('msg', options) + const messageCondition = nonSystemMessageCondition(db, 'msg', 'm') + const summary = db + .prepare( + `SELECT + COUNT(*) as totalMessages, + COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as activeDays, + COUNT(DISTINCT msg.sender_id) as activeMembers, + MIN(msg.ts) as firstMessageTs, + MAX(msg.ts) as lastMessageTs + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${messageCondition}${timeFilter.sql}` + ) + .get(...timeFilter.params) as + | { + totalMessages: number + activeDays: number + activeMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + } + | undefined + + const members = db + .prepare( + `SELECT + msg.sender_id as memberId, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as memberName, + COUNT(*) as messageCount, + COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as activeDays, + MIN(msg.ts) as firstMessageTs, + MAX(msg.ts) as lastMessageTs + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${messageCondition}${timeFilter.sql} + GROUP BY msg.sender_id, m.platform_id, m.group_nickname, m.account_name + ORDER BY msg.sender_id` + ) + .all(...timeFilter.params) as unknown as CrossChatMemberActivityFacts[] + + const dataRange = db + .prepare( + `SELECT MIN(msg.ts) as firstMessageTs, MAX(msg.ts) as lastMessageTs + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${messageCondition}` + ) + .get() as { firstMessageTs: number | null; lastMessageTs: number | null } | undefined + + return { + totalMessages: summary?.totalMessages ?? 0, + activeDays: summary?.activeDays ?? 0, + activeMembers: summary?.activeMembers ?? 0, + firstMessageTs: summary?.firstMessageTs ?? null, + lastMessageTs: summary?.lastMessageTs ?? null, + dataEarliestMessageTs: dataRange?.firstMessageTs ?? null, + dataLatestMessageTs: dataRange?.lastMessageTs ?? null, + members, + } +} + +function createTimeFilter(alias: string, options: CrossChatStatisticsQueryOptions): { sql: string; params: number[] } { + const clauses: string[] = [] + const params: number[] = [] + if (typeof options.startTs === 'number') { + clauses.push(`${alias}.ts >= ?`) + params.push(options.startTs) + } + if (typeof options.endTs === 'number') { + clauses.push(`${alias}.ts <= ?`) + params.push(options.endTs) + } + return { + sql: clauses.length > 0 ? ` AND ${clauses.join(' AND ')}` : '', + params, + } +} diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index 654a905c..68634fc8 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -93,6 +93,13 @@ export { export { buildContactKey, shouldScopeContactToSession } from './contact-identity' +export { getCrossChatSessionActivityFacts } from './cross-chat-statistics' +export type { + CrossChatMemberActivityFacts, + CrossChatSessionActivityFacts, + CrossChatStatisticsQueryOptions, +} from './cross-chat-statistics' + export { aggregateAnnualSummaryFacts, getAnnualSummarySessionFacts } from './global-insight' export type { AnnualSummaryAggregatedData, diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 4cb29829..1935f952 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -706,6 +706,99 @@ export interface CrossChatOverviewResult { } } +export interface CrossChatStatisticsTimeRange extends CrossChatInspectionTimeRange { + currentTs: number +} + +export type CrossChatPrivateContactsRankBy = 'message_count' | 'active_days' + +export interface CrossChatPrivateContactsRankingRequest { + startTs?: number + endTs?: number + recentDays?: number + rankBy?: CrossChatPrivateContactsRankBy + limit?: number +} + +export interface CrossChatPrivateContactRankItem { + rank: number + contactKey: string + displayName: string + platform: ChatPlatform + totalMessages: number + ownerMessages: number + contactMessages: number + activeDays: number + firstMessageTs: number | null + lastMessageTs: number | null + sessionIds: string[] +} + +export interface CrossChatPrivateContactsRankingResult { + algorithmVersion: string + rankBy: CrossChatPrivateContactsRankBy + appliedRange: CrossChatStatisticsTimeRange + items: CrossChatPrivateContactRankItem[] + coverage: { + candidateSessions: number + scannedSessions: number + analyzedSessions: number + excludedSessions: number + missingOwnerSessions: number + unresolvedOwnerSessions: number + missingContactSessions: number + ambiguousContactSessions: number + failedSessions: number + failedSessionIds: string[] + complete: boolean + truncated: boolean + truncatedReasons: Array<'time_budget'> + } +} + +export type CrossChatGroupRankingMode = 'owner_activity' | 'total_activity' +export type CrossChatOwnerStatus = 'resolved' | 'missing' | 'unresolved' | 'excluded' + +export interface CrossChatGroupSessionsRankingRequest { + mode: CrossChatGroupRankingMode + startTs?: number + endTs?: number + recentDays?: number + limit?: number +} + +export interface CrossChatGroupSessionRankItem extends CrossChatSessionDescriptor { + rank: number + totalMessages: number + ownerMessages: number | null + ownerMessageShare: number | null + ownerActiveDays: number | null + activeMembers: number + activeDays: number + firstMessageTs: number | null + ownerStatus: CrossChatOwnerStatus +} + +export interface CrossChatGroupSessionsRankingResult { + algorithmVersion: string + mode: CrossChatGroupRankingMode + appliedRange: CrossChatStatisticsTimeRange + items: CrossChatGroupSessionRankItem[] + coverage: { + candidateSessions: number + scannedSessions: number + analyzedSessions: number + excludedSessions: number + missingOwnerSessions: number + unresolvedOwnerSessions: number + failedSessions: number + failedSessionIds: string[] + complete: boolean + truncated: boolean + truncatedReasons: Array<'time_budget'> + } +} + export type CrossChatParticipantRef = | { type: 'owner' } | {