fix(relationships): rank active close contacts accurately

This commit is contained in:
digua committed 2026-06-30 14:41:46 +08:00
1 parent 3afc2a7329
commit d04eddc37c
7 files changed
+218 -26

No files matched your search

@@ -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)
+18 -2
View File
@@ -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,
@@ -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
)
})
@@ -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<string>([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<string, number>()
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
@@ -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 {
@@ -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 })
@@ -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
}