mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(ai): add cross-chat statistics facts
This commit is contained in:
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()
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
| {
|
||||
|
||||
Reference in new issue
Block a user