diff --git a/packages/core/src/query/__tests__/contact-queries.test.ts b/packages/core/src/query/__tests__/contact-queries.test.ts index dff378c6..bc6ed952 100644 --- a/packages/core/src/query/__tests__/contact-queries.test.ts +++ b/packages/core/src/query/__tests__/contact-queries.test.ts @@ -236,6 +236,25 @@ describe('contact query helpers', () => { assert.equal(result.type === 'ok' ? result.lastMessageTs : null, 1704103200) }) + it('does not mark private sessions ambiguous when extra members are inactive in the selected range', () => { + raw.exec("UPDATE meta SET type = 'private'") + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 3, 1600000000, 0, 'old bob residue', 'old-bob') + insert.run(2, 1, 1704103200, 0, 'from owner', 'owner-1') + insert.run(3, 2, 1704103260, 0, 'from alice', 'alice-1') + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + const result = getPrivateContactFacts(db, owner.id, { startTs: 1700000000 }) + + assert.equal(result.type, 'ok') + assert.equal(result.type === 'ok' ? result.contact.platformId : null, 'alice-pid') + assert.equal(result.type === 'ok' ? result.privateMessageCount : null, 2) + }) + it('marks private sessions with multiple non-owner members as ambiguous', () => { raw.exec("UPDATE meta SET type = 'private'") const owner = resolveOwnerMember(db) diff --git a/packages/core/src/query/contact-queries.ts b/packages/core/src/query/contact-queries.ts index dc36dc31..afd518a1 100644 --- a/packages/core/src/query/contact-queries.ts +++ b/packages/core/src/query/contact-queries.ts @@ -127,9 +127,25 @@ export function getPrivateContactFacts( ): PrivateContactFacts { const candidates = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId) if (candidates.length === 0) return { type: 'missing' } - if (candidates.length > 1) return { type: 'ambiguous', candidates } const timeFilter = createMessageTimeFilter('msg', options.startTs) + const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate])) + const activeCandidateRows = db + .prepare( + `SELECT msg.sender_id as senderId + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition('msg', 'm')} + AND msg.sender_id <> ?${timeFilter.sql} + GROUP BY msg.sender_id` + ) + .all(ownerMemberId, ...timeFilter.params) as Array<{ senderId: number }> + const activeCandidates = activeCandidateRows + .map((row) => candidateById.get(row.senderId)) + .filter((candidate): candidate is ContactMemberRef => Boolean(candidate)) + const resolvedCandidates = activeCandidates.length > 0 ? activeCandidates : candidates + if (resolvedCandidates.length > 1) return { type: 'ambiguous', candidates: resolvedCandidates } + const countRow = db .prepare( `SELECT COUNT(*) as count @@ -160,7 +176,7 @@ export function getPrivateContactFacts( return { type: 'ok', - contact: candidates[0], + contact: resolvedCandidates[0], privateMessageCount: countRow?.count ?? 0, activeMonths: monthRows.map((row) => row.month).filter(Boolean), lastMessageTs: lastRow?.lastMessageTs ?? null, diff --git a/packages/node-runtime/src/services/people/relationships/compute.test.ts b/packages/node-runtime/src/services/people/relationships/compute.test.ts index 9ee1688b..ee2cdbcb 100644 --- a/packages/node-runtime/src/services/people/relationships/compute.test.ts +++ b/packages/node-runtime/src/services/people/relationships/compute.test.ts @@ -13,7 +13,7 @@ import { CHAT_DB_SCHEMA } from '@openchatlab/core' import type { DatabaseAdapter } from '@openchatlab/core' import { openBetterSqliteDatabase } from '../../../better-sqlite3-adapter' import type { SessionRuntimeAdapter } from '../../adapters' -import { computePeopleRelationshipsSnapshot } from './compute' +import { computePeopleRelationshipsSnapshot, PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION } from './compute' const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') @@ -180,7 +180,7 @@ test('computes a cropped relationship galaxy from private contacts and group int }, }) - assert.equal(snapshot.algorithmVersion, 'people-relationships-v1') + assert.equal(snapshot.algorithmVersion, PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION) const owner = snapshot.nodes.find((node) => node.kind === 'owner') assert.ok(owner) assert.equal(owner.displayName, 'Me') @@ -218,3 +218,97 @@ test('computes a cropped relationship galaxy from private contacts and group int assert.equal(snapshot.diagnostics.processedGroupSessions, 1) assert.equal(snapshot.diagnostics.processedPrivateSessions, 1) }) + +test('prefers recent owner edges when cropping dense relationship graph edges', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const day = 24 * 60 * 60 + const staleTs = 1700000000 + const recentTs = staleTs + day * 240 + + env.seed({ + id: 'private-stale', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'stale', accountName: 'Stale' }, + ], + messages: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: staleTs + index, + })), + }) + env.seed({ + id: 'private-recent', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'recent', accountName: 'Recent' }, + ], + messages: Array.from({ length: 60 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: recentTs + index, + })), + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 1, + }, + }) + + const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + const ownerEdges = snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key) + + assert.equal(ownerEdges.length, 1) + assert.equal(ownerEdges[0]?.targetKey, 'weixin:recent') +}) + +test('keeps enough owner edges for the collapsed connection ranking', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (let index = 0; index < 11; index++) { + const contactId = `contact-${index + 1}` + env.seed({ + id: `private-${contactId}`, + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: contactId, accountName: contactId }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200 + index * 100 }, + { id: 2, senderId: 2, ts: 1704103201 + index * 100 }, + ], + }) + } + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + }) + + const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + + assert.equal( + snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key).length >= 10, + true + ) +}) diff --git a/packages/node-runtime/src/services/people/relationships/compute.ts b/packages/node-runtime/src/services/people/relationships/compute.ts index 3d742ccb..790fd145 100644 --- a/packages/node-runtime/src/services/people/relationships/compute.ts +++ b/packages/node-runtime/src/services/people/relationships/compute.ts @@ -46,6 +46,8 @@ const PRIVATE_COMMUNITY_ID = 'private' const OWNER_KEY_PREFIX = 'owner' const REPLY_WEIGHT = 3 const CO_OCCURRENCE_COUNT_WEIGHT = 0.05 +const EDGE_RECENCY_HALF_LIFE_SECONDS = 120 * 24 * 60 * 60 +const EDGE_RECENCY_FLOOR = 0.1 const COMMUNITY_COLORS = [ '#7dd3fc', @@ -200,19 +202,18 @@ export function buildPeopleRelationshipsNeighborhoodGraph( const center = snapshot.nodes.find((node) => node.key === contactKey) if (!center) return { nodes: [], edges: [], communities: [] } - const relatedEdges = snapshot.edges - .filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey) - .sort(compareEdges) + const relatedEdges = sortEdgesForDisplay( + snapshot.edges.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey) + ) const nodeKeys = new Set([contactKey]) for (const edge of relatedEdges) { if (nodeKeys.size >= normalizedLimits.neighborhoodNodeLimit) break nodeKeys.add(edge.sourceKey === contactKey ? edge.targetKey : edge.sourceKey) } - const edges = snapshot.edges - .filter((edge) => nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey)) - .sort(compareEdges) - .slice(0, normalizedLimits.neighborhoodEdgeLimit) + const edges = sortEdgesForDisplay( + snapshot.edges.filter((edge) => nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey)) + ).slice(0, normalizedLimits.neighborhoodEdgeLimit) const nodes = snapshot.nodes.filter((node) => nodeKeys.has(node.key)).sort(compareNodes) return { nodes, @@ -564,7 +565,7 @@ function cropEdges( ): PeopleRelationshipGraphEdge[] { const counts = new Map() const kept: PeopleRelationshipGraphEdge[] = [] - for (const edge of edges) { + for (const edge of sortEdgesForDisplay(edges)) { if (kept.length >= limits.coreEdgeLimit) break const sourceCount = counts.get(edge.sourceKey) ?? 0 const targetCount = counts.get(edge.targetKey) ?? 0 @@ -963,7 +964,7 @@ function normalizeLimits(limits: PeopleRelationshipsComputeLimits = {}): Require return { coreNodeLimit: normalizePositiveLimit(limits.coreNodeLimit, 1500), coreEdgeLimit: normalizePositiveLimit(limits.coreEdgeLimit, 6000), - perNodeEdgeLimit: normalizePositiveLimit(limits.perNodeEdgeLimit, 8), + perNodeEdgeLimit: normalizePositiveLimit(limits.perNodeEdgeLimit, 12), neighborhoodNodeLimit: normalizePositiveLimit(limits.neighborhoodNodeLimit, 80), neighborhoodEdgeLimit: normalizePositiveLimit(limits.neighborhoodEdgeLimit, 240), searchResultLimit: normalizePositiveLimit(limits.searchResultLimit, 20), @@ -1025,6 +1026,30 @@ export function compareEdges(a: PeopleRelationshipGraphEdge, b: PeopleRelationsh return b.weight - a.weight || a.id.localeCompare(b.id) } +function sortEdgesForDisplay(edges: PeopleRelationshipGraphEdge[]): PeopleRelationshipGraphEdge[] { + const anchorTs = edges.reduce((max, edge) => Math.max(max, edge.lastInteractionTs ?? 0), 0) + return [...edges].sort((a, b) => compareEdgesForDisplay(a, b, anchorTs)) +} + +function compareEdgesForDisplay( + a: PeopleRelationshipGraphEdge, + b: PeopleRelationshipGraphEdge, + anchorTs: number +): number { + const bRecentWeight = getRecentEdgeWeight(b, anchorTs) + const aRecentWeight = getRecentEdgeWeight(a, anchorTs) + if (bRecentWeight !== aRecentWeight) return bRecentWeight - aRecentWeight + return compareEdges(a, b) +} + +function getRecentEdgeWeight(edge: PeopleRelationshipGraphEdge, anchorTs: number): number { + if (!edge.lastInteractionTs || anchorTs <= 0) return edge.weight + const ageSeconds = Math.max(0, anchorTs - edge.lastInteractionTs) + const recencyFactor = + EDGE_RECENCY_FLOOR + (1 - EDGE_RECENCY_FLOOR) * Math.pow(0.5, ageSeconds / EDGE_RECENCY_HALF_LIFE_SECONDS) + return edge.weight * recencyFactor +} + function roundNum(value: number, digits = 4): number { const factor = 10 ** digits return Math.round(value * factor) / factor diff --git a/packages/node-runtime/src/services/people/relationships/service.test.ts b/packages/node-runtime/src/services/people/relationships/service.test.ts index 9442e17f..ed56e446 100644 --- a/packages/node-runtime/src/services/people/relationships/service.test.ts +++ b/packages/node-runtime/src/services/people/relationships/service.test.ts @@ -139,6 +139,10 @@ function makeAdapter(signatureSeed = 'stable'): SessionRuntimeAdapter { } as SessionRuntimeAdapter } +function makeFreshSignature(): string { + return `algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}|range:1y|session-a:missing` +} + test('returns search results from all snapshot nodes, including nodes outside the core graph', () => { const dir = makeTempDir() try { @@ -149,7 +153,7 @@ test('returns search results from all snapshot nodes, including nodes outside th throw new Error('runner should not be called for fresh injected snapshot') }, }) - service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing')) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) const response = service.getGraph({ acceptStale: true, query: 'carol' }) @@ -176,7 +180,7 @@ test('returns the owner node from full snapshot search', () => { throw new Error('runner should not be called for fresh injected snapshot') }, }) - service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing')) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) const response = service.getGraph({ acceptStale: true, query: '我' }) @@ -199,7 +203,7 @@ test('returns neighborhood graph for a searched node outside the core graph', () throw new Error('runner should not be called for fresh injected snapshot') }, }) - service.replaceSnapshotForTests?.(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing')) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) const response = service.getNeighborhood('weixin:carol', { acceptStale: true }) @@ -235,7 +239,7 @@ test('starts a background task when snapshot is missing and returns task state i assert.equal(response.cache.status, 'missing') assert.equal(response.task?.status, 'running') assert.equal(runnerCalls, 1) - releaseRunner(makeSnapshot('algorithm:people-relationships-v1|range:1y|session-a:missing')) + releaseRunner(makeSnapshot(makeFreshSignature())) await new Promise((resolve) => setTimeout(resolve, 0)) await service.close() } finally { diff --git a/src/pages/people/relationships/relationship-galaxy-connections.test.ts b/src/pages/people/relationships/relationship-galaxy-connections.test.ts index 1d33b827..d2f7de5b 100644 --- a/src/pages/people/relationships/relationship-galaxy-connections.test.ts +++ b/src/pages/people/relationships/relationship-galaxy-connections.test.ts @@ -94,6 +94,22 @@ test('builds selected node connection ranking by edge weight', () => { assert.equal(ranking.items[0]?.edge.weight, 12) }) +test('prefers recent selected-node connections over stale high-volume edges', () => { + const day = 24 * 60 * 60 + const data: PeopleRelationshipsGraphData = { + nodes: [node('owner', 1), node('recent', 2), node('stale', 3)], + edges: [edge('owner', 'stale', 100, 100), edge('owner', 'recent', 60, 100 + day * 240)], + communities: [], + } + + const ranking = buildRelationshipConnectionRanking(data, 'owner', { expanded: true }) + + assert.deepEqual( + ranking.items.map((item) => item.node.key), + ['recent', 'stale'] + ) +}) + test('expands selected node connection ranking to all visible neighbors', () => { const ranking = buildRelationshipConnectionRanking(graph(), 'owner', { expanded: true }) diff --git a/src/pages/people/relationships/relationship-galaxy-connections.ts b/src/pages/people/relationships/relationship-galaxy-connections.ts index 460eae2f..5618a6e3 100644 --- a/src/pages/people/relationships/relationship-galaxy-connections.ts +++ b/src/pages/people/relationships/relationship-galaxy-connections.ts @@ -21,6 +21,8 @@ export interface RelationshipConnectionRanking { } const DEFAULT_COLLAPSED_LIMIT = 10 +const CONNECTION_RECENCY_HALF_LIFE_SECONDS = 120 * 24 * 60 * 60 +const CONNECTION_RECENCY_FLOOR = 0.1 export function buildRelationshipConnectionRanking( graph: PeopleRelationshipsGraphData, @@ -30,14 +32,14 @@ export function buildRelationshipConnectionRanking( if (!selectedKey) return { items: [], total: 0, hasMore: false } const nodeByKey = new Map(graph.nodes.map((node) => [node.key, node])) - const items = graph.edges - .flatMap((edge): RelationshipConnectionRankingItem[] => { - if (edge.sourceKey !== selectedKey && edge.targetKey !== selectedKey) return [] - const otherKey = edge.sourceKey === selectedKey ? edge.targetKey : edge.sourceKey - const node = nodeByKey.get(otherKey) - return node ? [{ node, edge }] : [] - }) - .sort(compareConnectionItems) + const items = graph.edges.flatMap((edge): RelationshipConnectionRankingItem[] => { + if (edge.sourceKey !== selectedKey && edge.targetKey !== selectedKey) return [] + const otherKey = edge.sourceKey === selectedKey ? edge.targetKey : edge.sourceKey + const node = nodeByKey.get(otherKey) + return node ? [{ node, edge }] : [] + }) + const anchorTs = items.reduce((max, item) => Math.max(max, item.edge.lastInteractionTs ?? 0), 0) + items.sort((a, b) => compareConnectionItems(a, b, anchorTs)) const collapsedLimit = options.collapsedLimit ?? DEFAULT_COLLAPSED_LIMIT const visibleItems = options.expanded ? items : items.slice(0, collapsedLimit) @@ -49,7 +51,14 @@ export function buildRelationshipConnectionRanking( } } -function compareConnectionItems(a: RelationshipConnectionRankingItem, b: RelationshipConnectionRankingItem): number { +function compareConnectionItems( + a: RelationshipConnectionRankingItem, + b: RelationshipConnectionRankingItem, + anchorTs: number +): number { + const bRecentWeight = getRecentConnectionWeight(b.edge, anchorTs) + const aRecentWeight = getRecentConnectionWeight(a.edge, anchorTs) + if (bRecentWeight !== aRecentWeight) return bRecentWeight - aRecentWeight if (b.edge.weight !== a.edge.weight) return b.edge.weight - a.edge.weight const bLast = b.edge.lastInteractionTs ?? 0 const aLast = a.edge.lastInteractionTs ?? 0 @@ -57,3 +66,12 @@ function compareConnectionItems(a: RelationshipConnectionRankingItem, b: Relatio if (a.node.rank !== b.node.rank) return a.node.rank - b.node.rank return a.node.key.localeCompare(b.node.key) } + +function getRecentConnectionWeight(edge: PeopleRelationshipGraphEdge, anchorTs: number): number { + if (!edge.lastInteractionTs || anchorTs <= 0) return edge.weight + const ageSeconds = Math.max(0, anchorTs - edge.lastInteractionTs) + const recencyFactor = + CONNECTION_RECENCY_FLOOR + + (1 - CONNECTION_RECENCY_FLOOR) * Math.pow(0.5, ageSeconds / CONNECTION_RECENCY_HALF_LIFE_SECONDS) + return edge.weight * recencyFactor +}