feat(ai): add cross-chat statistics facts

This commit is contained in:
digua committed 2026-08-24 00:25:35 +08:00
1 parent 75eff70e23
commit 3b50a722c9
5 files changed
+389 -1

No files matched your search

@@ -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<string, unknown> | undefined
}
all(...params: unknown[]) {
return this.statement.all(...params) as Record<string, unknown>[]
}
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<T>(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()
}
})
+1 -1
View File
@@ -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)})`
}
@@ -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,
}
}
+7
View File
@@ -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,
+93
View File
@@ -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' }
| {