feat(insight): connect relationship galaxy across runtimes

This commit is contained in:
digua committed 2026-08-09 10:57:24 +08:00
1 parent 051203027a
commit 2148eaffdc
18 files changed
+193 -21

No files matched your search

+2
View File
@@ -38,6 +38,7 @@ import {
getLanguagePreferenceAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
@@ -211,6 +212,7 @@ const syncHandlers: Record<string, (payload: any) => any> = {
getLanguagePreferenceAnalysis: (p) => getLanguagePreferenceAnalysis(p),
getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter),
getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter),
getGroupRelationshipGalaxy: (p) => getGroupRelationshipGalaxy(p.sessionId, p.filter),
getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords),
getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options),
getRelationshipStats: (p) => getRelationshipStats(p.sessionId, p.filter, p.options),
@@ -5,7 +5,13 @@
export { getCatchphraseAnalysis } from './repeat'
export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social'
export {
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
} from './social'
export type {
MentionGraphData,
MentionGraphNode,
@@ -6,9 +6,12 @@ import { openDatabaseAdapter, type TimeFilter } from '../../core'
import {
getMentionAnalysis as coreGetMentionAnalysis,
getMentionGraph as coreGetMentionGraph,
getGroupRelationshipGalaxy as coreGetGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis as coreGetLaughAnalysis,
getClusterGraph as coreGetClusterGraph,
} from '@openchatlab/core'
import type { GroupRelationshipGalaxyData } from '@openchatlab/shared-types'
import type {
MentionGraphData,
MentionGraphNode,
@@ -41,6 +44,20 @@ export function getMentionGraph(sessionId: string, filter?: TimeFilter): Mention
return coreGetMentionGraph(db, filter)
}
export function getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): GroupRelationshipGalaxyData {
const db = openDatabaseAdapter(sessionId)
if (!db) {
return {
graph: { nodes: [], edges: [], communities: [] },
members: [],
edges: [],
stats: { totalMembers: 0, activeMembers: 0, displayedMembers: 0, displayedEdges: 0, communityCount: 0 },
algorithmVersion: GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
}
}
return coreGetGroupRelationshipGalaxy(db, filter)
}
export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): any {
const db = openDatabaseAdapter(sessionId)
if (!db)
+1
View File
@@ -36,6 +36,7 @@ export {
getLanguagePreferenceAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
getLaughAnalysis,
getClusterGraph,
getRelationshipStats,
@@ -406,6 +406,10 @@ export async function getMentionGraph(sessionId: string, filter?: any): Promise<
return sendToWorker('getMentionGraph', { sessionId, filter })
}
export async function getGroupRelationshipGalaxy(sessionId: string, filter?: any): Promise<any> {
return sendToWorker('getGroupRelationshipGalaxy', { sessionId, filter })
}
export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise<any> {
return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords })
}
@@ -8,6 +8,7 @@ describe('worker timeout policy', () => {
assert.equal(isRestartableReadOnlyRequestType('executeRawSQL'), true)
assert.equal(isRestartableReadOnlyRequestType('getSchema'), true)
assert.equal(isRestartableReadOnlyRequestType('getChatOverview'), true)
assert.equal(isRestartableReadOnlyRequestType('getGroupRelationshipGalaxy'), true)
assert.equal(isRestartableReadOnlyRequestType('searchMessages'), true)
assert.equal(isRestartableReadOnlyRequestType('getSegmentSummaries'), true)
assert.equal(isRestartableReadOnlyRequestType('analyzePushImport'), true)
@@ -17,6 +17,7 @@ const RESTARTABLE_READ_ONLY_REQUEST_TYPES = new Set([
'getLanguagePreferenceAnalysis',
'getMentionAnalysis',
'getMentionGraph',
'getGroupRelationshipGalaxy',
'getLaughAnalysis',
'getClusterGraph',
'getRelationshipStats',
@@ -62,6 +62,7 @@ const SESSION_ID = 'chat-1'
const MEMBER_ACTIVITY_URL = `/_web/sessions/${SESSION_ID}/stats/member-activity`
const JOURNEY_URL = `/_web/sessions/${SESSION_ID}/analytics/journey`
const KEYWORD_URL = `/_web/sessions/${SESSION_ID}/analytics/laugh`
const RELATIONSHIP_GALAXY_URL = `/_web/sessions/${SESSION_ID}/analytics/relationship-galaxy`
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
describe('analytics routes', () => {
@@ -76,8 +77,27 @@ describe('analytics routes', () => {
dbFile = path.join(root, `${SESSION_ID}.db`)
raw = new Database(dbFile, { nativeBinding })
raw.exec(`
CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT);
CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT, platform_message_id TEXT);
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 member_name_history (member_id INTEGER, name 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 (name, platform, type, imported_at, owner_id)
VALUES ('Group', 'wechat', 'group', 100, 'alice');
INSERT INTO member (id, platform_id, account_name) VALUES (1, 'alice', 'Alice'), (2, 'bob', 'Bob');
INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES
(1, 1, 100, 0, 'a', 'm-1'), (2, 2, 200, 0, 'b', 'm-2'), (3, 1, 300, 0, 'c', 'm-3');
@@ -207,4 +227,24 @@ describe('analytics routes', () => {
const differentRange = await app.inject({ method: 'GET', url: `${JOURNEY_URL}?startTs=200&endTs=300` })
assert.equal(differentRange.statusCode, 500)
})
it('serves the shared group relationship galaxy and keys its cache by time range', async () => {
const first = await app.inject({ method: 'GET', url: `${RELATIONSHIP_GALAXY_URL}?startTs=100&endTs=300` })
assert.equal(first.statusCode, 200)
assert.equal(first.json().algorithmVersion, 'group-relationship-galaxy-v1')
assert.equal(first.json().graph.nodes.length, 2)
assert.ok(first.json().graph.edges.length > 0)
raw.close()
const same = await app.inject({ method: 'GET', url: `${RELATIONSHIP_GALAXY_URL}?startTs=100&endTs=300` })
assert.equal(same.statusCode, 200)
assert.deepEqual(same.json(), first.json())
const differentRange = await app.inject({
method: 'GET',
url: `${RELATIONSHIP_GALAXY_URL}?startTs=200&endTs=300`,
})
assert.equal(differentRange.statusCode, 500)
})
})
@@ -24,6 +24,8 @@ import {
getCatchphraseAnalysis,
getMentionAnalysis,
getMentionGraph,
getGroupRelationshipGalaxy,
GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION,
getLaughAnalysis,
getClusterGraph,
getLanguagePreferenceAnalysis,
@@ -158,6 +160,21 @@ export function registerAnalyticsRoutes(server: FastifyInstance, ctx: AnalyticsR
}
)
server.get<{ Params: { id: string }; Querystring: FilteredQuery }>(
'/_web/sessions/:id/analytics/relationship-galaxy',
async (request) => {
const id = request.params.id
const filter = parseTimeFilter(request.query)
return cached(
'relationship-galaxy',
id,
{ ...filter },
() => getGroupRelationshipGalaxy(adapter.ensureReadonly(id), filter),
{ extraVersion: GROUP_RELATIONSHIP_GALAXY_ALGORITHM_VERSION }
)
}
)
server.post<{ Params: { id: string }; Querystring: FilteredQuery; Body: KeywordAnalysisBody }>(
'/_web/sessions/:id/analytics/laugh',
async (request) => {
@@ -1044,30 +1044,43 @@ describe('BrowserSessionRuntime', () => {
await runtime.importSource(source('insight-query.json', fixture), { formatId: 'chatlab' })
const [monthly, yearly, memberTrend, members, mentions, mentionGraph, clusterGraph, language, wordFrequency] =
await Promise.all([
runtime.getMonthlyActivity('insight-query-session'),
runtime.getYearlyActivity('insight-query-session'),
runtime.getMemberMonthlyTrend('insight-query-session'),
runtime.getMembers('insight-query-session'),
runtime.getMentionAnalysis('insight-query-session'),
runtime.getMentionGraph('insight-query-session'),
runtime.getClusterGraph('insight-query-session'),
runtime.getLanguagePreferenceAnalysis('insight-query-session', 'en-US'),
runtime.getWordFrequency('insight-query-session', {
locale: 'en-US',
topN: 10,
minCount: 2,
posFilterMode: 'all',
enableStopwords: false,
}),
])
const [
monthly,
yearly,
memberTrend,
members,
mentions,
mentionGraph,
relationshipGalaxy,
clusterGraph,
language,
wordFrequency,
] = await Promise.all([
runtime.getMonthlyActivity('insight-query-session'),
runtime.getYearlyActivity('insight-query-session'),
runtime.getMemberMonthlyTrend('insight-query-session'),
runtime.getMembers('insight-query-session'),
runtime.getMentionAnalysis('insight-query-session'),
runtime.getMentionGraph('insight-query-session'),
runtime.getGroupRelationshipGalaxy('insight-query-session'),
runtime.getClusterGraph('insight-query-session'),
runtime.getLanguagePreferenceAnalysis('insight-query-session', 'en-US'),
runtime.getWordFrequency('insight-query-session', {
locale: 'en-US',
topN: 10,
minCount: 2,
posFilterMode: 'all',
enableStopwords: false,
}),
])
assert.ok(monthly.length > 0)
assert.ok(yearly.length > 0)
assert.ok(memberTrend.length > 0)
assert.ok(members.length > 0)
assert.ok(mentions.totalMentions > 0)
assert.ok(mentionGraph.links.length > 0)
assert.ok(relationshipGalaxy.graph.edges.length > 0)
assert.equal(relationshipGalaxy.algorithmVersion, 'group-relationship-galaxy-v1')
assert.ok(clusterGraph.links.length > 0)
assert.ok(language.members.length > 0)
assert.ok(wordFrequency.words.length > 0)
@@ -9,6 +9,7 @@ import {
getMembersWithAliases as queryMembersWithAliases,
getMentionAnalysis as queryMentionAnalysis,
getMentionGraph as queryMentionGraph,
getGroupRelationshipGalaxy as queryGroupRelationshipGalaxy,
getAvailableYears as queryAvailableYears,
getDailyActivity as queryDailyActivity,
getHourlyActivity as queryHourlyActivity,
@@ -47,6 +48,7 @@ import {
type WordFrequencyResult,
type YearlyActivity,
} from '@openchatlab/core'
import type { GroupRelationshipGalaxyData } from '@openchatlab/shared-types'
import { PARSER_FORMAT_IDS } from '@openchatlab/parser/browser'
import { WebRuntimeError } from '../runtime-error'
import type { WorkspaceDatabasePort, WorkspaceDatabaseStage } from '../storage/workspace-database'
@@ -425,6 +427,10 @@ export class BrowserSessionRuntime {
return this.withSessionDatabase(id, (db) => queryMentionGraph(db, filter))
}
async getGroupRelationshipGalaxy(id: string, filter?: BrowserTimeFilter): Promise<GroupRelationshipGalaxyData> {
return this.withSessionDatabase(id, (db) => queryGroupRelationshipGalaxy(db, filter))
}
async getClusterGraph(
id: string,
filter?: BrowserTimeFilter,
+6
View File
@@ -30,6 +30,7 @@ import type {
WordFrequencyResult,
YearlyActivity,
} from '@openchatlab/core'
import type { GroupRelationshipGalaxyData } from '@openchatlab/shared-types'
import type {
BrowserImportFormatInfo,
BrowserMultiChatEntry,
@@ -231,6 +232,10 @@ export interface WebRuntimeTaskMap {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: MentionGraphData
}
'analysis.groupRelationshipGalaxy': {
payload: { sessionId: string; filter?: BrowserTimeFilter }
result: GroupRelationshipGalaxyData
}
'analysis.clusterGraph': {
payload: { sessionId: string; filter?: BrowserTimeFilter; options?: ClusterGraphOptions }
result: ClusterGraphData
@@ -381,6 +386,7 @@ const WEB_RUNTIME_TASK_TYPES: Record<WebRuntimeTaskType, true> = {
'analysis.memberList': true,
'analysis.mentions': true,
'analysis.mentionGraph': true,
'analysis.groupRelationshipGalaxy': true,
'analysis.clusterGraph': true,
'analysis.relationship': true,
'analysis.journey': true,
@@ -274,6 +274,22 @@ class FakeSessionRuntime implements WorkerSessionRuntime {
return { nodes: [], links: [], maxLinkValue: 0 }
}
async getGroupRelationshipGalaxy(_id: string, _filter?: BrowserTimeFilter) {
return {
graph: { nodes: [], edges: [], communities: [] },
members: [],
edges: [],
stats: {
totalMembers: 0,
activeMembers: 0,
displayedMembers: 0,
displayedEdges: 0,
communityCount: 0,
},
algorithmVersion: 'group-relationship-galaxy-v1',
}
}
async getClusterGraph(_id: string, _filter?: BrowserTimeFilter) {
return {
nodes: [],
@@ -804,6 +820,11 @@ describe('WebRuntimeWorkerController', () => {
{ id: 'members', type: 'analysis.memberList', payload: { sessionId: 'session-one' } },
{ id: 'mentions', type: 'analysis.mentions', payload: { sessionId: 'session-one' } },
{ id: 'mention-graph', type: 'analysis.mentionGraph', payload: { sessionId: 'session-one' } },
{
id: 'relationship-galaxy',
type: 'analysis.groupRelationshipGalaxy',
payload: { sessionId: 'session-one' },
},
{ id: 'cluster', type: 'analysis.clusterGraph', payload: { sessionId: 'session-one' } },
{ id: 'relationship', type: 'analysis.relationship', payload: { sessionId: 'session-one' } },
{ id: 'journey', type: 'analysis.journey', payload: { sessionId: 'session-one' } },
@@ -53,6 +53,7 @@ export type WorkerSessionRuntime = Pick<
| 'getMembers'
| 'getMentionAnalysis'
| 'getMentionGraph'
| 'getGroupRelationshipGalaxy'
| 'getClusterGraph'
| 'getRelationshipStats'
| 'getJourneyStats'
@@ -500,6 +501,15 @@ export class WebRuntimeWorkerController {
this.emitAnalysisQueryCompleted(request, startedAt)
return result
}
case 'analysis.groupRelationshipGalaxy': {
const startedAt = this.startAnalysisQuery()
const result = await this.sessionRuntime.getGroupRelationshipGalaxy(
request.payload.sessionId,
request.payload.filter
)
this.emitAnalysisQueryCompleted(request, startedAt)
return result
}
case 'analysis.clusterGraph': {
const startedAt = this.startAnalysisQuery()
const result = await this.sessionRuntime.getClusterGraph(
+15
View File
@@ -201,6 +201,19 @@ describe('BrowserDataAdapter', () => {
],
'analysis.mentions': { topMentioners: [], topMentioned: [], totalMentions: 0 },
'analysis.mentionGraph': { nodes: [], links: [], maxLinkValue: 0 },
'analysis.groupRelationshipGalaxy': {
graph: { nodes: [], edges: [], communities: [] },
members: [],
edges: [],
stats: {
totalMembers: 0,
activeMembers: 0,
displayedMembers: 0,
displayedEdges: 0,
communityCount: 0,
},
algorithmVersion: 'group-relationship-galaxy-v1',
},
'analysis.clusterGraph': {
nodes: [],
links: [],
@@ -225,6 +238,7 @@ describe('BrowserDataAdapter', () => {
assert.equal((await adapter.getMembers('session-one'))[0].accountName, 'Alice')
assert.equal((await adapter.getMentionAnalysis('session-one')).totalMentions, 0)
assert.equal((await adapter.getMentionGraph('session-one')).links.length, 0)
assert.equal((await adapter.getGroupRelationshipGalaxy('session-one')).stats.displayedEdges, 0)
assert.equal((await adapter.getClusterGraph('session-one')).stats.edgeCount, 0)
assert.equal((await adapter.getRelationshipStats('session-one')).hasSessionIndex, true)
assert.equal((await adapter.getJourneyStats('session-one')).hasSessionIndex, true)
@@ -240,6 +254,7 @@ describe('BrowserDataAdapter', () => {
'analysis.memberList',
'analysis.mentions',
'analysis.mentionGraph',
'analysis.groupRelationshipGalaxy',
'analysis.clusterGraph',
'analysis.relationship',
'analysis.journey',
+5
View File
@@ -48,6 +48,7 @@ type BrowserSessionDataAdapter = Pick<
| 'getMembers'
| 'getMentionAnalysis'
| 'getMentionGraph'
| 'getGroupRelationshipGalaxy'
| 'getClusterGraph'
| 'getRelationshipStats'
| 'getJourneyStats'
@@ -156,6 +157,10 @@ export class BrowserDataAdapter implements BrowserSessionDataAdapter {
return this.requestAnalysis('analysis.mentionGraph', { sessionId, filter })
}
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter) {
return this.requestAnalysis('analysis.groupRelationshipGalaxy', { sessionId, filter })
}
getClusterGraph(sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions): Promise<ClusterGraphData> {
return this.requestAnalysis('analysis.clusterGraph', { sessionId, filter, options })
}
+5
View File
@@ -14,6 +14,7 @@ import type {
PeopleRelationshipsGraphResponse,
PeopleRelationshipsNeighborhoodResponse,
AnnualSummaryResponse,
GroupRelationshipGalaxyData,
} from '@openchatlab/shared-types'
import type {
MemberActivity,
@@ -396,6 +397,10 @@ export class FetchDataAdapter implements DataAdapter {
return analyticsGet(`/sessions/${sessionId}/analytics/mention-graph${buildFilterParams(filter)}`)
}
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): Promise<GroupRelationshipGalaxyData> {
return analyticsGet(`/sessions/${sessionId}/analytics/relationship-galaxy${buildFilterParams(filter)}`)
}
getClusterGraph(sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions): Promise<ClusterGraphData> {
const params = new URLSearchParams()
if (filter?.startTs) params.set('startTs', String(filter.startTs))
+2
View File
@@ -19,6 +19,7 @@ import type {
PeopleRelationshipsNeighborhoodResponse,
AnnualSummaryMode,
AnnualSummaryResponse,
GroupRelationshipGalaxyData,
} from '@openchatlab/shared-types'
import type { JourneyStats, WordFrequencyParams, WordFrequencyResult } from '@openchatlab/core'
import type {
@@ -235,6 +236,7 @@ export interface DataAdapter {
): Promise<LanguagePreferenceResult>
getMentionAnalysis(sessionId: string, filter?: TimeFilter): Promise<MentionAnalysis>
getMentionGraph(sessionId: string, filter?: TimeFilter): Promise<MentionGraphData>
getGroupRelationshipGalaxy(sessionId: string, filter?: TimeFilter): Promise<GroupRelationshipGalaxyData>
getClusterGraph(sessionId: string, filter?: TimeFilter, options?: ClusterGraphOptions): Promise<ClusterGraphData>
getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): Promise<LaughAnalysis>
getRelationshipStats(